feat(planning): vue mobile dédiée "un jour à la fois" pour la grille (#88)
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m55s
CI / intent-service-test (push) Successful in 4m19s
CI / test (push) Failing after 4m23s
CI / e2e (push) Successful in 8m41s
CI / build-android (push) Has been skipped

La grille planning (7 colonnes × 5 repas, min-width: 62rem) était le
principal écran inaccessible sur téléphone : sous ce seuil elle ne se
réorganisait pas, elle scrollait juste horizontalement — un tableau
desktop rétréci, pas un vrai écran mobile.

Sous le breakpoint phone (useIsMobileViewport()), PlanningPage bascule
désormais entièrement sur PlanningDayView : un sélecteur de jour (jour
précédent/suivant, borné au lundi/dimanche de la semaine affichée, pas
de bouclage vers la semaine adjacente — ça reste le rôle des flèches du
WeekNavigator) suivi des 5 repas du jour sélectionné, empilés en
cartes.

Le contenu de chaque case (pastilles de recettes + bouton "+") est
mutualisé entre les deux layouts via une fonction non-hook partagée,
mealCellBody, appelée aussi bien par MealCell (<td>, grille desktop)
que par le nouveau MealCellRow (<div>, vue jour) — même logique de
drop-target @dnd-kit (useDroppable) des deux côtés, donc le glisser-
déposer d'un reste continue de fonctionner à l'identique sur mobile.

Mise à jour de layout.cy.ts : son test #39 "la grille reste scrollable
horizontalement sur phone" décrivait un comportement désormais remplacé
par ce ticket — réécrit pour vérifier la bascule vers la vue jour au
lieu du scroll horizontal.

Closes #88

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-07 16:27:46 +02:00
parent 23bc19b482
commit 964269d3f3
5 changed files with 485 additions and 75 deletions

View file

@ -276,7 +276,13 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
}); });
}); });
it("keeps the 7-day planning grid on its own horizontal scroll rather than overflowing the page", () => { // Until issue #88, a phone viewport just let the 7-column grid scroll
// horizontally inside `.planning-grid-wrapper` (a shrunk desktop table,
// not a real mobile layout). #88 replaces it outright with
// `PlanningDayView` below the phone breakpoint — this test now covers
// *that* switch; the day view's own day-by-day content is
// PlanningPage.tsx's own spec (planning-page.cy.ts).
it("shows the one-day mobile view instead of the horizontally-scrolling grid, on a phone", () => {
cy.intercept("GET", /\/planning\?/, { cy.intercept("GET", /\/planning\?/, {
statusCode: 200, statusCode: 200,
body: { body: {
@ -301,22 +307,22 @@ describe("Responsive breakpoint — page content under 640px (issue #39)", () =>
cy.setViewport("phone"); cy.setViewport("phone");
cy.visit("/"); cy.visit("/");
// The grid wrapper is the scroll container, and its content is genuinely // The desktop grid is gone entirely, not just visually — the day view
// wider than the phone viewport (7 columns never crushed). // takes over the whole page.
cy.get(".planning-grid-wrapper").should(($el) => { cy.get(".planning-grid-wrapper").should("not.exist");
expect(getComputedStyle($el[0]).overflowX).to.be.oneOf(["auto", "scroll"]); cy.get(".planning-day-view").should("be.visible");
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth); cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
});
// ...and that overflow stays *inside* the wrapper — the document itself // The document itself never scrolls sideways (global rule against a
// never scrolls sideways (global rule against a horizontally-scrolling // horizontally-scrolling body) — still true for the day view's own
// body). // stacked-card layout, same as every other page.
cy.document() cy.document()
.its("documentElement.scrollWidth") .its("documentElement.scrollWidth")
.should("be.lte", PHONE_VIEWPORT.width + 1); .should("be.lte", PHONE_VIEWPORT.width + 1);
// The per-recipe remove "✕" is hover-gated on desktop; on touch it must // The per-recipe remove "✕" is hover-gated on desktop; on touch it must
// be permanently visible or a planned recipe can't be removed at all. // be permanently visible or a planned recipe can't be removed at all —
// same shared `.recipe-chip__remove` class either layout renders.
cy.get(".recipe-chip__remove") cy.get(".recipe-chip__remove")
.first() .first()
.should(($btn) => { .should(($btn) => {

View file

@ -219,3 +219,84 @@ describe("Planning grid", () => {
cy.get(".bottom-sheet").should("not.exist"); cy.get(".bottom-sheet").should("not.exist");
}); });
}); });
// Ticket #88 — the 7-column grid gives way entirely to `PlanningDayView` on
// a phone (not the same table, scrolled): a switcher plus one day's meals
// stacked as cards. `.recipe-chip`/`.add-recipe-btn` are the same elements
// either layout renders (see `mealCellBody`, shared by `MealCell` and
// `MealCellRow` in PlanningPage.tsx), so this only covers what's actually
// different here — day selection, its clamped edges, and that the day
// view's own "+" still opens the real recipe picker.
describe("Planning day view (phone)", () => {
beforeEach(() => {
cy.setViewport("phone");
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("defaults to today, shows only that day's recipes, and clamps at Monday", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{
id: 2,
weekDay: "mardi",
meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
}).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
// The table is gone entirely on phone, not just visually hidden.
cy.get("table.planning-grid").should("not.exist");
cy.get(".planning-day-view").should("be.visible");
// Today (17 août, Lundi) is the default day — only its own recipe shows.
cy.contains(".planning-day-view__label", "Lundi").should("be.visible");
cy.contains(".planning-day-view__label", "17").should("be.visible");
cy.contains(".planning-day-view__label .today-badge", "Aujourd'hui").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("not.exist");
// Clamped, not wrapping: Monday is the week's first day.
cy.get(".planning-day-view__arrow").first().should("be.disabled");
cy.get(".planning-day-view__arrow").last().click();
cy.contains(".planning-day-view__label", "Mardi").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("not.exist");
cy.get(".planning-day-view__arrow").first().should("not.be.disabled");
});
it("clamps at Sunday going forward, and its own '+' opens the recipe picker", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
for (let i = 0; i < 6; i++) {
cy.get(".planning-day-view__arrow").last().click();
}
cy.contains(".planning-day-view__label", "Dimanche").should("be.visible");
cy.get(".planning-day-view__arrow").last().should("be.disabled");
cy.get(".add-recipe-btn").first().click();
cy.get(".recipe-picker-dialog").should("be.visible");
cy.contains(".recipe-picker-dialog", "Choisir une recette").should("be.visible");
});
});

View file

@ -9,6 +9,11 @@
"nextWeek": "Semaine suivante", "nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}" "label": "Semaine du {{range}}"
}, },
"dayNav": {
"prevDay": "Jour précédent",
"nextDay": "Jour suivant",
"today": "Aujourd'hui"
},
"calendar": { "calendar": {
"title": "Choisir une semaine", "title": "Choisir une semaine",
"prevMonth": "Mois précédent", "prevMonth": "Mois précédent",

View file

@ -21,7 +21,7 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useEffect, useRef, useState } from "react"; import { type ReactNode, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
@ -32,6 +32,7 @@ import type { LeftoverDragData } from "../../features/planning/leftovers-basket"
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { useTour } from "../../features/tour/TourContext"; import { useTour } from "../../features/tour/TourContext";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import "./planning-page.scss"; import "./planning-page.scss";
@ -63,6 +64,13 @@ type PlanningState =
/** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */ /** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */
const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]); const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]);
/** Index (0=Monday..6=Sunday) of `weekStart`'s week that contains today, or `0` if today falls outside it — the sensible default for "which day should the mobile day view open on". */
function defaultDayIndexForWeek(weekStart: DateTime): number {
const today = toDateOnly(DateTime.utc());
const diffDays = Math.round(today.diff(weekStart, "days").days);
return diffDays >= 0 && diffDays <= 6 ? diffDays : 0;
}
/** /**
* Landing page for an authenticated visitor — the household's planning for * Landing page for an authenticated visitor — the household's planning for
* a selectable week, laid out as a grid (days × meals). Behind * a selectable week, laid out as a grid (days × meals). Behind
@ -75,11 +83,27 @@ const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gou
* showing just its "+" button, already communicates that. The "+" itself * showing just its "+" button, already communicates that. The "+" itself
* isn't wired to anything yet (no recipe catalog to search — see the * isn't wired to anything yet (no recipe catalog to search — see the
* planning page's plan/PR description) — a future task. * planning page's plan/PR description) — a future task.
*
* On a phone, the 7-column grid gives way to {@link PlanningDayView} — one
* day at a time with a switcher, not the same grid just scrolled
* horizontally (see the mobile/tablet UI chantier's own audit: a 62rem-wide
* table forced into a phone viewport was the actual reason some planning
* controls were unreachable). Both views share the *same* `weekStart`/
* `planning` state and the same {@link MealCell} drop-target logic — only
* the layout differs, decided once via {@link useIsMobileViewport} rather
* than duplicating any of the data/DnD wiring below.
*/ */
export function PlanningPage() { export function PlanningPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobileViewport();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc())); const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
// Which day the mobile view is showing — irrelevant on desktop (the grid
// always shows the whole week), but kept in this shared parent rather
// than local to `PlanningDayView` so switching to mobile mid-session
// (e.g. rotating a tablet past the phone breakpoint) doesn't need to
// re-derive it from scratch on first mobile render only.
const [selectedDayIndex, setSelectedDayIndex] = useState(() => defaultDayIndexForWeek(weekStart));
const [state, setState] = useState<PlanningState>({ status: "loading" }); const [state, setState] = useState<PlanningState>({ status: "loading" });
// The slot a `RecipePickerDialog` is currently open for — `null` means // The slot a `RecipePickerDialog` is currently open for — `null` means
// closed. Mounting the dialog only while this is set (rather than an // closed. Mounting the dialog only while this is set (rather than an
@ -136,6 +160,14 @@ export function PlanningPage() {
}; };
}, [weekStart]); }, [weekStart]);
// A new week means "which day" needs re-deriving too — otherwise jumping
// from this week to next week while showing "Mercredi" would silently
// keep showing index 2 (now next Wednesday) instead of resetting to
// today-if-in-week-else-Monday, same default the initial render uses.
useEffect(() => {
setSelectedDayIndex(defaultDayIndexForWeek(weekStart));
}, [weekStart]);
/** /**
* Applies `updater` to the currently loaded planning's items, patching * Applies `updater` to the currently loaded planning's items, patching
* local state without a full `GET /planning` refetch — same philosophy * local state without a full `GET /planning` refetch — same philosophy
@ -248,14 +280,24 @@ export function PlanningPage() {
</p> </p>
)} )}
{state.status === "loaded" && ( {state.status === "loaded" &&
(isMobile ? (
<PlanningDayView
weekStart={weekStart}
selectedDayIndex={selectedDayIndex}
onSelectDayIndex={setSelectedDayIndex}
planning={state.planning}
onAddSlot={setOpenSlot}
onRemoveItem={handleRemove}
/>
) : (
<PlanningGrid <PlanningGrid
weekStart={weekStart} weekStart={weekStart}
planning={state.planning} planning={state.planning}
onAddSlot={setOpenSlot} onAddSlot={setOpenSlot}
onRemoveItem={handleRemove} onRemoveItem={handleRemove}
/> />
)} ))}
{openSlot && ( {openSlot && (
<RecipePickerDialog <RecipePickerDialog
@ -278,7 +320,7 @@ export function PlanningPage() {
); );
} }
/** The week grid itself — 7 day columns × 5 meal rows. */ /** The week grid itself — 7 day columns × 5 meal rows. Desktop/tablet only, see {@link PlanningDayView} for the phone equivalent. */
function PlanningGrid({ function PlanningGrid({
weekStart, weekStart,
planning, planning,
@ -336,7 +378,111 @@ function PlanningGrid({
); );
} }
/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. Also a `@dnd-kit` drop target for a leftover card. */ /**
* Phone equivalent of {@link PlanningGrid} — one day at a time (a switcher
* up top, the day's 5 meals stacked below) instead of a 7-column table
* that would need `min-width: 62rem` and a permanent horizontal scroll to
* stay legible. Same `planning`/`onAddSlot`/`onRemoveItem` contract as the
* grid; `selectedDayIndex` is lifted to `PlanningPage` (see its own doc
* comment on why) rather than owned here.
*
* The switcher clamps at the week's own edges (Monday/Sunday) rather than
* wrapping or rolling into the next/previous week — crossing a week
* boundary here would need to also change `weekStart` (a real refetch),
* which `WeekNavigator`'s own arrows already do; conflating the two would
* make it unclear which control moves by a day and which by a week.
*/
function PlanningDayView({
weekStart,
selectedDayIndex,
onSelectDayIndex,
planning,
onAddSlot,
onRemoveItem,
}: {
weekStart: DateTime;
selectedDayIndex: number;
onSelectDayIndex: (index: number) => void;
planning: PlanningView | null;
onAddSlot: (slot: PlanningSlot) => void;
onRemoveItem: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
const today = toDateOnly(DateTime.utc());
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
const selectedDay = days[selectedDayIndex];
// `selectedDayIndex` only ever moves via the switcher below (clamped to
// [0, 6] by its own disabled state) or `defaultDayIndexForWeek` (same
// range), and `days` always has exactly WEEK_DAYS.length === 7 entries —
// so this is unreachable in practice. `noUncheckedIndexedAccess` can't
// see that though, and a real guard is preferred over a `days[i]!`
// assertion (see specs/dev-conventions.md's `noNonNullAssertion` rule).
if (!selectedDay) return null;
const { weekDay, date } = selectedDay;
const isToday = date.hasSame(today, "day");
const items = planning?.items ?? [];
return (
<div className="planning-day-view">
<div className="planning-day-view__switcher">
<button
type="button"
className="planning-day-view__arrow"
title={t("common.dayNav.prevDay")}
disabled={selectedDayIndex === 0}
onClick={() => onSelectDayIndex(selectedDayIndex - 1)}
>
‹
</button>
<span className={isToday ? "planning-day-view__label today" : "planning-day-view__label"}>
{t(`common.days.${weekDay}`)} {date.day}
{isToday && <span className="today-badge">{t("common.dayNav.today")}</span>}
</span>
<button
type="button"
className="planning-day-view__arrow"
title={t("common.dayNav.nextDay")}
disabled={selectedDayIndex === 6}
onClick={() => onSelectDayIndex(selectedDayIndex + 1)}
>
›
</button>
</div>
<ul className="planning-day-view__meals" data-tour="planning-grid">
{MEALS.map((meal, mealIndex) => (
<li key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
<MealCellRow
mealLabel={t(`planning.meals.${meal}`)}
isToday={isToday}
date={formatDateOnly(date)}
weekDay={weekDay}
meal={meal}
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
onRemove={onRemoveItem}
isTourAddTarget={mealIndex === 0}
/>
</li>
))}
</ul>
</div>
);
}
/** Props shared by both meal-cell layouts — everything except the wrapping element itself, which differs (a `<td>` for the grid vs. a plain `<div>` card for the day view). */
interface MealCellSharedProps {
isToday: boolean;
date: string;
weekDay: WeekDay;
meal: Meal;
items: PlanningItemView[];
onAdd: () => void;
onRemove: (item: PlanningItemView) => void;
isTourAddTarget?: boolean;
}
/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. Also a `@dnd-kit` drop target for a leftover card. Desktop/tablet grid layout — see {@link MealCellRow} for the phone day-view equivalent, which shares this same content via {@link mealCellBody}. */
function MealCell({ function MealCell({
isToday, isToday,
date, date,
@ -346,16 +492,7 @@ function MealCell({
onAdd, onAdd,
onRemove, onRemove,
isTourAddTarget = false, isTourAddTarget = false,
}: { }: MealCellSharedProps) {
isToday: boolean;
date: string;
weekDay: WeekDay;
meal: Meal;
items: PlanningItemView[];
onAdd: () => void;
onRemove: (item: PlanningItemView) => void;
isTourAddTarget?: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
id: `cell-${weekDay}-${meal}`, id: `cell-${weekDay}-${meal}`,
@ -368,6 +505,69 @@ function MealCell({
return ( return (
<td ref={setNodeRef} className={className}> <td ref={setNodeRef} className={className}>
{mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })}
</td>
);
}
/**
* Same drop target and content as {@link MealCell}, laid out as a labeled
* row/card for {@link PlanningDayView} instead of a table cell — a phone
* viewport shows one day's meals stacked, each needing its own "Déjeuner"/
* "Dîner"/… heading that the grid gets for free from its own header row.
*/
function MealCellRow({
mealLabel,
isToday,
date,
weekDay,
meal,
items,
onAdd,
onRemove,
isTourAddTarget = false,
}: MealCellSharedProps & { mealLabel: string }) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({
id: `cell-${weekDay}-${meal}`,
data: { date, weekDay, meal } satisfies CellDropData,
});
const className = [
"meal-cell",
"meal-day-row",
isToday ? "today" : "",
isOver ? "meal-cell--drop-over" : "",
]
.filter(Boolean)
.join(" ");
return (
<div ref={setNodeRef} className={className}>
<h3 className="meal-day-row__label">{mealLabel}</h3>
{mealCellBody({ items, onAdd, onRemove, isTourAddTarget, t })}
</div>
);
}
/**
* The actual recipe-chips-plus-"+"-button content shared by both
* {@link MealCell} (a `<td>`) and {@link MealCellRow} (a `<div>`) — a plain
* function, not a component: it renders no hooks of its own, so calling it
* directly from each wrapper's render (rather than mounting it as a nested
* component) avoids a pointless extra element in the tree for something
* that's really just shared markup.
*/
function mealCellBody({
items,
onAdd,
onRemove,
isTourAddTarget,
t,
}: Pick<MealCellSharedProps, "items" | "onAdd" | "onRemove" | "isTourAddTarget"> & {
t: (key: string) => string;
}): ReactNode {
return (
<div className="meal-cell__content"> <div className="meal-cell__content">
{items.length > 0 && ( {items.length > 0 && (
<div className="meal-cell__recipes"> <div className="meal-cell__recipes">
@ -411,6 +611,5 @@ function MealCell({
+ +
</button> </button>
</div> </div>
</td>
); );
} }

View file

@ -267,6 +267,125 @@ table.planning-grid {
} }
} }
// --- Vue mobile "un jour à la fois" (issue #88) -----------------------------
// Remplace entièrement `.planning-grid-wrapper`/`table.planning-grid` sous le
// breakpoint phone (voir `useIsMobileViewport()` dans PlanningPage.tsx) — pas
// une variante CSS de la même table, un DOM différent (switcher + liste de
// cartes), le tableau 7 colonnes n'ayant pas de mise en page lisible en
// dessous de son `min-width: 62rem`. `.meal-cell`/`.meal-cell__content`/
// `.recipe-chip`/`.add-recipe-btn` ci-dessus restent utilisés tels quels
// (voir `mealCellBody` partagé côté TSX) — seul l'habillage `.meal-day-row`
// autour change.
.planning-day-view {
display: flex;
flex-direction: column;
gap: var(--space-md);
flex: 1;
min-height: 0;
}
.planning-day-view__switcher {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.planning-day-view__arrow {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border: none;
border-radius: 50%;
background: none;
color: var(--color-text);
font-size: var(--font-size-lg);
line-height: 1;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--color-surface-alt);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.planning-day-view__label {
flex: 1;
text-align: center;
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-text);
&.today {
color: var(--color-primary);
}
.today-badge {
display: inline-block;
margin-left: var(--space-xs);
padding: 0.1rem 0.5rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-size: var(--font-size-xs);
font-weight: 600;
vertical-align: middle;
}
}
.planning-day-view__meals {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-sm);
list-style: none;
margin: 0;
padding: 0 0 var(--space-md);
li.band-end {
margin-bottom: var(--space-sm);
}
}
.meal-day-row {
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
&.today {
box-shadow: 0 0 0 1px var(--color-primary), var(--shadow-sm);
}
&.meal-cell--drop-over {
background: var(--color-tag);
outline: 2px dashed var(--color-primary);
outline-offset: -2px;
}
&__label {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
font-weight: 600;
}
}
// --- Phone (issue #39, responsive pass) ----------------------------------- // --- Phone (issue #39, responsive pass) -----------------------------------
// The grid itself already degrades gracefully on a narrow screen — the // The grid itself already degrades gracefully on a narrow screen — the
// wrapper's `overflow: auto` + the table's `min-width: 62rem` mean it just // wrapper's `overflow: auto` + the table's `min-width: 62rem` mean it just