/* ==========================================================================
   THE MEALS TAB, IN CODE — /meal-planner/
   One recreation of the wall's Meals tab, used by both animated sections on
   this page. Each supplies only a camera track; the tab is authored once by
   tools/meal-build-tab.py and styled here.

   The camera, the bezel and the screen are NOT here — they are the shared
   engine in wall-demo.css, which must be linked before this file.

   What ships is the tablet-landscape layout, because that is the only wide
   one the app has: calcViewport (ResponsiveWidgetGrid.jsx:12) returns no
   'desktop' value at all. So `meal_calendar` is the full-width anchor,
   `grocery_planner` is colSpan 3 at a fixed 588, the tab padding is 16 and
   the two are 8 apart.

   Palette is the wall's honey theme — the same warm set /chores/ and
   /family-calendar/ use, so the three pages depict one wall.
   ========================================================================== */

/* ── the tab ────────────────────────────────────────────────────────────── */
.mt {
  width: 1284px;
  padding: 16px;                 /* TabWidgetGrid: p-3 sm:p-4 */
  display: flex;
  flex-direction: column;
  gap: 8px;                      /* space-y-2 between anchor and grid */
  background: #fdf9f3;
  font-family: inherit;
  color: #33302c;
}

.mt-card {
  background: #fff;
  border: 1px solid rgba(0, 0, 0, 0.07);
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(160, 100, 0, 0.12);
}

/* ══ MEAL PLANNER ═══════════════════════════════════════════════════════ */

/* ── header ─────────────────────────────────────────────────────────────── */
/* px-4 py-2.5, and the stepper is the same 18px control the calendar wears. */
.mw-head { display: flex; align-items: center; gap: 8px; padding: 10px 16px; }
.mw-title {
  font-size: 12px; font-weight: 600; letter-spacing: 0.04em;
  text-transform: uppercase; color: #b4772a; white-space: nowrap;
}
.mw-step { display: flex; align-items: center; gap: 4px; }
.mw-step i {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; border-radius: 3px;
  background: rgba(255, 255, 255, 0.75); border: 1px solid rgba(0, 0, 0, 0.07);
  font-size: 10px; line-height: 1; font-style: normal; color: #8a7f6d;
}
.mw-step b { min-width: 90px; text-align: center; font-size: 10px; font-weight: 400; color: #9a8f7d; }
.mw-spacer { flex: 1 1 0; min-width: 0; }

/* "Suggest again", not "Plan my week": mode is `gapCount > 0 ? "plan" :
   "reroll"` (useMealSuggestions.js:256), and every open slot here already
   holds a proposal, so the gap count is nought — and stays nought as they are
   accepted. */
.mw-plan {
  display: inline-flex; align-items: center; gap: 4px;
  height: 24px; padding: 0 9px; border-radius: 999px;
  background: #d97706; color: #fff; font-size: 10px; font-weight: 800;
}
.mw-plan svg { width: 11px; height: 11px; }
.mw-plan b { font-weight: 800; }
/* The press the controls get.

   The app gives none of them any feedback of its own: a tap is instantaneous
   there and the RESULT is the feedback. On screen there is no finger, so a
   control that changes nothing before its consequence reads as the consequence
   happening by itself. These are invented, deliberately, and they are the only
   invented UI on the page.

   Each settles on its pressed colour (`forwards`) rather than springing back:
   the tick stays green for the 300ms until the card becomes a planned meal,
   and the pencil stays lit while the drawer it opened is on screen, which is
   what says which slot is being filled. */
.mw-plan.is-press { animation: ct-press 340ms ease; }

@keyframes mw-tap-ok {
  0%   { transform: scale(1);   background-color: #d97706; box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); }
  30%  { transform: scale(.80); background-color: #15803d; box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45); }
  100% { transform: scale(1);   background-color: #15803d; box-shadow: 0 0 0 11px rgba(22, 163, 74, 0); }
}
@keyframes mw-tap-ed {
  0%   { transform: scale(1);   background-color: #fbf7ec; color: #8a7f6d;
         box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.5); }
  30%  { transform: scale(.80); background-color: #d97706; color: #fff;
         box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.4); }
  100% { transform: scale(1);   background-color: #d97706; color: #fff;
         box-shadow: 0 0 0 11px rgba(217, 119, 6, 0); }
}
/* The whole slot rings when it is chosen — on the tick, and again when the
   diary supplies its replacement. The app has no such state either; what it
   has is the result appearing, and on a screen with no finger in it the
   result alone does not say that anyone chose.

   ON A PSEUDO-ELEMENT, not on the cell. `animation` is one property, and
   `.mw-cell.is-sug` is already using it for the shimmer — at equal
   specificity the later rule simply won, so the ring did not start until the
   card stopped being a proposal 550ms after the tap, which is 550ms after the
   moment it exists to mark. Two animations, two elements.

   Inset rather than outset so the ring is clipped to the card's own radius
   and cannot overlap its neighbours. */
.mw-cell::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 5;
  border-radius: 8px;
  pointer-events: none;
  box-shadow: inset 0 0 0 0 rgba(217, 119, 6, 0);
}
@keyframes mw-pick {
  0%   { box-shadow: inset 0 0 0 0 rgba(217, 119, 6, 0); }
  12%  { box-shadow: inset 0 0 0 4px rgba(217, 119, 6, 1); }
  62%  { box-shadow: inset 0 0 0 4px rgba(217, 119, 6, 1); }
  100% { box-shadow: inset 0 0 0 4px rgba(217, 119, 6, 0); }
}
.mw-cell.is-picked::after { animation: mw-pick 1200ms ease forwards; }

.mw-ok.is-press { animation: mw-tap-ok 460ms cubic-bezier(.2, .9, .3, 1) forwards; }
.mw-ed.is-press { animation: mw-tap-ed 460ms cubic-bezier(.2, .9, .3, 1) forwards; }

.mw-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; border-radius: 8px; color: #c2762a;
  background: rgba(217, 119, 6, 0.10);
}
.mw-icon svg { width: 14px; height: 14px; }

/* ── the week grid ──────────────────────────────────────────────────────── */
/* DAY_COLUMN_WIDTH 164, gap 6, SLOT_LABEL_WIDTH 24. The rail and the day row
   share one template so the columns line up to the pixel, which is what the
   component does with two mirrored CSS grids. */
.mw-body { padding: 4px 12px 8px; }

.mw-days {
  display: grid;
  grid-template-columns: 24px repeat(7, 164px);
  gap: 6px;
  padding-top: 16px; padding-bottom: 12px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.07);
}
.mw-day {
  text-align: center; font-size: 10px; font-weight: 600; line-height: 1;
  color: #9a8f7d; overflow: hidden; white-space: nowrap;
}
.mw-day.is-today { color: #d97706; }

.mw-grid { display: flex; gap: 6px; padding-top: 10px; }

/* The rail is `position: sticky; left: 0` in the component — it is the only
   thing on the board naming the row, so like the calendar's member gutter it
   may never leave frame, and every camera hold below pins --tx because of it.
   The word runs down the rail one letter per line, which is the component's
   own `slot.label.split("")`. */
.mw-rail { width: 24px; flex: none; display: flex; flex-direction: column; gap: 6px; }
.mw-rail-slot {
  height: 182px;                             /* cardHeightFor(2) */
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  border-radius: 4px;
  background: linear-gradient(180deg, color-mix(in srgb, var(--mw-slot) 12%, transparent), rgba(255, 255, 255, 0.7));
  border: 1px solid rgba(0, 0, 0, 0.05);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
.mw-rail-slot svg { width: 13px; height: 13px; color: var(--mw-slot); margin-bottom: 8px; }
.mw-rail-word { display: flex; flex-direction: column; align-items: center; gap: 1px;
  font-size: 10px; font-weight: 900; line-height: 1; color: #9a8f7d; opacity: 0.84; }

.mw-rows { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.mw-row { display: grid; grid-template-columns: repeat(7, 164px); gap: 6px; }

/* ── one cell ───────────────────────────────────────────────────────────── */
.mw-cell {
  position: relative;
  height: 182px;
  display: flex; flex-direction: column; overflow: hidden;
  background: #fff; border-radius: 8px;
  border: 1px solid rgba(0, 0, 0, 0.08);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.13);
}
/* The text half is a fixed 74px budget and the photo takes the rest, so a long
   name can never push the controls past the bottom edge. */
.mw-photo { position: relative; height: 108px; flex: none; overflow: hidden; }   /* 182 less the fixed 74 */
.mw-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }

.mw-text { flex: 1; min-width: 0; display: flex; flex-direction: column;
  justify-content: space-between; padding: 5px 7px 6px; gap: 3px; }
.mw-name {
  font-size: 12.5px; font-weight: 700; line-height: 1.25; color: #33302c;
  overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.mw-foot { display: flex; align-items: center; gap: 6px; min-height: 24px; }
.mw-meta { display: flex; align-items: center; gap: 6px; }
.mw-cook { width: 22px; height: 22px; border-radius: 999px; overflow: hidden; flex: none; }
.mw-cook img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mw-stat { font-size: 11px; font-weight: 600; color: #9a8f7d; white-space: nowrap; }

/* Leftovers and the proposal controls are both off by default; a class turns
   each on. Nothing is hidden in the MARKUP — see the generator's note. */
.mw-lo {
  position: absolute; left: 5px; top: 5px;
  font-size: 8px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em;
  padding: 2px 5px; border-radius: 4px;
  background: rgba(15, 23, 42, 0.72); color: #fff;
  opacity: 0; transition: opacity 260ms ease;
}
.mw-cell.is-leftover .mw-lo { opacity: 1; }

.mw-actions { display: none; margin-left: auto; gap: 4px; }
.mw-btn {
  width: 24px; height: 24px; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
}
.mw-btn svg { width: 12px; height: 12px; }
.mw-ok { background: #d97706; color: #fff; }
.mw-ed { background: #fbf7ec; border: 1px solid rgba(0, 0, 0, 0.08); color: #8a7f6d; }

/* ── a proposal ─────────────────────────────────────────────────────────── */
/* A suggestion carries no cook, no time and no calories — those come off the
   assignment, which does not exist yet — and gains the two buttons instead.
   The sweep is the component's own: accent at 18% across a 200% background. */
.mw-cell.is-sug .mw-meta    { display: none; }
.mw-cell.is-sug .mw-actions { display: flex; }
.mw-cell.is-sug {
  background-image: linear-gradient(90deg, transparent 25%, rgba(217, 119, 6, 0.18) 50%, transparent 75%);
  background-size: 200% 100%;
  animation: mw-shimmer 2s infinite linear;
}
@keyframes mw-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }

/* Accepting is a crossfade in place, never a reflow: the meta and the actions
   occupy the same row, so the card's geometry does not move under the reader.
   Same lesson as the calendar's task band. */
.mw-cell .mw-meta { transition: opacity 300ms ease; }
.mw-cell.is-accepting .mw-meta { opacity: 0; }

/* An empty slot: a dashed box with a thin plus. */
.mw-cell.is-empty { background: transparent; border: 1px dashed rgba(0, 0, 0, 0.14);
  box-shadow: none; align-items: center; justify-content: center; }
.mw-cell.is-empty > * { display: none; }
.mw-cell.is-empty::after { content: "+"; font-size: 20px; font-weight: 200; line-height: 1; color: #b3a893; }

/* ── the Recipe Diary ───────────────────────────────────────────────────── */
.mw-diary { border-top: 1px solid rgba(0, 0, 0, 0.07); }
.mw-diary-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px; }
.mw-diary-head b { font-size: 12px; font-weight: 700; color: #33302c; }
.mw-diary-stat { font-size: 10px; color: #9a8f7d; }
.mw-diary-chev { margin-left: auto; color: #9a8f7d; display: inline-flex; }
.mw-diary-chev svg { width: 14px; height: 14px; transition: transform 300ms ease; }
.mt[data-diary="open"] .mw-diary-chev svg { transform: rotate(180deg); }

/* The drawer animates to exactly 261px (LibrarySection.jsx), spring damping 30
   / stiffness 250 — approximated here by an overshoot-free cubic, because a
   camera hold has to know when the motion has finished. */
.mw-diary-body { height: 0; overflow: hidden; transition: height 420ms cubic-bezier(.2, .9, .3, 1); }
.mt[data-diary="open"] .mw-diary-body { height: 261px; }
.mw-diary-inner { padding: 10px 12px; }

.mw-pills { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.mw-pill {
  padding: 4px 10px; border-radius: 999px; font-size: 10px; font-weight: 500;
  border: 1px solid rgba(0, 0, 0, 0.09); color: #8a7f6d; white-space: nowrap;
  transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
}
.mw-pill i { font-style: normal; opacity: 0.6; }
.mw-pill.is-on { background: #d97706; border-color: #d97706; color: #fff; }

/* The banner the component puts above the grid while a slot is being filled.
   It names the beat in the app's own words, which is why this chapter needs
   no caption for it. */
.mw-assign {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px; margin-bottom: 8px; border-radius: 8px;
  background: rgba(217, 119, 6, 0.09); border: 1px solid rgba(217, 119, 6, 0.27);
  opacity: 0; transition: opacity 260ms ease;
}
.mt[data-diary="open"] .mw-assign { opacity: 1; }
.mw-assign b { font-size: 10px; font-weight: 700; color: #b4772a; }
.mw-assign span { font-size: 9px; color: #9a8f7d; margin-left: auto; }

.mw-lib { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 8px; }
.mw-lib-card {
  border: 1.5px solid rgba(0, 0, 0, 0.09); border-radius: 12px; overflow: hidden;
  background: #fdfaf4;
  transition: opacity 240ms ease, border-color 240ms ease, box-shadow 240ms ease;
}
.mw-lib-card.is-dim { opacity: 0.25; }
.mw-lib-card.is-press { animation: ct-press 320ms ease; }
.mw-lib-photo { position: relative; aspect-ratio: 16 / 9; overflow: hidden; }
.mw-lib-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mw-lib-photo i { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: #d97706; }
.mw-lib-text { padding: 4px 6px 6px; }
.mw-lib-text b { display: block; font-size: 11px; font-weight: 700; line-height: 1.15; color: #33302c;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mw-lib-text span { font-size: 9px; color: #9a8f7d; }

/* ══ GROCERY ════════════════════════════════════════════════════════════ */
/* Fixed 588 at tablet-landscape (MealsTab.jsx), three masonry columns. */
.gp { height: 588px; display: flex; flex-direction: column; overflow: hidden; }
.gp-head { display: flex; align-items: center; gap: 8px; padding: 10px 16px; }
.gp-title { font-size: 12px; font-weight: 600; letter-spacing: 0.04em;
  text-transform: uppercase; color: #b4772a; }
.gp-count { font-size: 11px; font-weight: 700; color: #9a8f7d; font-variant-numeric: tabular-nums; }
.gp-step { display: flex; align-items: center; gap: 4px; }
.gp-step i {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; border-radius: 3px;
  background: rgba(255, 255, 255, 0.75); border: 1px solid rgba(0, 0, 0, 0.07);
  font-size: 10px; line-height: 1; font-style: normal; color: #8a7f6d;
}
.gp-step b { min-width: 90px; text-align: center; font-size: 10px; font-weight: 400; color: #9a8f7d; }
.gp-share { width: 28px; height: 28px; border-radius: 9px; color: #c2762a;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(217, 119, 6, 0.10); }
.gp-share.is-press { animation: ct-press 340ms ease; }
.gp-share svg { width: 15px; height: 15px; }

.gp-bar { height: 4px; background: #f1e9db; }
.gp-bar i { display: block; height: 100%; width: 0; background: #d97706;
  transition: width 520ms cubic-bezier(.2, .9, .3, 1); }

/* MASONRY, not a grid. GroceryBuckets uses `columnCount` with a 22px
   `columnGap` and `break-inside: avoid` on each aisle, so aisles of different
   lengths pack down the columns instead of being forced into equal-height
   rows. With a grid, five aisles of 9/6/5/4/7 items made two rows each as
   tall as its tallest aisle and the list overflowed the card by 100px. */
.gp-body { flex: 1; min-height: 0; column-count: 3; column-gap: 22px; padding: 14px 16px; }
.gp-aisle { break-inside: avoid; -webkit-column-break-inside: avoid; margin-bottom: 18px; }

.gp-aisle-head { display: flex; align-items: center; gap: 8px; padding-bottom: 8px;
  border-bottom: 2px solid color-mix(in srgb, var(--gp-hue) 20%, transparent); }
.gp-emoji { font-size: 15px; line-height: 1; }
.gp-aisle-name { font-size: 11px; font-weight: 800; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--gp-hue); }
.gp-aisle-n { margin-left: auto; font-size: 10px; font-weight: 700; color: #a99a86;
  font-variant-numeric: tabular-nums; }

/* The list is DERIVED: with nothing accepted there is nothing to shop for, so
   the rows start absent and arrive with the meals. `is-off` is the resting
   state the script sets — the markup ships full, so no-JS shows a real list. */
.gp-row { display: flex; align-items: center; gap: 9px; padding: 6px 0;
  border-bottom: 1px solid #f3ece1;
  transition: opacity 360ms ease, transform 360ms cubic-bezier(.2, .9, .3, 1); }
.gp.is-off .gp-row { opacity: 0; transform: translateY(6px); }
.gp.is-off .gp-aisle-n { opacity: 0; }

.gp-check { width: 22px; height: 22px; flex: none; border-radius: 999px;
  border: 2px solid #ddd3c4; display: inline-flex; align-items: center; justify-content: center;
  color: #fff; }
.gp-check svg { width: 12px; height: 12px; opacity: 0; }
.gp-name { font-size: 12px; font-weight: 600; color: #4a453e; }
.gp-qty { margin-left: auto; font-size: 11px; font-weight: 700; color: #8d9099;
  font-variant-numeric: tabular-nums; }

/* ── reduced motion ─────────────────────────────────────────────────────── */
/* The engine stills the camera; the shimmer is this page's own, so this page
   stops it by name. The resting frame is a planned week and a built list. */
@media (prefers-reduced-motion: reduce) {
  .mw-cell.is-sug { animation: none !important; }
  .mw-cell.is-picked::after { animation: none !important; }
  .mw-diary-body, .gp-row, .gp-bar i, .mw-lo, .mw-assign { transition: none !important; }
}

/* ══ CAMERA TRACKS ══════════════════════════════════════════════════════ */
/* Every frame below is measured in tab space and lands on a content edge.
   The camera maps a tab point (X,Y) to scale(fit*z) translate(tx,ty) about
   0 0, so a frame whose top-left is (X0,Y0) has tx = -X0, ty = -Y0, and the
   visible region is (1284/z) x (883/z).

   --tx IS PINNED AT -42 IN EVERY HOLD. The slot rail is `position: sticky;
   left: 0` and is the only thing on the board that says which row this is;
   pan it away and the wall becomes a strip of food photographs. Same
   constraint as the calendar's member gutter, and it forbids travelling
   across the days exactly as it does there.

   The horizontal edges are forced too: cells are 164 wide on a 170 pitch from
   x=76, so the only right edges that do not slice a card in half are the
   gutters — 581.5, 753, 923. Those fix z at 2.380, 1.806 and 1.457 rather
   than at round numbers.

   Measured tab: 1284 x 1034 closed, 1295 with the diary open.
     card        16 - 422 (683 open)      cells      147 - 353
     day row     89 - 128                 diary head 371 - 408
     grid       128 - 362                 diary body 408 - 669
     grocery    430 - 1018 (691 - 1279 open)
   ========================================================================== */

/* ── chapter 1 · plan ───────────────────────────────────────────────────── */
/* 20s. Empty week → the control → pan left as the proposals land → in on the
   DINNER row for the two decisions → the diary → wide, and hold.

   Cut like the calendar's task chapter: the camera goes to the control, the
   control is pressed and NOTHING else changes, then it pulls back and travels
   left so the proposals arrive in front of it rather than being found
   afterwards.

   Measured tab, 1284 x 1145 (1406 with the diary open):
     card     16 - 533 (794 open)     lunch cells   116 - 298
     header   17 - 63                 dinner cells  304 - 486
     day row  67 - 106                diary head    495 - 532
     pill   x 1081 - 1183             diary body    532 - 793
     columns x 59, 229, 399, 569, 739, 909, 1079 — 164 wide on a 170 pitch */
@keyframes mealsPlan {
  0%,    4%   { --z: 1;      --tx: 0;    --ty: 0; }      /* 0.00 – 0.80 the tab */
  10%,  16%   { --z: 2.4272; --tx: -739; --ty: 0; }      /* 2.00 – 3.20 the control */
  22.5%, 33%  { --z: 1.8169; --tx: -29;  --ty: 0; }      /* 4.50 – 6.60 both rows */
  38.5%, 46.5%{ --z: 3.5275; --tx: -129; --ty: -298; }   /* 7.70 – 9.30 Tuesday's dinner */
  50%,   57%  { --z: 3.5275; --tx: -29;  --ty: -298; }   /* 10.00 – 11.40 Monday's dinner */
  62%,  82.5% { --z: 1.8239; --tx: -29;  --ty: -304; }   /* 12.40 – 16.50 Monday + the diary */
  92.5%, 100% { --z: 1;      --tx: 0;    --ty: 0; }      /* 18.50 – 20.00 wide, 1.5s */
}
.ct-stage[data-track="plan"] .ct-cam { animation: mealsPlan 20s infinite; }

/* Where each lands:
     control     x 739-1268, y 0-364 — cell 4's left edge, the card's right,
                 the tab's top. Its bottom crosses a dinner row that is seven
                 dashed empties at that moment, so nothing is severed.
     both rows   x 29-736, y 0-486 — all four on content: the grid's left, the
                 Thu/Fri gutter, the tab's top, the dinner row's bottom.
     Tue dinner  x 129-493, y 298-548 — Tuesday whole and centred. THE ONLY
                 SHOT THAT LETS THE RAIL LEAVE FRAME, and the only one that
                 bleeds into its neighbours: at 364px wide a frame is 250 tall,
                 and a 164x182 card cannot be isolated at a 1.454 aspect
                 without taking part of the cards either side. The pan that
                 follows resolves both — it slides left and Monday becomes
                 whole exactly as the chapter turns to it.
     Mon dinner  x 29-393, y 298-548 — rail back in frame, Monday and Tuesday
                 both whole, top on the lunch row's bottom edge. The bottom
                 clips 7px of the grocery card's top corner.
     Mon + diary x 29-733, y 304-788 — top on the dinner row's own top edge,
                 right on Wednesday's right edge. Not "a little" out: the
                 drawer is 261 tall and the cell 182, so 484px of frame is the
                 least that holds Monday's slot and the library together. */



/* Where each of those lands, and why it is not a round number:
     control   x 739-1268, y 0-364 — cell 4's left edge, the card's right edge
               and the tab's top. Its bottom crosses the dinner row, which at
               that moment is seven dashed empties, so nothing is severed.
     both rows x 29-736, y 0-486 — all four edges on content: the grid's left,
               the Thu/Fri gutter, the tab's top and the dinner row's bottom.
     lunch row x 29-392, y 54-304 — Monday and Tuesday whole, the rail in
               frame, the bottom on the dinner row's top edge. The top clears
               the header's controls (they end at 52) and cuts only padding.
     diary     x 29-1076, y 73-793 — six days, both rows and the whole drawer
               in one frame, which is what this beat has to show. The top edge
               sits in the day row's padding, above the labels themselves. */

/* ── chapter 2 · cook ───────────────────────────────────────────────────── */
/* 16s. The Meal Card is a stage overlay, so the camera barely moves: it pushes
   onto the board so the tap is visible and holds while the card is over it. */
@keyframes mealsCook {
  0%,   6%   { --z: 1;      --tx: 0;   --ty: 0; }
  13%,  82%  { --z: 1.8169; --tx: -29; --ty: 0; }
  89%, 100%  { --z: 1;      --tx: 0;   --ty: 0; }
}
.ct-stage[data-track="cook"] .ct-cam { animation: mealsCook 16s infinite; }

/* One frame with every transition off. A track's reset() walks the finished
   board back to its opening frame, and without this the reader watches a full
   week fade out before the chapter starts. */
.ct-stage.no-anim .mw-cell,
.ct-stage.no-anim .mw-meta,
.ct-stage.no-anim .mw-lo,
.ct-stage.no-anim .mw-assign,
.ct-stage.no-anim .mw-diary-body,
.ct-stage.no-anim .mw-diary-chev svg,
.ct-stage.no-anim .mw-pill,
.ct-stage.no-anim .mw-lib-card,
.ct-stage.no-anim .gp-row,
.ct-stage.no-anim .gp-bar i { transition: none !important; }

/* The empty list is not an empty list of aisles — GroceryBuckets returns null
   when there is nothing to group, and the widget renders its own empty state
   instead. Aisle headings standing over nothing would be a shape the app never
   makes, and it left a 450px white void in the hero's opening frame.

   Both states share one grid cell so the swap is a crossfade in place: the
   card is a fixed 588 and nothing may move inside it. */
.gp-stack { flex: 1; min-height: 0; display: grid; }
.gp-stack > * { grid-area: 1 / 1; transition: opacity 420ms ease; }
.gp-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; padding: 40px 16px; text-align: center; opacity: 0; pointer-events: none;
}
.gp.is-off .gp-empty { opacity: 1; }
.gp.is-off .gp-body  { opacity: 0; }
.gp-empty-ico {
  display: inline-flex; padding: 14px; border-radius: 16px;
  background: rgba(217, 119, 6, 0.12); color: #d97706;
}
.gp-empty-ico svg { width: 30px; height: 30px; }
.gp-empty-t { margin: 0; font-size: 14px; font-weight: 700; color: #33302c; }
.gp-empty-s { margin: 0; font-size: 12px; color: #9a8f7d; max-width: 360px; }

/* The count and the bar are summary figures; with no items the widget has no
   summary to render, so they go with the aisles. */
.gp-count, .gp-bar { transition: opacity 300ms ease; }
.gp.is-off .gp-count, .gp.is-off .gp-bar { opacity: 0; }

/* ══ THE MEAL CARD ══════════════════════════════════════════════════════ */
/* A stage overlay, NOT part of the tab: the camera scales everything inside
   .ct-cam, and a dialog that grew and shrank with the zoom would be absurd.
   So it sits beside the camera and is staged by --mz.

   --mz IS A STAGING NUMBER, NOT A PHYSICAL ONE. The card is centred in the
   FRAME rather than on the tab, so the shot is already a hybrid and there is
   no "correct" scale to derive — only one that reads. At 1 the card is 52% of
   the frame and its 12px body text renders at 5.3px, which is not reading. */
.ct-modal {
  position: absolute; inset: 0; z-index: 4;
  display: flex; align-items: center; justify-content: center;
  background: rgba(24, 20, 16, 0.42);
  opacity: 0; pointer-events: none;
  transition: opacity 320ms ease;
}
.ct-stage.is-card .ct-modal { opacity: 1; }

.ct-scaler {
  transform: scale(calc(var(--fit, 0.4) * var(--mz, 1.5)));
  transform-origin: center center;
  transition: transform 320ms cubic-bezier(.2, .9, .3, 1);
}
/* 1.64 is where this lands, and the trade is worth stating plainly.

   The stage is ~543 CSS px wide, so --fit is 0.423: a 12px app font is 5.1px
   on screen at TRUE proportion. Every point of legibility past that comes
   from --mz, and --mz is bounded by the frame — which is why the card was
   trimmed (the description came off) before it could be scaled at all.

   The card is 672 x 506 against a 1284 x 883 wall — portrait inside landscape:
     --mz   height   width   12px body text
      1.00    57%     52%        5.1px   true proportion, unreadable
      1.45    83%     76%        7.4px   the previous setting
      1.64    94%     86%        8.3px   ships
      1.75   100%     92%        8.9px   touches both edges, and at 92% wide
                                         it stops reading as a panel on a wall

   --mz is a STAGING number, not a physical one: the card is centred in the
   FRAME rather than on the tab, so the shot is already a hybrid and there is
   no "correct" scale to derive — only one that reads. */
.ct-stage.is-card .ct-scaler { --mz: 1.64; }
.ct-stage:not(.is-card) .ct-scaler { --mz: 1.46; }   /* it scales up as it arrives */

/* DialogContent is sm:max-w-2xl and rounded-[28px]. */
.mc {
  width: 672px;
  border-radius: 28px;
  background: #fffdfa;
  overflow: hidden;                /* the card may clip; the popover it has
                                      none of is what must not be clipped */
  box-shadow: 0 40px 90px rgba(23, 20, 16, 0.34);
  text-align: left;
}

.mc-head { position: relative; display: flex; gap: 14px; padding: 16px 22px 14px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.06); }
.mc-x { position: absolute; right: 16px; top: 16px; width: 32px; height: 32px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 12px; color: #a99a86; }
.mc-x svg { width: 16px; height: 16px; }
.mc-hero { width: 84px; height: 84px; flex: none; border-radius: 16px; overflow: hidden;
  background: rgba(217, 119, 6, 0.2); border: 1px solid rgba(0, 0, 0, 0.07); }
.mc-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mc-htext { min-width: 0; padding-right: 36px; }
.mc-htext h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; line-height: 1.35; color: #33302c; }
.mc-when { margin: 0 0 3px; font-size: 12px; font-weight: 600; color: #33302c; }
.mc-sub  { margin: 0 0 6px; font-size: 12px; color: #9a8f7d; }
.mc-chips { margin: 0; display: flex; gap: 16px; }
.mc-chips span { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #6f665a; }
.mc-chips svg { width: 14px; height: 14px; }

.mc-body { padding: 13px 22px 14px; display: flex; flex-direction: column; gap: 11px; }
.mc-desc { margin: 0; font-size: 12px; line-height: 1.6; color: #6f665a; }
.mc-sec { display: flex; flex-direction: column; gap: 8px; }
.mc-sec h4 { margin: 0; font-size: 10px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.08em; color: #a99a86; }

/* Picking someone widens their pill as the previous name collapses away. */
.mc-pills { display: flex; gap: 6px; }
.mc-pill { display: inline-flex; align-items: center; padding: 2px; border-radius: 999px;
  border: 1.5px solid rgba(0, 0, 0, 0.1); background: transparent;
  transition: border-color 320ms cubic-bezier(.25,.1,.25,1), background-color 320ms cubic-bezier(.25,.1,.25,1),
              padding-right 320ms cubic-bezier(.25,.1,.25,1); }
.mc-pill i { width: 22px; height: 22px; border-radius: 999px; overflow: hidden; flex: none; display: block; }
.mc-pill i img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mc-pill b { max-width: 0; overflow: hidden; white-space: nowrap; opacity: 0;
  font-size: 12px; font-weight: 600; color: #33302c;
  transition: max-width 320ms cubic-bezier(.25,.1,.25,1), opacity 220ms ease, padding-left 320ms; }
.mc-pill.is-on { border-color: #d97706; background: rgba(217, 119, 6, 0.1); padding-right: 10px; }
.mc-pill.is-on b { max-width: 90px; opacity: 1; padding-left: 6px; }

.mc-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.mc-tag { padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.03em;
  background: rgba(217, 119, 6, 0.14); color: #b4772a; }
.mc-tag--out { background: transparent; border: 1px solid rgba(0, 0, 0, 0.1);
  color: #6f665a; font-weight: 500; text-transform: none; letter-spacing: 0; }

.mc-ings { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.mc-ings li { display: flex; gap: 12px; font-size: 12px; color: #33302c; }
.mc-amt { width: 80px; flex: none; font-weight: 600; color: #6f665a; font-variant-numeric: tabular-nums; }
.mc-ings i { font-style: normal; color: #a99a86; }

.mc-steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }
.mc-steps li { display: flex; gap: 12px; }
.mc-num { width: 20px; height: 20px; flex: none; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 10px; font-weight: 700; background: rgba(217, 119, 6, 0.14); color: #d97706; }
.mc-steps p { margin: 0; font-size: 12px; line-height: 1.6; color: #33302c; }

.mc-foot { display: flex; gap: 8px; padding: 11px 22px; border-top: 1px solid rgba(0, 0, 0, 0.06); }
.mc-btn { flex: 1; min-width: 0; height: 36px; border-radius: 12px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  font-size: 12px; font-weight: 600; color: #6f665a;
  background: #fbf7ec; border: 1px solid rgba(0, 0, 0, 0.08); }
.mc-btn svg { width: 14px; height: 14px; flex: none; }
.mc-btn b { font-weight: 600; }
.mc-danger { color: #dc2626; }

/* The heart is drawn rather than iconised: outline until it is loved, then
   filled, with a burst on the way in only — the component fires particles on
   the way in and nothing on the way out. */
.mc-heart { width: 14px; height: 14px; flex: none; position: relative;
  background: currentColor; color: hsl(350deg 80% 75%);
  -webkit-mask: var(--mc-heart) center/contain no-repeat;
          mask: var(--mc-heart) center/contain no-repeat;
  transition: color 240ms ease, transform 240ms cubic-bezier(.2,.9,.3,1); }
.mc-love.is-loved .mc-heart { color: hsl(350deg 100% 60%); transform: scale(1.18); }
.mc-love.is-loved b::after { content: ""; }
:root, .mc { --mc-heart: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M3.7 5.4C8.6 1.3 11.9 7.4 12 7.4c.2 0 3.4-6.1 8.4-2 6.5 5.5-6.9 16.4-8.4 16.4S-2.8 10.9 3.7 5.4Z'/></svg>"); }

/* The burst. 12 petals thrown from the heart, which is the component's
   canvas shower reduced to what reads at this scale. */
.mc-burst { position: absolute; left: 50%; top: 50%; width: 5px; height: 5px;
  border-radius: 999px; background: hsl(350deg 100% 62%); opacity: 0; }
.mc-love.is-burst .mc-burst { animation: mc-burst 720ms cubic-bezier(.2,.7,.4,1) forwards; }
@keyframes mc-burst {
  0%   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(calc(-50% + var(--bx) * 1px), calc(-50% + var(--by) * 1px)) scale(0.3); }
}

.mc-btn.is-press { animation: ct-press 320ms ease; }

@media (prefers-reduced-motion: reduce) {
  .ct-modal, .ct-scaler, .mc-pill, .mc-pill b, .mc-heart { transition: none !important; }
  .mc-love.is-burst .mc-burst { animation: none !important; }
}

/* The burst is thrown from the button, so the button is the containing block. */
.mc-love { position: relative; }

/* An aisle with nothing showing is not an empty aisle — GroceryBuckets only
   groups items that exist, so a category with no rows is simply absent. */
.gp-aisle.is-off { display: none; }
.gp-row.is-off   { display: none; }

/* A row ticked off in the shop. The check fills with the AISLE's own colour,
   not a global accent, and the line rules itself through — GroceryRow.jsx's
   own treatment. Nothing moves: the widget does not re-sort a ticked row. */
.gp-row.is-ticked .gp-check { background: var(--gp-hue); border-color: var(--gp-hue); }
.gp-row.is-ticked .gp-check svg { opacity: 1; }
.gp-row.is-ticked .gp-name {
  text-decoration: line-through;
  color: #9a8f7d;
  opacity: 0.6;
}
.gp-row.is-ticked .gp-qty { opacity: 0.6; }
.gp-check, .gp-name, .gp-qty { transition: background-color 260ms ease, border-color 260ms ease,
                                            color 260ms ease, opacity 260ms ease; }
.gp-check svg { transition: opacity 200ms ease; }

@media (prefers-reduced-motion: reduce) {
  .gp-check, .gp-name, .gp-qty, .gp-check svg { transition: none !important; }
}

/* ══ THE SHARE SHEET ════════════════════════════════════════════════════ */
/* Two pages in one shell. The real dialog SLIDES between them rather than
   trading one modal for another, so both are in the DOM and one is offset. */
.ct-modal--share { z-index: 4; }
.gs {
  width: 440px;
  border-radius: 28px;
  background: #fffdfa;
  overflow: hidden;
  box-shadow: 0 40px 90px rgba(23, 20, 16, 0.34);
  text-align: left;
  display: grid;
}
.gs-page { grid-area: 1 / 1; transition: opacity 300ms ease, transform 320ms cubic-bezier(.25,.1,.25,1); }
.gs-qr   { opacity: 0; transform: translateX(12%); pointer-events: none; }
.ct-stage.is-qr .gs-menu { opacity: 0; transform: translateX(-12%); }
.ct-stage.is-qr .gs-qr   { opacity: 1; transform: translateX(0); }

.gs-head {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 18px 20px 15px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  background: linear-gradient(180deg, rgba(217, 119, 6, 0.16), #fffdfa);
}
.gs-ico {
  width: 44px; height: 44px; flex: none; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.72); color: #b4772a;
}
.gs-ico svg { width: 19px; height: 19px; }
.gs-back, .gs-x {
  width: 30px; height: 30px; flex: none; border-radius: 10px;
  display: inline-flex; align-items: center; justify-content: center; color: #a99a86;
}
.gs-back svg, .gs-x svg { width: 15px; height: 15px; }
.gs-htext { flex: 1; min-width: 0; padding-top: 2px; }
.gs-htext h3 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.2; color: #33302c; }
.gs-htext p  { margin: 3px 0 0; font-size: 12px; color: #9a8f7d; }

.gs-rows { display: flex; flex-direction: column; gap: 8px; padding: 16px 20px 20px; }
.gs-row {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; border-radius: 12px;
  font-size: 14px; font-weight: 600; color: #33302c;
  background: #fbf7ec; border: 1px solid rgba(0, 0, 0, 0.08);
}
.gs-row svg { width: 16px; height: 16px; flex: none; }
.gs-row.is-primary { background: #d97706; border-color: #d97706; color: #fff; }
.gs-row.is-press { animation: ct-press 320ms ease; }

.gs-qrbody { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 22px 20px 26px; }
.gs-code {
  padding: 12px; border-radius: 16px; background: #fff;
  border: 1px solid rgba(217, 119, 6, 0.22);
  box-shadow: 0 10px 30px rgba(124, 58, 237, 0.12);
}
.gs-code img { display: block; width: 200px; height: 200px; }
.gs-ttl { margin: 0; font-size: 11px; color: #9a8f7d; }

@media (prefers-reduced-motion: reduce) {
  .gs-page { transition: none !important; }
}

/* ── chapter 3 · shop ───────────────────────────────────────────────────── */
/* 24s. The week with Thursday still undecided → accept it → down to the list
   as it takes on what Thursday needs → Share → the code → the ticks arriving
   from the shop → wide.

   Measured grocery card (tab 1284 x 1145), masonry-packed:
     card     541 - 1129     header 542 - 590     bar 590 - 594
     body     594 - 1128     share icon x 1223-1251, y 552-580
     col1  x 33-424   Fruit & Veg    608 - 948
     col2  x 446-837  Meat & Fish    608 - 843 · Dairy & Eggs  861 - 1061
     col3  x 860-1251 Bakery         608 - 773 · Pantry        791 - 1061 */
@keyframes mealsShop {
  0%,    3.7% { --z: 1;      --tx: 0;    --ty: 0; }      /*  0.00 – 0.90 the tab */
  10.4%, 20%  { --z: 3.5275; --tx: -569; --ty: -298; }   /*  2.50 – 4.80 Thursday's dinner */
  27.5%, 37%  { --z: 1.5639; --tx: -16;  --ty: -541; }   /*  6.60 – 8.90 down onto the list */
  43%,   52%  { --z: 3.1470; --tx: -860; --ty: -541; }   /* 10.30 – 12.50 the Share control */
  60%,   87%  { --z: 1.5639; --tx: -16;  --ty: -541; }   /* 14.40 – 20.88 back on the aisles */
  92.5%, 100% { --z: 1;      --tx: 0;    --ty: 0; }      /* 22.20 – 24.00 wide, 1.8s */
}
.ct-stage[data-track="shop"] .ct-cam { animation: mealsShop 24s infinite; }

/* Where each lands:
     Thursday  x 569-933, y 298-548 — the distance chapter 1 accepts from, so
               the gesture reads identically in both. Thursday is whole;
               its neighbours bleed, which at a 1.454 aspect is unavoidable
               for a 164x182 card and is what a close-up is.
     the list  x 16-837, y 541-1106 — the card's own left and top edges, the
               col2/col3 gutter, and a bottom that falls in the body's empty
               tail below the last aisle. TWO of the three columns: at one
               column the ticks are enormous but it stops looking like a
               shopping list, and at three the 12px rows render at 5.3px and
               cannot be read at all. Two gives 7.9px rows and a 14.5px check,
               with the counter and the bar in the same frame.
     the Share x 860-1268, y 541-822 — col3's left edge, the card's right and
               top. Free to move --tx because the header is not the grid; its
               bottom sits just under the Pantry heading.
   The list shot is RETURNED to rather than replaced, so the ticks arrive in a
   frame the reader has already been taught to read — and the camera makes
   that move behind the share sheet, which costs no time at all. */

/* The one line on the share sheet that carries chapter 3's argument. In the
   app it is the same dim 12px subtitle every panel gets; here it is
   highlighted, because the reader has about two and a half seconds with it
   and it is the sentence the whole chapter exists to make. Same licence as
   the ring and the press states: an emphasis the animation needs and the
   product does not. */
.gs-qr .gs-htext p {
  display: inline-block;
  margin-top: 5px;
  padding: 3px 8px;
  border-radius: 6px;
  background: rgba(217, 119, 6, 0.16);
  color: #9a5b12;
  font-weight: 700;
}
