/* Layout and components. Phone-first at 390px (§6.1, §14).
 *
 * Ergonomics that shape this file: two or three items per screen, one very large
 * element per screen, controls in the lower two-thirds, nothing important in the
 * top corners, large touch targets, high contrast.
 */

* {
  box-sizing: border-box;
}

/* NO SCROLL CHAINS OUT OF ANYTHING, ANYWHERE.
 *
 * THE PROBLEM, MEASURED: `overscroll-behavior` was `none` on `body` and `auto` on
 * `html` and on all 21 `.screen__body` scrollers and every inner list. `auto` is
 * "chain to my ancestor when I hit my end", so a drag that starts at the TOP of a
 * list — the read-back's rows, the full list, the deals column — travelled up to
 * the document and became the browser's own overscroll: the rubber band on Safari,
 * pull-to-refresh on Chrome. In a store, one-handed, that is a page reload in the
 * middle of a shopping list.
 *
 * WHY THE UNIVERSAL SELECTOR RATHER THAN A LIST OF SCROLLERS. There are 26 scroll
 * rules in this file and a hand-kept list of them is a list the next scroll
 * container is not on — the same failure `toastBandOccupants` and `layout.js`'s
 * bottom band are both computed rather than listed to avoid. The property is "no
 * scroll container in this app chains", and the only selector that states exactly
 * that is every element. `overscroll-behavior` has NO effect on an element that is
 * not a scroll container, so there is no collateral: this cannot change the layout,
 * the paint, or a single non-scrolling node.
 *
 * `contain` AND NOT `none` HERE. `contain` stops the chain and KEEPS the element's
 * own overscroll affordance — the little bounce at the end of a list that tells a
 * thumb it has reached the bottom. `none` would also suppress that, which on a
 * touch list reads as the scroll being stuck. `none` is right for the root, where
 * there is no parent to chain to and the affordance belongs to the browser rather
 * than to us; that is the rule below. */
* {
  overscroll-behavior: contain;
}

html,
body {
  margin: 0;
  height: 100%;
  /* `none` on the ROOT, both elements. `body` already had it; `html` did not, and
     `html` is the one the viewport's scrolling box comes from — so pull-to-refresh
     and the document rubber band were reachable the whole time the declaration
     below looked like it had closed them. The same weak-guard shape as the rest:
     the rule was present, on the wrong element. */
  overscroll-behavior: none;
}

body {
  font-family: var(--font);
  color: var(--ink);
  /* PAPER, NOT #111, AND THIS IS THE STATUS BAR FIX.
   *
   * With `viewport-fit=cover` the status bar and the home-indicator strip are
   * part of the viewport, and whatever the BODY is painted with shows through
   * them wherever `#app` does not reach. `#111` made that black in standalone.
   * The surround is white too, by ruling — there is no dark ground anywhere on
   * any surface now, so this is the only background the page ever paints. */
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  /* `overscroll-behavior: none` USED TO BE HERE and now sits on `html, body`
     together, above. One declaration in two places is one declaration that goes
     stale — and this copy is what made the root look covered when it was not. */
}

/* The phone. On a real phone this fills the viewport; on a desktop it renders as
 * the design's 390x844 frame so the layout can be checked at its true size.
 *
 * `max-height: 844px` IS GONE FROM HERE and now lives in the desktop-preview query
 * below, which is the only place it was ever anything but a lie.
 *
 * WHY THAT IS THE FIX RATHER THAN A DELETION. 844 is the frames' canvas: an iPhone
 * in a standalone Home Screen launch. Roughly 90% of shoppers are in a mobile
 * browser tab, where the measured content band is 646 (iOS Chrome: 56pt status
 * bar, 57pt URL bar, 93pt bottom toolbar) or 687 (iOS Safari, no top bar). So on
 * every real handset the number was either irrelevant or wrong, and it was only
 * ever RIGHT for the desktop mock-up — where it is a preview constraint and has
 * to stay, because without it the simulated phone below is as tall as the window.
 *
 * IT DID NOT BITE ON A PHONE, AND THAT IS NOT A REASON TO LEAVE IT. `(pointer:
 * coarse)` cancelled it, so a handset already got `none`. What that cancellation
 * could not do is cover a surface that is NEITHER a coarse pointer nor a
 * hover-capable desktop — an in-app webview, an automation surface, anything that
 * reports no pointer at all — and for those it fell through to 844, which is the
 * worst available guess. Now the default is "as tall as the viewport" and 844 is
 * opted into by the one surface that wants it.
 *
 * `100dvh` is the correct height and is already chrome-aware: it tracks the
 * DYNAMIC viewport, so it is the band with the URL bar showing rather than the
 * larger one after a scroll hides it. `layout.js` measures this element to choose
 * the band, so this declaration is also the input to that measurement. */
#app {
  position: relative;
  width: 100%;
  max-width: 390px;
  height: 100dvh;
  margin: 0 auto;
  background: var(--app-bg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* THE DESKTOP PREVIEW FRAME, and the pointer query is the whole of the bug fix.
 *
 * This was gated on size alone — `min-width: 430px and min-height: 880px` — which
 * is a description of a large PHONE as accurately as of a desktop window. An
 * iPhone 15 Pro Max in standalone is 430 x 932, so it MATCHED: the shopper got
 * the desktop mock-up's centred, rounded, drop-shadowed frame on the device the
 * frame exists to imitate, with `#111` showing through the status bar around it.
 *
 * `(hover: hover) and (pointer: fine)` is the honest discriminator — a mouse,
 * which no handset has — rather than a size that a phone can grow into. */
/* NO DARK SURROUND ANYWHERE. Product ruling 2026-08-15: the surround is white as
   well as the status bar. The mock-up's dark ground is gone from every surface —
   the body is `--paper` on desktop and on device alike, so no viewport,
   orientation or safe-area combination can put black at an edge. The simulated
   phone's own BEZEL survives below as a box-shadow ring on `#app`: that is the
   device's edge rather than the page's background, and the pointer query keeps it
   unreachable from a handset. */
@media (hover: hover) and (pointer: fine) and (min-width: 430px) and (min-height: 880px) {
  body {
    display: grid;
    place-items: center;
  }
  #app {
    /* THE 844 CANVAS, AND THIS IS THE ONLY PLACE IT BELONGS. It is a preview
       constraint: it makes the mock-up on a laptop the size of the device the
       frames were drawn for, so a layout can be checked at its true size. The
       query already guarantees at least 880px of window, so the cap always has
       room. `layout.js` measures this element, so a desktop preview correctly
       reports the 844 band rather than the window's height. */
    max-height: 844px;
    border-radius: 46px;
    box-shadow: 0 0 0 11px #131316, 0 0 0 13px #3a3a3e, 0 22px 50px rgba(0, 0, 0, 0.28);
  }
}

/* On a real touch device the app fills the screen.
 *
 * `max-height: none` USED TO BE HERE and is gone with the declaration it undid —
 * `#app` no longer caps its height at all outside the desktop-preview query above,
 * so there is nothing left to cancel. The width cap stays: 390px is the design's
 * column and is right on a laptop and wrong on a 430pt handset. */
@media (pointer: coarse) {
  #app {
    max-width: none;
  }
}

/* ------------------------------------------------------------------ screens */

.screen {
  position: absolute;
  inset: 0;
  display: none;
  flex-direction: column;
  padding-top: env(safe-area-inset-top, 0);
}

.screen[data-active='true'] {
  display: flex;
}

/* ------------------------------------------------- page transitions (§CP) */

/* A NEW SCREEN SLIDES IN FROM THE RIGHT; GOING BACK IS THE EXACT REVERSE.
 *
 * The direction lives on the APP, set per navigation by `BACK_ACTIONS` in
 * main.js, and never on a screen — `answer` is entered forward from a query and
 * backward from a product page, and it is the same screen both times, so a
 * direction stored per screen gets one of the two wrong.
 *
 * `--nav-ms` MUST EQUAL `NAV_MS` in main.js, which owns the cleanup timer for the
 * leaving screen; a test asserts the two match. Drift in one direction snatches
 * the old screen away mid-slide, and in the other leaves it sitting on top of the
 * new one after the motion has stopped.
 *
 * TRANSLATEX ONLY, AND THE Y AXIS IS DELIBERATELY UNTOUCHED. `.screen` carries
 * `padding-top: env(safe-area-inset-top)`, which is 0 in a desktop browser and
 * real on a handset — so any vertical component of this transform would be tested
 * against 0 here and be wrong by the notch inset on the device, which is exactly
 * the class of bug that only shows up in someone's hand. A purely horizontal
 * transform cannot interact with a vertical inset at all.
 *
 * The transform exists only WHILE the animation runs (no fill-mode, no resting
 * transform), so a screen at rest creates no containing block. That matters
 * because a transformed ancestor re-parents `position: fixed` descendants. */
:root {
  --nav-ms: 260ms;
  --nav-ease: cubic-bezier(0.32, 0.72, 0, 1);
  /* ONE definition of the page background, shared by `#app` and by any screen
   * that is mid-slide. Two copies of a gradient is one copy that goes stale, and
   * here a drift between them would show as a seam moving across the screen. */
  --app-bg: linear-gradient(180deg, var(--paper) 0%, var(--paper-warm) 100%);
}

/* THE LEAVING SCREEN STAYS PAINTED for the length of the slide. Without this it
 * is `display: none` the instant `data-active` flips and there is nothing to
 * animate out — the new screen would slide in over a blank background. */
.screen[data-leaving='true'] {
  display: flex;
  /* Under the entering screen, so the incoming page covers the outgoing one
   * rather than the two blending at the seam. */
  z-index: 0;
}

/* A SLIDING SCREEN CARRIES ITS OWN BACKGROUND, and without this the whole effect
 * is wrong in a way no structural check catches.
 *
 * MEASURED: every `.screen` computes `background-color: rgba(0, 0, 0, 0)` and
 * `background-image: none` — the warm gradient belongs to `#app`, one level up.
 * So two screens mid-slide are two transparent panes over one background, and the
 * outgoing screen's text reads straight through the incoming screen's. The
 * animation was running correctly and looked like a rendering fault.
 *
 * Scoped to the transition rather than put on `.screen` permanently, because a
 * resting screen showing `#app`'s gradient through itself is the CORRECT
 * behaviour — that is where the page background comes from, and painting every
 * screen opaque all the time would put a hard-edged 844px-tall copy of the
 * gradient inside a shorter viewport. */
[data-nav] .screen[data-active='true'],
[data-nav] .screen[data-leaving='true'] {
  background: var(--app-bg);
}

[data-nav] .screen[data-active='true'] {
  z-index: 1;
}

[data-nav='forward'] .screen[data-active='true'] {
  animation: navInFromRight var(--nav-ms) var(--nav-ease);
}

[data-nav='forward'] .screen[data-leaving='true'] {
  animation: navOutToLeft var(--nav-ms) var(--nav-ease);
}

[data-nav='back'] .screen[data-active='true'] {
  animation: navInFromLeft var(--nav-ms) var(--nav-ease);
}

[data-nav='back'] .screen[data-leaving='true'] {
  animation: navOutToRight var(--nav-ms) var(--nav-ease);
}

@keyframes navInFromRight {
  from {
    transform: translateX(100%);
  }
}

@keyframes navOutToLeft {
  to {
    transform: translateX(-100%);
  }
}

@keyframes navInFromLeft {
  from {
    transform: translateX(-100%);
  }
}

@keyframes navOutToRight {
  to {
    transform: translateX(100%);
  }
}

.screen__body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* Screens that carry the persistent surface leave room for it — AND FOR WHATEVER
 * ELSE IS PINNED IN THE SAME BAND, which is the whole of the change here.
 *
 * `--bottom-h` is the MEASURED height of everything anchored to the bottom of the
 * screen the shopper is on: the surface, plus the list-count pill when it is up,
 * plus the walking screen's docked CTA when it has one. `layout.js` computes it by
 * intersection on every navigation and publishes it on `<html>`.
 *
 * WHY IT IS NOT THREE SEPARATE RESERVATIONS. Every one of the three
 * double-reservation defects CLAUDE.md records was written by somebody adding a
 * second `--surface-h` to a footer that felt like it should pay for one, and the
 * worst of them held 232px for a 104px surface. One number, measured once, read
 * once, is the only shape in which that mistake is unwriteable.
 *
 * MEASURED, and this is what it fixes: `.list-count` sits at
 * `calc(var(--surface-h) - 2px)` and is ~32px tall, so it stood 14px proud of a
 * reservation that stopped at the surface — the last row of every list on the nine
 * screens that show the pill was underneath it. The walking CTA is worse: 66px
 * tall at `calc(var(--surface-h) + 6px)`, so it occupied 104->170 against a 114px
 * reservation.
 *
 * THE GAP IS 8, AND IT WAS 16 FOR A REASON THAT NO LONGER HOLDS (product-owner
 * ruling). 16 was a CUSHION from when `--surface-h` was a hardcoded token — a
 * number written down once and hoped to be right, so a margin of error on top of
 * it was prudent. `--bottom-h` is not that: it is measured off the rendered
 * element on every navigation, and it already includes the pill and the docked
 * CTA. A cushion on top of a measurement is a second reservation on top of a
 * reservation — the same double-pay removed from the pager foot and the action
 * row in 27505d4, and the third instance of the shape CLAUDE.md records.
 *
 * The 8 that remains is a GAP, not a reservation: it is what keeps the last row
 * of a list from sitting flush against the bar, and it is asserted as clearance
 * rather than as this rule's number. */
.screen--with-surface .screen__body {
  padding-bottom: calc(var(--bottom-h, var(--surface-h)) + 8px);
}

/* --------------------------------------------------------------- typography */

.kicker {
  font: 600 15px/1.4 var(--font);
  color: var(--ink-kicker);
  letter-spacing: 0.4px;
  text-align: center;
}

/* 34px, and the number is MEASURED rather than chosen (fix 5).
 *
 * The typing sequence's longest string — `"Add eggs, bacon and yogurt to my list"` —
 * wrapped to THREE lines at the design's 40px and read as a paragraph rather than a
 * spoken example. 34 is the largest size at which EVERY string in the sequence fits
 * two lines: at 36 and 35 the long one is still three, at 34 it is two. A cliff, not
 * a gradient.
 *
 * It only holds with the container at 24px of side padding. At the previous 30px the
 * narrow viewport (375, 315px available) is three lines even at 34 — so the padding
 * is half of this fix and changing the size alone would have left it broken on the
 * smaller phone while looking right on the larger one.
 *
 * MARGIN, measured: the unwrapped string at 34px in Plus Jakarta Sans is 608px, so
 * an even split needs 304 against the 327 available at 375. The system fallback that
 * renders before the webfont lands is NARROWER (291), so an unloaded font is the
 * safe direction rather than the risky one. */
/* 34px/1.2 with -1.2 tracking, per browser frame 16a. It was /1.1 and -1.4.
 *
 * MEASURED, and this is why the 88pt block below it is safe: probed at the frame's
 * type in the frame's 341px column, the longest rotating phrase we ship — "Add
 * eggs, bacon and yogurt to my list" — comes to 82px, which is 2.01 lines. That is
 * exactly the "two lines measure 82pt" 16a states, so the frame's reservation was
 * derived against copy the same length as ours and the three-line cliff is not
 * near. `headline.reserveHeight` re-measures at the COMPUTED font, so it follows
 * this rule without knowing it exists. */
.display {
  font: 800 34px/1.2 var(--font);
  color: var(--ink);
  letter-spacing: -1.2px;
  text-align: center;
  text-wrap: balance;
  margin: 0;
}

/* ONE STEP DOWN THE SCALE, 27px -> 24px (DR2). 27 is the display size and this is
 * prose, not a headline: at four sentences it filled the screen before the cards
 * had a chance. 24/1.35 is the next rung the type scale already uses (11a's live
 * transcript is the same size), so this is a step and not a new value. */
/* 21px/1.28, per browser frame 18a — down from 24/1.35. Three lines now cost
 * 80.6px against 97.2, and that 16.6px is most of what pays for 18a's second
 * complete card at the 646 band. */
.answer {
  font: 700 21px/1.28 var(--font);
  color: var(--ink);
  letter-spacing: -0.4px;
  text-wrap: pretty;
  margin: 8px 0 0;
}

/* §7.1 rule 3 wants one or two sentences — a caption for a structured result,
 * not the result. The service sometimes writes four. Clamping keeps §8.4's "two
 * results visible without scrolling" true; the full text is one tap away, so
 * nothing is hidden, only folded. */
/* THREE LINES, FLAT, AS 18a DRAWS IT.
 *
 * It was briefly derived from the measured furniture (`--answer-lines`), because
 * the list-count pill pushed `--bottom-h` to 130 and there were 31.8px to find
 * from somewhere. The pill no longer contributes to that number — it floats — so
 * the answer screen reads 98 whatever is on the list, the derivation returned 3
 * at every reachable value, and a mechanism that can only ever produce its own
 * fallback is dead code. Removed rather than left in place as a safety net for a
 * pressure that no longer exists. */
.answer--clamped {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* A LOCATION ANSWER HAS NO PROSE (item 5), and an empty `<h2>` is not free: its
 * `margin: 12px 0 0` and its line box still sit between the query and the strip.
 *
 * `display: none` rather than `[hidden]`, which is only a UA rule that any
 * component `display` outranks. AND DECLARED AFTER `.answer--clamped`, not
 * before: the two are both (0,1,0) and both set `display`, so SOURCE ORDER is
 * the whole tie-break — written above it, the clamp's `-webkit-box` won and the
 * empty heading kept its box while looking, in the stylesheet, as though it had
 * been hidden. `.answer--clamped` is applied at query start, so the two classes
 * really are on the node together. */
.answer--empty {
  display: none;
}

.answer-more {
  appearance: none;
  border: 0;
  background: none;
  /* margin, not padding: 18a spaces it 6px BELOW the answer's box, and padding
     here would put the gap inside the tap target instead of above it. */
  padding: 0;
  margin-top: 6px;
  font: 700 14.5px var(--font);
  color: var(--accent);
  cursor: pointer;
}

.answer-more[hidden] {
  display: none;
}

.echo {
  /* 16px, per browser frame 18a (was 17). */
  font: 500 16px/1.4 var(--font);
  color: var(--ink-faint);
  margin: 0;
}

/* THE QUOTATION MARKS ARE DRAWN, NOT STORED (design 2b, item 3). The echo's one
 * guarantee is that it is what was said or typed, byte-identical — wrapping the
 * string in `main.js` would have made every downstream reader of that node see a
 * query nobody uttered. `:not(:empty)` so the pre-answer state is not a pair of
 * bare quotes around nothing. */
.echo:not(:empty)::before {
  content: '"';
}

.echo:not(:empty)::after {
  content: '"';
}

.label {
  font: 700 12px var(--font);
  color: var(--ink-faint);
  letter-spacing: 0.7px;
}

.muted {
  font: 500 15px var(--font);
  color: var(--ink-faint);
}

/* ------------------------------------------------------------------- the orb */

.orb {
  position: relative;
  width: 190px;
  height: 190px;
  border-radius: 50%;
  overflow: hidden;
  flex: none;
  background: radial-gradient(
    circle at 50% 62%,
    var(--accent-hot) 0%,
    #ff7a1a 26%,
    #ffb25e 48%,
    #ffdca8 66%,
    #fff3e2 82%,
    #fff 100%
  );
  box-shadow: 0 0 60px rgba(255, 120, 20, 0.35), inset 0 -10px 30px rgba(255, 255, 255, 0.6);
  filter: blur(0.4px);
}

.orb__wave {
  position: absolute;
  border-radius: 46% 54% 58% 42%;
}

.orb__wave--a {
  width: 120%;
  height: 120%;
  left: -10%;
  top: -4%;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(255, 58, 0, 0.9) 0%,
    rgba(255, 72, 4, 0.45) 40%,
    rgba(255, 110, 20, 0) 70%
  );
  animation: waveA 7.5s ease-in-out infinite;
}

.orb__wave--b {
  width: 104%;
  height: 104%;
  left: -2%;
  top: -6%;
  border-radius: 58% 42% 45% 55%;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(255, 138, 31, 0.6) 0%,
    rgba(255, 150, 45, 0.28) 42%,
    rgba(255, 170, 70, 0) 70%
  );
  animation: waveB 9.5s ease-in-out infinite;
}

.orb__wave--c {
  width: 76%;
  height: 76%;
  left: 6%;
  top: 52%;
  border-radius: 52% 48% 40% 60%;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(255, 196, 120, 0.45) 0%,
    rgba(255, 205, 140, 0.16) 46%,
    rgba(255, 215, 160, 0) 72%
  );
  animation: waveC 11s ease-in-out infinite;
}

/* Listening speeds the orb up — motion responds to state (§8.3). */
.orb--live .orb__wave--a {
  animation-duration: 4.6s;
}
.orb--live .orb__wave--b {
  animation-duration: 6s;
}
.orb--live .orb__wave--c {
  animation-duration: 7.4s;
}

@keyframes waveA {
  0%,
  100% {
    transform: scale(1) rotate(0deg);
  }
  50% {
    transform: scale(1.06) rotate(8deg);
  }
}
@keyframes waveB {
  0%,
  100% {
    transform: scale(1.04) rotate(0deg);
  }
  50% {
    transform: scale(0.96) rotate(-10deg);
  }
}
@keyframes waveC {
  0%,
  100% {
    transform: scale(0.98);
  }
  50% {
    transform: scale(1.08);
  }
}

/* ----------------------------------------------- the orb, stirring (item 2) */

/* MORE MOTION, INSIDE THE SPHERE — not a bigger pulse of the whole thing.
 *
 * The three base waves breathe: each is a two-stop scale-and-tilt that returns to
 * where it started, so at any instant the orb is a slightly different size and
 * nothing appears to move WITHIN it. Stirring is a different effect and needs two
 * things the base does not have:
 *
 *   * FOUR stops rather than two, with the rotation carrying on in one direction
 *     through the cycle instead of reversing at the midpoint — that is what reads as
 *     circulation rather than as breathing;
 *   * the layers turning at DIFFERENT rates and in OPPOSITE senses, so they shear
 *     past each other. Two layers rotating together are one layer.
 *
 * `--whirl` is applied only where the orb is decorative and idle. The LIVE orb keeps
 * the plain waves: `.orb--live` speeds them up to mean "listening", and a permanent
 * churn underneath would leave that state with nothing to say.
 */
@keyframes whirlA {
  0% {
    transform: scale(1) rotate(0deg) translate(0, 0);
  }
  25% {
    transform: scale(1.07) rotate(90deg) translate(2.5%, -2%);
  }
  50% {
    transform: scale(0.97) rotate(180deg) translate(0, 2.5%);
  }
  75% {
    transform: scale(1.05) rotate(270deg) translate(-2.5%, 0);
  }
  100% {
    transform: scale(1) rotate(360deg) translate(0, 0);
  }
}

@keyframes whirlB {
  0% {
    transform: scale(1.04) rotate(0deg) translate(0, 0);
  }
  33% {
    transform: scale(0.94) rotate(-120deg) translate(-3%, 1.5%);
  }
  66% {
    transform: scale(1.08) rotate(-240deg) translate(2%, -2.5%);
  }
  100% {
    transform: scale(1.04) rotate(-360deg) translate(0, 0);
  }
}

@keyframes whirlC {
  0% {
    transform: scale(0.96) rotate(0deg) translate(0, 0);
  }
  50% {
    transform: scale(1.12) rotate(160deg) translate(-4%, -3%);
  }
  100% {
    transform: scale(0.96) rotate(320deg) translate(0, 0);
  }
}

/* A fourth, small and bright, crossing the others. It is a `::after` rather than a
 * fourth wave element so the effect is available to every orb in the markup without
 * four of them needing an extra child. */
@keyframes whirlD {
  0% {
    transform: rotate(0deg) scale(1);
  }
  50% {
    transform: rotate(180deg) scale(1.18);
  }
  100% {
    transform: rotate(360deg) scale(1);
  }
}

/* THE GRADIENTS ARE RE-CENTRED OFF-AXIS, and without this the whole effect is
 * invisible. The base waves are `radial-gradient(circle at 50% 50%, …)` — rotating a
 * shape whose fill is symmetric about its own centre of rotation changes nothing you
 * can see, so the first build of this animated correctly and looked completely
 * static. Only the blob's `border-radius` edge moved, under a 0.4px blur, inside a
 * sphere of the same colour.
 *
 * Off-centre, the bright core ORBITS, which is the thing being asked for. Same
 * colours, same alphas as the base waves; only the origin moves. */
.orb--stir .orb__wave--a {
  background: radial-gradient(
    circle at 34% 40%,
    rgba(255, 58, 0, 0.92) 0%,
    rgba(255, 72, 4, 0.46) 38%,
    rgba(255, 110, 20, 0) 68%
  );
  animation-name: whirlA;
  animation-duration: 9s;
  animation-timing-function: linear;
}

.orb--stir .orb__wave--b {
  background: radial-gradient(
    circle at 68% 36%,
    rgba(255, 138, 31, 0.66) 0%,
    rgba(255, 150, 45, 0.3) 40%,
    rgba(255, 170, 70, 0) 68%
  );
  animation-name: whirlB;
  animation-duration: 13s;
  animation-timing-function: linear;
}

.orb--stir .orb__wave--c {
  background: radial-gradient(
    circle at 40% 66%,
    rgba(255, 210, 150, 0.6) 0%,
    rgba(255, 214, 160, 0.24) 44%,
    rgba(255, 220, 175, 0) 70%
  );
  animation-name: whirlC;
  animation-duration: 17s;
  animation-timing-function: linear;
}

.orb--stir::after {
  content: '';
  position: absolute;
  width: 54%;
  height: 54%;
  left: 24%;
  top: 18%;
  border-radius: 60% 40% 55% 45%;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(255, 244, 226, 0.72) 0%,
    rgba(255, 214, 156, 0.3) 44%,
    rgba(255, 200, 130, 0) 72%
  );
  animation: whirlD 11s linear infinite;
}

@keyframes caret {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}

.caret {
  display: inline-block;
  width: 3px;
  height: 0.72em;
  background: var(--accent);
  vertical-align: -0.1em;
  margin-left: 3px;
  animation: caret 1s step-end infinite;
}

/* ------------------------------------------------------------------ buttons */

/* An element with `hidden` must actually be hidden.
 *
 * `[hidden]` is only a UA `display: none`, so ANY component rule that sets its
 * own `display` silently outranks it — and this file already patches that
 * per-component three times (`.surface[hidden]`, `.toast[hidden]`,
 * `.answer-more[hidden]`). The failure is invisible: the element is "hidden" in
 * the DOM and painted on the screen, which is how the read-back's two action
 * buttons rendered as empty black and white slabs during parsing.
 *
 * One rule instead of a fourth patch, so the next component cannot inherit it. */
[hidden] {
  display: none !important;
}

.btn {
  appearance: none;
  border: 0;
  font-family: var(--font);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
}

.btn--primary {
  width: 100%;
  height: 66px;
  border-radius: var(--r-tile);
  background: var(--ink-black);
  color: #fff;
  font: 700 20px var(--font);
}

.btn--accent {
  width: 100%;
  height: 68px;
  border-radius: var(--r-card);
  background: var(--accent);
  color: #fff;
  font: 800 20px var(--font);
  box-shadow: var(--shadow-accent);
}

.btn--ghost {
  min-height: var(--touch-min);
  padding: 0 20px;
  border-radius: var(--r-control);
  border: 1px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink);
  font: 600 16px var(--font);
}

.btn--chip {
  min-height: 48px;
  padding: 13px 18px;
  border-radius: var(--r-chip);
  border: 1px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink);
  font: 600 15px var(--font);
  text-align: left;
}

.btn:disabled,
.home-card[aria-disabled='true'] {
  opacity: 0.45;
  cursor: default;
}

/* ------------------------------------------------------- home destinations */

/* FOUR DESTINATIONS, THREE SIZES, RANKED BY AREA — design `10a`.
 *
 * This replaces the four EQUAL tiles of `2f`. Equal tiles are what a screen has
 * instead of a hierarchy: the thing this product is for (building a list) got the
 * same 92px box as the thing behind a sign-in wall, and reading order was the only
 * thing saying which mattered. `10a` ranks them by area instead — a full-width
 * accent card, then two half-width cards, then a single full-width row.
 *
 * EVERY NUMBER BELOW IS THE FRAME'S, read as computed values off the rendered
 * frame rather than off its source text, because the source is inline styles whose
 * box model depends on `box-sizing` — and `10a` sets it on three of the four cards
 * and not the fourth. See `docs/CN_CQ_UI_BATCH.md` §1.
 *
 * `minmax(0, 1fr)` and NOT `1fr`, for the same reason the four-tile grid needed it:
 * `1fr` is `minmax(auto, 1fr)`, so the widest label sets its column's floor and the
 * two half cards stop being halves. At 390px this yields the frame's 166px exactly. */
.home-cards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  padding: 24px 24px 0;
  /* The frame's corner radius, shared by all four cards. Not `--r-tile` (20px) or
   * `--r-card` (22px): `10a` draws 24px and the three sizes must agree with each
   * other before they agree with any other screen. */
  --r-home: 24px;
  /* 11a: the lower cards slide DOWN as the listening surface takes over.
   * Declared here, on the resting element, so removing `[data-speaking]`
   * runs it backwards. */
  transition:
    opacity 0.26s ease,
    transform 0.34s cubic-bezier(0.3, 0.7, 0.3, 1);
}

.home-card {
  border-radius: var(--r-home);
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-card);
  /* `border-box` on every card, including the flat row. THE FRAME OMITS IT ON THE
   * FOURTH ONE, which is why that card's declared `height: 62px` renders as 64px
   * there — 62 plus two 1px borders. Reproducing the omission would reproduce a
   * slip, so the height below is the frame's RENDERED 64px with the box model made
   * consistent, and the two agree on screen. */
  box-sizing: border-box;
  position: relative;
  cursor: pointer;
  text-align: left;
  font-family: var(--font);
}

/* The chevron every card carries, top-right on the tall ones and centred on the
 * flat row. `#707274` is the frame's own grey and has no token — it is lighter
 * than `--ink-muted` and darker than `--ink-faint`, so neither would be this. */
.home-card__go {
  color: #707274;
  font-size: 20px;
  line-height: 1;
}

.home-card__title {
  font: 800 21px var(--font);
  color: var(--ink);
  letter-spacing: -0.6px;
}

.home-card__sub {
  font: 500 13px var(--font);
  color: var(--ink-muted);
  margin-top: 2px;
}

/* ---- the accent card: full width, and the only one that is not white ---- */

.home-card--primary {
  grid-column: 1 / -1;
  height: 96px;
  background: linear-gradient(120deg, var(--accent-hot) 0%, #ff7a1a 62%, #ff9a3d 100%);
  box-shadow: 0 10px 26px rgba(255, 90, 0, 0.32);
  border: 0;
  /* The rings below are drawn far larger than the card and are meant to be cut by
   * its corner. */
  overflow: hidden;
  padding: 18px 22px;
}

.home-card--primary .home-card__go {
  position: absolute;
  right: 20px;
  top: 14px;
  font: 800 22px var(--font);
  color: #fff;
  line-height: 1;
}

/* THREE CONCENTRIC RINGS, struck from one origin off the card's right edge, at
 * 120 / 190 / 270px with the stroke fading as they widen. Purely decorative, so
 * `aria-hidden` in the markup and no hit area here. */
.home-card__rings {
  position: absolute;
  right: 34px;
  top: 48px;
  pointer-events: none;
}

.home-card__ring {
  position: absolute;
  border-radius: 50%;
  border: 1.5px solid rgba(255, 255, 255, 0.34);
}

.home-card__ring:nth-child(1) {
  width: 120px;
  height: 120px;
  margin: -60px -60px;
}

.home-card__ring:nth-child(2) {
  width: 190px;
  height: 190px;
  margin: -95px -95px;
  border-color: rgba(255, 255, 255, 0.24);
}

.home-card__ring:nth-child(3) {
  width: 270px;
  height: 270px;
  margin: -135px -135px;
  border-color: rgba(255, 255, 255, 0.14);
}

/* `position: relative` on the two text lines so they paint above the rings
 * without a z-index race — the rings are earlier in the markup and unpositioned
 * children would lose to them. */
.home-card__kicker {
  position: relative;
  font: 600 13px var(--font);
  color: rgba(255, 255, 255, 0.8);
  letter-spacing: 0.6px;
}

.home-card--primary .home-card__title {
  position: relative;
  font: 800 29px var(--font);
  color: #fff;
  letter-spacing: -1.1px;
  margin-top: 4px;
}

/* Bottom-right, on the same line as nothing else — the frame positions it
 * absolutely rather than letting it sit under the title. */
.home-card__hint {
  position: absolute;
  right: 22px;
  bottom: 18px;
  font: 700 14px var(--font);
  color: rgba(255, 255, 255, 0.9);
}

/* ---- the two half cards ---- */

.home-card--half {
  height: 80px;
  padding: 18px 20px;
  display: flex;
  flex-direction: column;
  /* Bottom-aligned, so the title/subtitle pair sits on the card's floor and the
   * two cards' baselines agree whether or not a subtitle wraps. */
  justify-content: flex-end;
}

.home-card--half .home-card__go {
  position: absolute;
  right: 18px;
  top: 16px;
}

/* ---- the flat full-width row ---- */

.home-card--row {
  grid-column: 1 / -1;
  height: 64px;
  display: flex;
  align-items: center;
  padding: 0 22px;
}

.home-card--row .home-card__title {
  flex: 1;
}

/* ------------------------------------------------------------ product cards */

.cards {
  display: flex;
  flex-direction: column;
  gap: var(--gap-m);
  padding: 18px 22px 0;
}

.card {
  /* The `Buy 1 Get 1 Free` pill hangs above the card's top-right edge, so the
   * card is its containing block and must not clip it. The `% off` pill is no
   * longer a reason for this rule — it now nests INSIDE the top-left corner —
   * but BOGO still overhangs and this stays for it alone. */
  position: relative;
  overflow: visible;
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  padding: var(--pad-card);
  display: flex;
  gap: var(--gap-l);
  align-items: center;
  box-shadow: var(--shadow-card);
  background: var(--paper);
  text-align: left;
  width: 100%;
  font-family: var(--font);
  cursor: pointer;
}

/* NO FRAME ON A PRODUCT IMAGE, ANYWHERE (product ruling). A packshot reads as a
 * floating object: no border and no shadow, on every surface that shows one — the
 * answer and see-all cards, the deals column, the walking screen's two
 * recommendations, and the product screen's own packshot.
 *
 * `background` STAYS and is not part of the frame. Most catalogue packshots are
 * transparent PNGs, so the ground is what stops them rendering against whatever
 * happens to be behind them; it is the same white as the card it sits on, which is
 * exactly why removing the 1px rule around it is what makes the image float. */
.card__shot {
  width: 96px;
  height: 116px;
  flex: none;
  border-radius: var(--r-thumb);
  object-fit: contain;
  background: var(--paper);
}

.card__shot--empty {
  background: repeating-linear-gradient(
    45deg,
    var(--skeleton-a) 0 7px,
    var(--skeleton-b) 7px 14px
  );
}

.card__main {
  flex: 1;
  min-width: 0;
}

.card__brand {
  font: 600 13px var(--font);
  color: var(--ink-faint);
  letter-spacing: 0.3px;
  text-transform: uppercase;
}

.card__name {
  font: 800 22px/1.15 var(--font);
  color: var(--ink);
  margin-top: 6px;
  letter-spacing: -0.5px;
  /* Catalog names run to 90+ characters ("Pictsweet Farms - Seasoned Vegetables
   * For The Air Fryer - ... - 11 Ounce"). Two lines on a card keeps the price and
   * the destination in the hierarchy §14 asks for; the product screen shows the
   * name in full. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.card__meta {
  font: 500 15px var(--font);
  color: var(--ink-muted);
  margin-top: 5px;
}

.card__price {
  font: 800 26px var(--font);
  color: var(--ink);
  margin-top: 9px;
  display: flex;
  align-items: baseline;
  gap: 10px;
}

/* ACCENT AND STRUCK, not faint (designs 2b and 2l). The same treatment
 * `.deal-card__was` already carries — the two are deliberately separate
 * components, so this is stated rather than shared. */
.card__was {
  font: 600 14px var(--font);
  color: var(--accent);
  text-decoration: line-through;
}

/* `--good` and not the frames' #1DA05B, following the ruling already made for
 * `.deal-card__save`: both are the same green statement and this palette has
 * one. */
.card__save {
  font: 700 13px var(--font);
  color: var(--good);
  margin-left: auto;
}

.card__off {
  font: 700 13px var(--font);
  color: var(--accent);
}

/* ---------------------------------------------- the answer card (design 2b)
 *
 * 2b and 2l disagree about exactly two lines. 2l is the base — it is what the
 * compare and see-all lists show — and this is the answer screen's variant.
 * `.card--answer .card__brand` is (0,2,0) against the base's (0,1,0), so it
 * wins on specificity rather than on source order. */
.card--answer .card__brand {
  font: 500 14px var(--font);
  color: var(--ink-muted);
  letter-spacing: normal;
  text-transform: none;
  margin-top: 0;
}

.card--answer .card__name {
  font: 800 25px/1.15 var(--font);
  margin-top: 10px;
}


.badges {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.badge {
  font: 700 11px var(--font);
  padding: 5px 9px;
  border-radius: 7px;
  background: var(--chip-bg);
  color: var(--ink-muted);
}

.badge--sale {
  background: var(--accent-tint);
  color: var(--accent);
}

.badge--sponsored {
  background: var(--chip-bg);
  color: var(--ink-muted);
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}

.tag {
  font: 600 12px var(--font);
  padding: 4px 9px;
  border-radius: 7px;
  background: var(--good-tint);
  color: var(--good);
}

/* Skeletons paint on `result`, before the prose has streamed (§6.3). */
@keyframes shimmer {
  0% {
    background-position: -200px 0;
  }
  100% {
    background-position: 200px 0;
  }
}

.skeleton {
  border-radius: 8px;
  background: linear-gradient(
    90deg,
    var(--skeleton-a) 0px,
    var(--skeleton-b) 80px,
    var(--skeleton-a) 160px
  );
  background-size: 400px 100%;
  animation: shimmer 1.2s linear infinite;
}

.card--skeleton {
  pointer-events: none;
}

/* ---------------------------------------------------------------- the stage */

.stage {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
  font: 600 15px var(--font);
  color: var(--ink-faint);
}

.stage__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  animation: pulse 1s ease-in-out infinite;
}

@keyframes pulse {
  0%,
  100% {
    opacity: 0.35;
    transform: scale(0.8);
  }
  50% {
    opacity: 1;
    transform: scale(1.15);
  }
}

/* ------------------------------------------------------------------ the map */

/* THE PLAN IS THE DESIGNER'S DRAWING, LIFTED. Almost nothing here styles it —
 * `assets/store-plan.html` carries its own fills, its own type and its own
 * labels. What this file owns is the frame the plan sits in and the one mark we
 * add to it. Restyling the plan from here would be the same mistake as redrawing
 * it: two sources for one appearance, and the drift is silent. */

/* The plan's own pixel box. Fixed, because the drawing is fixed; the stage
   scales it. `transform-origin: center` so the base fit-scale and a pinch
   compose around the same point rather than fighting for the top-left. */
.plan-map {
  position: relative;
  width: 1385px;
  height: 900px;
  flex: none;
  transform-origin: center center;
  background: #fff;
}

/* The marks ride over the plan in the plan's own coordinates. `pointer-events:
   none` so the pinch handler sees the stage, not the overlay. */
.plan-map__marks {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* ABOVE THE DRAWING, BELOW THE NAMES. The marks are appended last, so without
     this they paint over the labels and the destination hides the name of the
     place it is in — measured: "Dairy Aisle" was covered by its own highlight,
     which is the map answering a question by deleting the answer. The label is
     what the shopper reads and the mark is what they look for; both, in that
     order. */
  z-index: 1;
}

.plan-map [data-label] {
  z-index: 2;
}

/* THE DESTINATION. The only thing on this map we draw, and the only thing that
 * is allowed to be this loud: the plan is grey, its two landmarks are red and
 * green, and this is the one shelf the shopper is walking to. Sized in plan
 * units so it scales with the drawing. */
.plan-map__mark {
  fill: var(--accent);
  stroke: #7a2b00;
  stroke-width: 3;
  stroke-linejoin: round;
}

/* Same concept in several places: one solid, the others outlined (§9.2 rule 7) */
.plan-map__mark--also {
  fill: rgba(255, 90, 0, 0.18);
  stroke: var(--accent-hot);
  stroke-width: 2.5;
  stroke-dasharray: 7 5;
}

/* Zone-level, when the shelf is not known: the whole zone, and the copy says so
   rather than the drawing implying shelf precision. */
.plan-map__zone-on {
  fill: rgba(255, 90, 0, 0.16);
  stroke: var(--accent-hot);
  stroke-width: 3;
  stroke-linejoin: round;
}

/* full screen (§9.3) */
.map-full {
  position: absolute;
  inset: 0;
  background: var(--paper);
  z-index: 40;
  display: none;
  flex-direction: column;
  padding: 20px;
}

.map-full[data-open='true'] {
  display: flex;
}

.map-full__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}

.map-full__title {
  font: 800 24px var(--font);
  color: var(--ink);
  letter-spacing: -0.5px;
}

.map-full__stage {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* PINCH TO ZOOM, so the browser must not claim the second finger for its own
     page zoom — with the default the handler sees one pointer that jumps and the
     gesture reads as broken. Same reason the walking screen sets it. */
  touch-action: none;
  /* The zoomed map is clipped to its stage rather than painting over the title
     and the close button. */
  overflow: hidden;
}

/* No transition on the transform: a pinch must track the fingers, and an eased
   scale during a live gesture is what makes a map feel like it is arguing. */
.map-full__stage .plan-map {
  will-change: transform;
}

/* Zoomed in, a one-finger drag pans. Grab is the honest affordance for that and
   costs nothing on a touch screen. */
.map-full__stage[data-zoomed='true'] {
  cursor: grab;
}

/* THE PLAN IS SCALED BY A TRANSFORM, NOT BY A VIEWBOX, and that is forced rather
 * than chosen: its labels are HTML nodes positioned over an SVG, so there is no
 * single viewport that owns both layers. The stage measures itself and sets
 * `--plan-fit` to whichever of the two axis ratios is smaller — the whole store,
 * uncropped (§9.2 rule 1) — and the pinch multiplies that same variable. One
 * number, two multipliers, so a gesture cannot fight the fit.
 *
 * NO `width`/`height` RULE HERE. A transform does not change layout size, so the
 * plan still occupies its own 1385x900 in the flex centring; that is why the
 * stage clips (`overflow: hidden`) and why `transform-origin` is the centre. */
.map-full__stage {
  --plan-fit: 1;
}

/* The resting state, as a stylesheet rule rather than only as something JS does.
   The inline transform the pinch writes wins over this, so it is a fallback and
   not a second implementation: if `apply()` never runs the map is still fitted
   rather than showing its top-left corner at 1:1. */
.map-full__stage .plan-map {
  transform: scale(var(--plan-fit));
}

.iconbtn {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--chip-bg);
  border: 0;
  color: var(--ink-muted);
  font: 600 18px var(--font);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: none;
}

/* ------------------------------------------------- the persistent surface (§6) */

.surface {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 18px 12px calc(20px + env(safe-area-inset-bottom, 0));
  display: flex;
  align-items: center;
  gap: 8px;
  z-index: 20;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--paper-warm) 38%);
  /* 11a: the bar leaves with the cards, 50ms behind them. Same reason as
   * `.home-cards` for living on the resting rule. */
  transition:
    opacity 0.24s ease,
    transform 0.34s cubic-bezier(0.3, 0.7, 0.3, 1) 0.05s;
}

.surface[hidden] {
  display: none;
}

/* Touch targets stay at 56px+ even though the boxes are narrower — the ceiling
 * is four controls, and squeezing a fifth in is what §6 warns against. */
.surface__btn {
  width: 52px;
  height: 60px;
  border-radius: var(--r-control);
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-tile);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: none;
}

.surface__ask {
  flex: 1;
  min-width: 0;
  height: 60px;
  border-radius: var(--r-card);
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-float);
  display: flex;
  align-items: center;
  padding: 0 6px 0 14px;
  gap: 8px;
  cursor: pointer;
}

.surface__example {
  flex: 1;
  min-width: 0;
  font: 600 15px var(--font);
  color: var(--ink-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-align: left;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
}

/* The keyboard glyph on the pill. A 44px touch target (the iOS minimum) inside the
 * pill's right edge, so typing has a visible affordance now that the standalone
 * keyboard box is gone. It shares `data-action="keyboard"` with the pill itself —
 * one route, two ways to hit it, not two routes. */
.surface__keyglyph {
  width: 44px;
  height: 44px;
  flex: none;
  border: 0;
  background: none;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.surface__mic {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  border: 0;
  background: radial-gradient(circle at 50% 60%, var(--accent-hot), var(--accent-warm));
  box-shadow: 0 4px 12px rgba(255, 90, 0, 0.35);
  cursor: pointer;
  flex: none;
}

/* ---------------------------------------------------------------- waveform */

.wave {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 6px;
  height: 52px;
  margin-top: 26px;
}

.wave__bar {
  width: 5px;
  height: 8px;
  background: var(--accent);
  border-radius: 3px;
  /* Height is set from the real input level in voice.js — never a fake
   * animation. "Is it hearing me?" must never be a question (§8.3 rule 5). */
  transition: height 60ms linear;
}

/* ------------------------------------------------------------------ notices */

.notice {
  margin: 16px 22px 0;
  padding: 14px 16px;
  border-radius: var(--r-control);
  background: var(--accent-bg);
  border: 1px solid var(--accent-line);
  font: 600 15px/1.4 var(--font);
  color: var(--ink);
}

.notice--plain {
  background: var(--paper);
  border-color: var(--line);
  color: var(--ink-muted);
}

/* A REFUSED MICROPHONE HIDES EVERYTHING THAT MEANS "LISTENING".
   The orb, the level bars and the example chips are all claims that capture is
   running, and none of them is true behind a permission panel. Hiding them is also
   what stops a four-step path pushing the examples out of `.screen__body`, which
   CLIPS rather than scrolls — measured at 375x812: 988px of content in an 812px box,
   with the chips entirely below the fold and unreachable.

   SPECIFICITY IS DELIBERATE, NOT INCIDENTAL. `[data-blocked]` on the section plus a
   descendant is (0,3,1); the rules being overridden are single classes and inline
   `display: flex` on the orb frame, which is why the frame carries `display: none`
   with `!important` and the others do not need it. Three of the five defects the
   recipe screens shipped were a correctly written rule that lost on specificity, so
   this is verified with `getComputedStyle`, not by reading it back. */
[data-screen='voice'][data-blocked='true'] [data-region='voice-orb-frame'] {
  display: none !important;
}

[data-screen='voice'][data-blocked='true'] [data-region='wave'],
[data-screen='voice'][data-blocked='true'] [data-region='voice-examples'] {
  display: none;
}

/* The microphone-permission recovery panel. A numbered path the shopper has to
   follow in another app, so it is set as a real list rather than a run-on sentence:
   they will be looking away from this screen while they do it.

   The panel lives inside `[data-region="voice-error"]`, which sits above a
   `flex: 1` spacer on the voice screen, so it can grow to four steps without
   pushing the example chips off the bottom. */
.notice__title {
  font: 700 17px/1.3 var(--font);
  margin-bottom: 6px;
}

.notice__lead {
  font: 600 14.5px/1.45 var(--font);
  margin-top: 6px;
}

.notice__alt {
  font: 500 13.5px/1.45 var(--font);
  color: var(--ink-muted);
  margin-top: 8px;
}

.mic-steps {
  margin: 10px 0 0;
  padding-left: 22px;
  font: 600 14.5px/1.6 var(--font);
}

.mic-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 14px;
}

/* THE TOAST SITS IN THE PERSISTENT SURFACE'S BAND, NOT ABOVE IT (§CQ RULE 2).
 *
 * It was `bottom: calc(var(--surface-h) + 16px)` — 120px, which is INSIDE the
 * content area, so it sat over whatever each screen put at the bottom. MEASURED:
 * 49px of the recipe screen's own 62px "View my list" button, `.list-count` on
 * all nine screens that show it, and three controls on the walking screen.
 *
 * THE PER-SCREEN NUDGE IS GONE, and it is worth saying why rather than just
 * deleting it. It read:
 *
 *     [data-screen='walking'][data-active='true'] ~ .toast {
 *       bottom: calc(var(--surface-h) + 96px);
 *     }
 *
 * and its comment said it existed to clear the walking screen's primary action.
 * At that offset the toast still covered `pwa-continue`, a deal suggestion and
 * "See all" — it did not fix the screen it was written for. A rule that needs an
 * exception, and whose exception does not work, is two bugs.
 *
 * No fixed band is clear (docs §4.3): top 12 hits the back button, top 64 hits
 * the filter chips, 120 and 200 hit content. The only placement measured at ZERO
 * overlaps is this one, with the surface made non-interactive by `data-toast`
 * below — and this band is already reserved exactly once by
 * `.screen--with-surface .screen__body`, so nothing reflows and nothing is
 * reserved twice. */
.toast {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 16px;
  padding: 14px 16px;
  border-radius: var(--r-control);
  background: var(--ink);
  color: #fff;
  font: 600 15px/1.4 var(--font);
  z-index: 60;
  box-shadow: var(--shadow-float);
}

/* WHATEVER IS IN THE TOAST'S BAND STANDS ASIDE WHILE THE TOAST SHOWS.
 *
 * `pointer-events: none` is the half that makes the guarantee — "no toast covers
 * an interactive control" is satisfied by the occupant not BEING interactive,
 * rather than by the toast trying to dodge it, and no band is free to dodge into.
 * `opacity` is the half that makes it look deliberate instead of like a toast
 * dropped on top of the ask field.
 *
 * The class is applied by `toast()` to the elements it measures as intersecting,
 * NOT to a hand-written list of selectors. The usual occupant is `.surface`, but
 * on the screens with no surface the band holds something else — `add-items` puts
 * "Back to my list" there, welcome puts the consent link and the build stamp
 * there — and a list of those would have to be updated by whoever adds the next
 * screen. Computed at show time, it cannot go stale.
 *
 * Nothing here touches `.surface[hidden]`: that belongs to `show()`, which knows
 * which screens have a surface at all, and a toast that overwrote it would have to
 * restore a value a navigation may have changed underneath it. */
.is-toast-yield {
  opacity: 0;
  pointer-events: none;
}

.toast[hidden] {
  display: none;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 16px 22px 0;
}

/* Item 7 / design 2l: the filter row scrolls horizontally instead of wrapping.
 *
 * `flex-wrap: nowrap` is only half of it — the chips also have to refuse to
 * SHRINK, or eight of them compress to fit one line and the row never overflows
 * to scroll at all. `.chip` already carries `flex: none`; this states the wrap
 * and the scroll.
 *
 * The right padding is on a trailing pseudo-element rather than the container:
 * padding-right on a horizontal scroller is dropped at the end of the scroll
 * range by every engine here, so the last chip would sit flush against the edge
 * with nothing after it. */
.chips--rail {
  /* `flex: none` FIRST, and it is not decoration. Both hosts are flex COLUMNS
   * (`.screen__body--fixed`, `.sheet`), so a flex item with content taller than
   * the space left over gets SHRUNK — and with `overflow-y: hidden` below, the
   * chips are then clipped rather than the row growing.
   *
   * MEASURED at 375x664 before this line: the rail computed to 17px tall holding
   * 48px chips, and the first chip hit-tested to something else — visible in the
   * stylesheet as a perfectly ordinary row. This is the same failure
   * `.readback-prefs` already carries `flex: none` for, in a second place; the
   * test that guards that row documents the 62px-to-42px measurement. */
  flex: none;
  flex-wrap: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  padding: 12px 0 0 22px;
}

.chips--rail::-webkit-scrollbar {
  display: none;
}

.chips--rail::after {
  content: '';
  flex: none;
  width: 22px;
}

/* NOTE: Phase 1's `.pager` was the page-dot row. Phase 2 makes `.pager` the
 * horizontal card pager and moves the dots to `.pager__dots`. The old rule is
 * gone rather than left to cascade — its `justify-content: center` centred the
 * overflowing pager, which puts half the content at negative x where no amount
 * of scrolling can reach it. */

/* ------------------------------------------------------------------ product */

.metrics {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--gap-m);
  padding: 14px 22px 0;
}

.metric {
  border: 1px solid var(--line);
  border-radius: var(--r-control);
  padding: 16px 6px;
  text-align: center;
}

.metric__value {
  font: 800 24px var(--font);
  color: var(--ink);
}

.metric__name {
  font: 500 14px var(--font);
  color: var(--ink-muted);
  margin-top: 2px;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The debug panel is an OVERLAY, never a flex child of the layout.
 *
 * REGRESSION IT CAUSED: as an ordinary child of `.screen__body--fixed` it grew to
 * 585px of JSON, and since `.pager` is `flex: 1 1 auto; min-height: 0` the pager
 * collapsed to ZERO height. Ten pages of cards existed, each 154px tall, and none
 * of them were visible — while the dots and the swipe count still rendered,
 * because those are `flex: none`. A diagnostic must never be able to displace the
 * thing it is there to help diagnose. */
.debug {
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: calc(var(--surface-h) + 8px);
  max-height: 26%;
  overflow: auto;
  z-index: 30;
  padding: 12px;
  border-radius: 10px;
  background: rgba(247, 247, 248, 0.97);
  border: 1px solid var(--line-strong);
  font: 400 11px/1.5 var(--font-mono);
  color: var(--ink-muted);
  white-space: pre-wrap;
  word-break: break-all;
}

.debug[hidden] {
  display: none;
}

/* ============================================================ Phase 2 ==== */

/* Screens whose content must fit without scrolling: the pager and the two
 * actions below it are the point of two-results-at-a-time (§8.4, §A2). The
 * answer text and the map give up space first; the actions never do. */
.screen__body--fixed {
  overflow-x: hidden;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* 10px top and a 20px gutter, per browser frame 18a's content region
   (`padding: 10px 20px 0`). It was 22 top on the screen gutter; the 12px that
   frees goes to the pager, which is where 18a's second complete card lives. */
.answer-head {
  flex: 0 0 auto;
  padding: 10px 20px 0;
}


.answer-head__row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.answer-head__row .echo {
  flex: 1;
  min-width: 0;
}

/* 36px, PER FRAME 24c, and the frame choice is deliberate. 18a omits this control
 * entirely and 24c draws it at 36 — where two frames of one screen disagree about
 * whether a control EXISTS, the one that drew it is the one that considered it,
 * and the exit-results ruling stands on its own besides: this always returns home,
 * wherever the search was invoked from, and a swipe does not replace that.
 *
 * MEASURED: the row was 44 tall against 18a's 22.4 bare echo. At 36 it costs 8px
 * less, which goes to the pager where 18a's second complete card lives.
 *
 * `margin-top: -4px` is the frame's: it optically aligns the circle with the first
 * line of the echo rather than with the echo's box. */
.answer-head__row .iconbtn {
  width: 36px;
  height: 36px;
  flex: none;
  margin-top: -4px;
  border-radius: 50%;
  font: 600 18px var(--font);
}

/* One sentence now, so it needs less type than the design's two-line block.
 *
 * THIS RULE HAS TO MOVE WITH THE ONE ABOVE, and that is the hazard of it: it
 * overrides only `font-size`, so leaving it at 24 while `.answer` went to 21/1.28
 * would have produced 24px text on a 1.28 line box — a size the frame never
 * specifies, arrived at by two rules disagreeing. */
.answer {
  font-size: 21px;
}

/* A locate answer also carries the map, and the destination is already the
 * headline of the map block — so the caption gives up a line rather than the
 * cards or the two actions giving up their space. */
.answer--with-map.answer--clamped {
  -webkit-line-clamp: 2;
}

/* Fixed height, so the pager's budget is known and the map block's footer can
 * never be clipped. The SVG inside absorbs the remainder and scales to fit — it
 * is never cropped, only drawn smaller. */
/* The screen's own horizontal padding, which the region did NOT have.
 *
 * It never needed it: the retired `.map-thumb` was an inline-ish button carrying
 * its own margins, so the region could sit full-bleed and the thumbnail still
 * lined up. The strip is `width: 100%`, so it ran edge to edge while the headline
 * above it sat at 26 px — measured in the browser, region left 0 / width 375
 * against text left 26 / width 323. Nothing failed; it just looked wrong, which
 * is the only way this class of defect ever shows up. */
.answer-map {
  flex: none;
  padding: 0 var(--pad-screen);
}

/* --------------------------------------- the expanded product image (AY2) */

/* THE SAME OVERLAY SHAPE AS `.map-full`, INCLUDING THE NOTCH INSET. Both are
 * `position: absolute; inset: 0` over the same viewport, so a padding-top that
 * ignores `env(safe-area-inset-top)` puts the close button behind the status bar
 * in standalone and nowhere else — the defect `.sheet` already paid for once,
 * invisible in a browser tab because the inset is 0 there. */
.shot-full {
  position: absolute;
  inset: 0;
  background: var(--paper);
  z-index: 46;
  display: none;
  flex-direction: column;
  padding: calc(20px + env(safe-area-inset-top, 0)) 20px 20px;
}

.shot-full[data-open='true'] {
  display: flex;
}

.shot-full__head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}

.shot-full__title {
  font: 700 17px/1.3 var(--font);
  color: var(--ink);
  letter-spacing: -0.3px;
  /* A catalogue name runs past 90 characters; the close button must not be
     pushed off the row by one. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* SWIPE IS SCROLL-SNAP, NOT A POINTER HANDLER, and that is a measured choice:
 * `.pager` already proves this pattern on this surface, it tracks the finger
 * natively, and it needs no `IntersectionObserver` — which never fires at all in
 * the preview browser this gets verified in, so a feature built on one cannot be
 * watched working. The dot index comes from a scroll listener reading
 * `scrollLeft`, which is observable. */
.shot-full__track {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.shot-full__track::-webkit-scrollbar {
  display: none;
}

.shot-full__slide {
  flex: 0 0 100%;
  /* `start`, for the reason `.pager__page` documents: with a slide exactly as
     wide as the scrollport, centre-alignment lets Chrome pick a snap target that
     desyncs from scrollLeft and strands the track between two slides. */
  scroll-snap-align: start;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
}

/* CONTAIN, NEVER COVER. A nutrition panel that is cropped to fill the box is a
 * nutrition panel with a column of numbers missing, which is worse than a
 * letterboxed one. */
.shot-full__img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: 14px;
  background: #fff;
}

.shot-full__dots {
  flex: none;
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  padding-top: 14px;
}

/* ONE IMAGE, NO DOTS — and it is `:empty` on the container rather than a `hidden`
 * attribute, because `[hidden]` is only a UA `display: none` and this rule's own
 * `display: flex` would outrank it. The container is left empty for a single
 * image, so there is nothing to lay out and nothing to hide. */
.shot-full__dots:empty {
  display: none;
}

.shot-full__dot {
  width: 8px;
  height: 8px;
  border-radius: 4px;
  /* The resting dot, and the current one is DARKER rather than a different hue —
     the brief's own words, and `--accent` would read as a second meaning.
     `--ink-faint` rather than `--line`: at #ededef against white the resting dot
     is invisible on a phone in a shop, so "two dots, one darker" would render as
     one dot. Both of these are legible and the contrast between them is the
     signal. */
  background: var(--ink-faint);
  border: 0;
  padding: 0;
}

.shot-full__dot--on {
  background: var(--ink);
}

/* The packshot on the product screen is the affordance for all of the above, so
   it has to look tappable. */
.product-shot__open {
  border: 0;
  padding: 0;
  background: none;
  display: block;
  cursor: pointer;
}

/* ------------------------------------------------------------------- pager */

/* ══════ THE PAGER'S WRAPPER, AND WHY THE HEIGHT HAS TO BE STATED ══════
 *
 * HORIZONTALLY THE SCROLLER'S HEIGHT IS FREE; VERTICALLY IT MUST BE STATED. That
 * one sentence is why the first attempt at this rotation was reverted.
 *
 *   - horizontal: pages sit side by side, so the pager's intrinsic height is ONE
 *     page's height. The constraint arrives for nothing.
 *   - vertical: pages stack, so the pager's intrinsic height is ALL TEN pages.
 *
 * The first attempt stated it by absolutely positioning the PAGER inside this
 * wrapper, which removed the pager from the wrapper's content — so the wrapper had
 * no intrinsic height either and collapsed to the 168px floor, leaving the page
 * 115px taller than its own scrollport with card 2 unreachable under mandatory
 * snap. MEASURED at 393x687: scrollport 168, page 283.3.
 *
 * WHAT IS DIFFERENT HERE: the pager stays in NORMAL FLOW and its height is a
 * JS-measured `--page-h`. So the wrapper's intrinsic height IS the pager's height,
 * nothing is out of flow, and there is nothing to collapse. Only the RAIL is
 * absolute, and it is a sibling of the pager rather than a child, so
 * `paintPager()`'s `textContent = ''` cannot destroy it.
 *
 * `--page-h` IS MEASURED, NOT A FIXED CARD HEIGHT. Frame 18a draws the answer card
 * content-sized on purpose, so a constant breaks the first time a name wraps
 * differently or a sale row appears. Same pattern as `--surface-h`, `--bottom-h`
 * and `--band-h`: render, measure, publish. `main.js::measurePageHeight` reads it
 * in the HORIZONTAL configuration, where the browser's own flex algorithm has
 * already done both jobs — the tallest of the ten pages AND the clamp to the room
 * the column actually has.
 *
 * THE WRAPPER IS THE FLEX ITEM. It inherits the pager's former place in the
 * column, so the band-scoped group-centring below applies to IT. */
.pager-wrap {
  position: relative;
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  /* A floor, not zero: defence in depth against the class of bug above. Whatever
   * else appears in this column, the cards keep enough room to be seen.
   * ON THE WRAPPER AND NOT ON THE PAGER, because the wrapper is what the column
   * sizes now — a floor on the inner element is a floor the column never sees. */
  min-height: 168px;
}

.pager {
  flex: 1 1 auto;
  /* `min-height: 0` AND NOT the 168px floor. The floor is the WRAPPER's job (one
     reservation, once — the rule CLAUDE.md records three instances of); a flex
     item's default `min-height: auto` would refuse to shrink below its content,
     which is exactly what has to be allowed when the column is tight. */
  min-height: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

/* ══════ THE VERTICAL PAGER — OPT-IN, ONCE `--page-h` HAS BEEN MEASURED ══════
 *
 * Added by `measurePageHeight()` and never in the markup, so a client that cannot
 * measure keeps the shipped horizontal pager rather than a vertical scroller with
 * no stated height. That is the difference between a degradation and a defect.
 *
 * `flex: 0 0 auto` beside a definite `height`: the pager must neither grow into
 * the wrapper nor shrink below the height that was measured FOR it. The wrapper
 * above it still shrinks, which is where a tight column is absorbed.
 *
 * `.pager__page` KEEPS ITS `flex: 0 0 100%` UNCHANGED, and that is the load-bearing
 * detail. A flex-basis percentage resolves against the container's USED main size,
 * which in a column is the pager's used height — so the page equals its own
 * scrollport BY CONSTRUCTION, at every band, even if something shrinks the pager
 * below `--page-h`. The acceptance property ("the page fits its scrollport") is
 * therefore structural rather than a value that has to be kept in sync.
 *
 * `overscroll-behavior: contain` already reaches the pager from the `*` rule at
 * the top of this file — NOT from a rule of its own — and it is what stops a drag
 * at scrollTop 0 chaining to the document and triggering pull-to-refresh. It is
 * inert on a horizontal-only scroller and load-bearing the moment the axis turns,
 * so the rotation depends on it without adding it. Verified on the computed value.
 * `touch-action` is deliberately NOT set here: it is `auto` on every element from
 * `.pager` to `html`, the pager was always native scroll + snap with no pointer
 * handler, and the browser's own left-edge back gesture is horizontal and
 * history-driven — so rotating the scroll axis does not touch it. */
.pager--v {
  flex: 0 0 auto;
  flex-direction: column;
  height: var(--page-h);
  overflow-x: hidden;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
}

/* ══════ `min-height: 0`, AND IT IS THE WHOLE ACCEPTANCE PROPERTY ══════
 *
 * A FLEX ITEM'S AUTOMATIC MINIMUM SIZE OUTRANKS ITS OWN FLEX-BASIS, and that is
 * what breaks the claim above if this rule is missing. `.pager__page` is
 * `flex: 0 0 100%`, which in a column resolves to the pager's used height — but a
 * column flex item defaults to `min-height: auto`, which refuses to go below its
 * CONTENT height. So the page silently becomes its content rather than its
 * scrollport, and once the two differ the snap boundaries are multiples of the
 * CONTENT height while `scrollTop / clientHeight` counts in scrollport heights.
 * The two desync, `round()` returns the wrong page, and a drag can rest mid-card.
 *
 * MEASURED at 393x646 on the worst real shape — a located answer (map 143.7) whose
 * cards carry the sale rows (137.1 each, +12.3 over the 124.8 of a card with no
 * sale) — WITHOUT this rule:
 *   --page-h 269 (the room), page 282.2, page-vs-scrollport +13.2
 *   scrollHeight 2822 against 10 x 269 = 2690
 *   a mid-page scrollTop of 404 landed on 295.5, which is NOT a page boundary
 *   at least one card partly outside the scrollport on some page
 * WITH it: page 269, delta 0, boundaries exact, every card inside on all ten pages.
 *
 * THIS IS NOT LOOSENING THE SNAP — the snap stays `y mandatory`. It is the
 * opposite: it makes every snap position a real page top, which is what stops a
 * card being STRANDED rather than merely cropped. The 13.2 of overflow then sits
 * INSIDE the page, where `safe flex-end` falls back to flex-start and keeps the
 * TOP of card 1 — byte for byte the behaviour the horizontal pager already has in
 * that state, and the half a shopper can act on.
 *
 * AND THE STATE IS TREATED AS REACHABLE RATHER THAN OBSERVED. Three real located
 * queries (milk, ice cream, cheese) returned 124.8 cards and none of them produced
 * it. Card height is content-driven with no CSS floor, the sale rows add 12.3, and
 * nothing on the located screen prevents an on-sale product — so "the pool is
 * empty of it" is not "it cannot happen", and a property that holds only because
 * today's catalogue is short is not a property. */
.pager--v .pager__page {
  min-height: 0;
}

/* THE DOT RAIL. All of these numbers are MEASURED and recorded in
 * docs/HANDOFF_UP_NEXT_FRAMES.md §6.11 — do not re-derive them.
 *
 * `right: 26px` is a PRODUCT RULING and not the 20pt gutter: the floating pill
 * lives in the gutter and two sessions went into keeping them apart. 26 is the
 * page's own 20px gutter + 6 INSIDE the card, measured rail 361->367 against cards
 * 20->373, which is 6px inside the card and 12px clear of the pill's lane at 379.
 *
 * CONTRAST, and the photo case cannot arise: every ground under all ten dots is
 * white (`elementFromPoint` gave `.card` for eight and the 8px inter-card gap,
 * which also resolves white, for two), and packshots end at x=112 against a rail
 * at 361 — 249px clear. `--ink-faint` measures 2.53:1 on white and `--accent`
 * 3.13:1, against the old `#e1e4e8`'s 1.28:1.
 *
 * `pointer-events: none` on the rail with `auto` on each dot, so the rail cannot
 * swallow a card tap while every dot stays its own target. */
.pager__rail {
  position: absolute;
  right: 26px;
  top: 0;
  bottom: 0;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  pointer-events: none;
}

.pager--v ~ .pager__rail {
  display: flex;
}

.pager__rail .pager__dot {
  pointer-events: auto;
  /* THE ACTIVE DOT ELONGATES ON THE AXIS THE PAGER SCROLLS, and in the rail that
     is the Y axis. The base `--on` rule widens it to 26px, which in a column reads
     as a progress bar pointing across the shopper's travel and pushes the rail's
     box 20px further into the card than the measured `right: 26px` intends. So the
     rail transposes it: 6px wide, 26px tall, and the `transition` follows.

     STATED HERE RATHER THAN BY EDITING `.pager__dot--on`, because that rule is the
     horizontal row's and the horizontal row is still the fallback. */
  width: 6px;
  transition: height 140ms ease;
}

.pager__rail .pager__dot--on {
  width: 6px;
  height: 26px;
  border-radius: 3px;
}

/* THE HORIZONTAL ROW IS THE FALLBACK. A row of dots under a vertically-paging
   list states the wrong gesture, so the rail replaces it rather than joining it.
   Hidden with `display: none` and NOT `visibility`, because the row must stop
   RESERVING its 6px + the foot's 6px gap as well as stop being drawn — the
   `[hidden]`-is-only-`display:none` trap in CLAUDE.md is the same fact read the
   other way round. */
.pager-wrap:has(.pager--v) ~ .pager-foot .pager__dots {
  display: none;
}

.pager::-webkit-scrollbar {
  display: none;
}

.pager__page {
  flex: 0 0 100%;
  /* `start`, not `center`: with a page exactly as wide as the scrollport,
   * centre-alignment leaves Chrome able to pick a snap target that desyncs from
   * scrollLeft, which strands the pager between two pages. */
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  /* `safe flex-end`, and the two words do different jobs.
   *
   * `flex-end` is defect 3: the pagination dots belong to the cards, and centring
   * split the page's spare height into equal bands ABOVE and BELOW them, so the
   * dots were pushed down by half the slack. MEASURED at 390x844 with a real
   * 20-result answer: 24px between the bottom card and the dots, of which 12 was
   * centring slack that has no reason to be there — the cards now sit against the
   * bottom of the pager and the slack collects above them, where the prose is.
   *
   * `safe` is the earlier fix and still load-bearing: at 375x664 two real cards
   * need 424px in a 246px pager, and any end-alignment would push the overflow
   * out of the opposite edge into an `overflow-y: hidden`. `safe` falls back to
   * flex-start exactly on overflow, so the TOP of the first card is what survives
   * — which is the half a shopper can act on.
   *
   * The earlier note, still true: MEASURED at 375x664 — a Safari TAB, where the
   * browser chrome takes ~110px against standalone's full height: two cards need
   * 259px in a 246px pager, and plain centring distributes the 13px overflow to
   * BOTH ends, pushing the first card's top above the page's own top edge. The
   * pager is `overflow-y: hidden`, so that end is cut.
   *
   * The cards were ALREADY losing 13px of their top edge to this at that height;
   * the `% off` pill, which overhangs the card by 11px, is only what made it
   * visible — it was clipped outright and hit-tested to nothing. `safe` falls
   * back to `flex-start` exactly when the content overflows, so the centring is
   * unchanged at 812 and nothing is pushed out of the top at 664. */
  justify-content: safe flex-end;
  /* 8px between the two cards and a 20px gutter, per frame 18a.
     THIS PADDING IS OURS, NOT THE FRAME'S. 18a has no page wrapper at all —
     its cards sit directly in a `padding: 10px 20px 0` region — so these
     numbers are an artefact of the pager's page element and are the right
     place to find the last pixels rather than bending one of 18a's own.
     TOP 1: the pager is `overflow-y: hidden` and the cards carry
     `0 3px 14px` shadows, but that shadow is offset DOWNWARD, so almost
     nothing of it sits above the top card and clipping there costs
     nothing visible. BOTTOM 0: with the content overflowing, bottom
     padding sits past the clip and buys no room at all — it was measured
     at 4 and moved nothing, which is why it is 0 and not a nicer number. */
  gap: 8px;
  padding: 1px 20px 0;
  min-width: 0;
}

/* A LONE RESULT IS CENTRED (AY1).
 *
 * DECLARED AFTER `.pager__page`, and that is load-bearing rather than tidy: both
 * selectors are specificity (0,1,0), so source order is the ONLY thing that
 * decides which `justify-content` applies. Moved above, this rule silently loses
 * and the card stays at the bottom — the same class of failure as the recipe
 * screens' `padding-bottom`, which was written correctly and outranked. Verified
 * on the COMPUTED value in the browser, never on this file.
 *
 * `safe` is kept for the same reason the base rule has it: a single card taller
 * than the pager must fall back to flex-start so its TOP survives the
 * `overflow-y: hidden`, rather than being centred and clipped at both ends. */
.pager__page--single {
  justify-content: safe center;
}

.pager-foot {
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* 18a spaces the dots 8 below the cards and the swipe line 6 below the dots,
     and puts NOTHING under the swipe line — the action block's own 14px top
     padding is the gap below it. The 6px that used to sit here was a second
     reservation for the same space. */
  gap: 6px;
  padding: 8px 0 0;
}

/* ══════ THE CARDS AND THE SWIPE LINE ARE CENTRED IN THEIR BAND (18a / 24c) ══════
 *
 * PRODUCT-OWNER RULING, from a real iPhone, and it overrules the `safe flex-end`
 * note above at the two browser bands. That note is right about WHERE the slack
 * must not go — between the cards and the dots, which belong together — and it
 * put all of it above the cards instead. On the device that reads as the results
 * hanging off the bottom of the screen with a hole above them.
 *
 * MEASURED before, with a real 20-result answer ("high protein snacks"), gap
 * ABOVE the first card and gap BELOW the swipe line:
 *
 *   646   9.6 above,   0 below
 *   687  50.6 above,   0 below     <- the device
 *   844 207.6 above,   0 below     <- not touched here; see below
 *
 * The 0 is the complaint and the 50.6 is why it looks like one: `.pager` is
 * `flex: 1 1 auto` and `.pager__page` is `flex-end`, so the pager GROWS into
 * every point the band has spare and then stacks all of it above the cards. That
 * is the same 41pt of unallocated band that put "Welcome to" sideways on home.
 *
 * THE MECHANISM IS TWO AUTO MARGINS, NOT A NUMBER. Free space in a flex column is
 * shared equally between auto margins before anything else gets it, so
 * `margin-top: auto` on the pager and `margin-bottom: auto` on the foot centre
 * the two AS A GROUP — no gap opens between the cards and the dots, and the value
 * follows the band with nothing to keep in sync.
 *
 * `flex: 0 1 auto` AND NOT `0 0 auto`: the pager must stop GROWING (or it eats the
 * space the margins are meant to share) but must keep SHRINKING. Two complete
 * cards and no clipping is the property that has to survive, and on a band where
 * the cards do not fit, shrinking is what hands the overflow to `safe`'s
 * flex-start fallback rather than out of the top edge.
 *
 * 844 IS NOW INCLUDED, and the 207.6 above is why. It was left out of the first
 * pass "by instruction", because every rule in that batch named a browser band —
 * and the band that was skipped is the one where the symptom is worst. This is
 * the SAME mechanism, extended to a band that was omitted, not a new rule.
 *
 * `safe flex-start` was rejected rather than overlooked. It closes the top gap to
 * ~1.5, and puts 206.1 of slack BETWEEN the cards and their own pagination dots
 * — precisely the complaint `flex-end` was chosen to prevent. The two auto
 * margins put the slack OUTSIDE the pager-plus-foot group instead, so the group
 * moves and nothing opens inside it.
 *
 * MEASURED at 393x844 and 440x894 with a real 20-result answer ("high protein
 * snacks"), before -> after. The pager's height is the tallest of its ten pages,
 * so the exact value tracks the result set; the RULE's property is that the pager
 * stops growing and the remaining slack is shared equally by the two margins.
 *
 * The per-band before/after table is in docs/HANDOFF_UP_NEXT_FRAMES.md §6.14
 * rather than here, because those values move with the catalogue and this
 * comment does not. */
/* ON `.pager-wrap`, NOT `.pager`. The wrapper is the column's flex item now, and
 * an auto margin only shares free space with the flex container it is a child of —
 * left on the pager it would be sharing the WRAPPER's free space, of which there
 * is none, and the centring would silently stop working. Verified on the computed
 * value rather than on this file. */
:root[data-band='646'] #app[data-screen='answer'] .pager-wrap,
:root[data-band='687'] #app[data-screen='answer'] .pager-wrap,
:root[data-band='844'] #app[data-screen='answer'] .pager-wrap {
  flex: 0 1 auto;
  margin-top: auto;
}

:root[data-band='646'] #app[data-screen='answer'] .pager-foot,
:root[data-band='687'] #app[data-screen='answer'] .pager-foot,
:root[data-band='844'] #app[data-screen='answer'] .pager-foot {
  margin-bottom: auto;
}


/* 14px, per 18a. SCOPED, because `.muted` is a shared utility at 15px and this is
   the one place the frame gives a number for. */
.pager-foot .muted {
  font-size: 14px;
}

.pager__dots {
  display: flex;
  gap: 8px;
  align-items: center;
  /* THE BACKSTOP. 144 each side is the widest the floating pill can be
     (MEASURED: 125.9 at "20 on your list") plus its own `right: 14px` plus 4 of
     air. A centred box inside a symmetric bound cannot cross either edge of it,
     so while this cap holds the row cannot reach the pill whatever happens to a
     dot's size or the gap. The dots carry the default `flex-shrink: 1`, so the
     cap COMPRESSES them rather than overflowing — ugly, and strictly better than
     unreachable.

     IT IS NOT DEAD BELT-AND-BRACES, AND THE NEGATIVE CONTROL PROVED IT. Defeating
     the gap rule below with the cap still in place holds the row at the cap and
     the clearance stays positive; at the first value tried (140 each side) it
     stayed positive by 0.1, which is not a margin. 144 leaves 4.1 there and still
     leaves 7 of headroom before the cap bites in normal operation — ten dots at
     the compressed gap are 98 wide against a 105 cap. */
  max-width: calc(100% - 2 * (126px + 14px + 4px));
}

/* ══════ THE DOT ROW MUST NOT GROW INTO THE FLOATING PILL (18a) ══════════════
 *
 * PRODUCT-OWNER RULING: the pill must not sit over a tap target. Every dot is
 * one — `<button aria-label="Page 10">` — and the last of ten was 100% covered.
 * MEASURED at 393x646: dots row 120.5 -> 272.5 against a pill starting at 262.8,
 * and `elementFromPoint(269, 455)` returned `list-count`, so Page 10 could not
 * be tapped at all.
 *
 * §9 OF THE HANDOFF CLAIMED THIS CLEARED "BY CONSTRUCTION" and named the swipe
 * line's x=255. The swipe line does clear. THE DOTS WERE NEVER CHECKED, because
 * frame 18a draws THREE of them — one 26pt active and two 6pt, a 3-page answer,
 * 54 wide — and at three dots there is 29.6 of room. The claim was true of the
 * frame and false of the product.
 *
 * WHY THE DOTS GIVE AND NOT THE PILL. The pill was moved here in the first place
 * because it is the only band on this screen that is free: MEASURED after the
 * centring fix, the gap above the first card is 5.6 and below the swipe line 2.6
 * against a 33.5pt pill, the cards and the two action buttons are tap targets,
 * and every row of `.answer-head` is the shopper's own question or the answer to
 * it. There is nowhere to move it to. And the alternative constraint — reserving
 * a column and centring the dots in what is left — shifts them 65pt off centre
 * at ten pages, which is a visible defect traded for an invisible one.
 *
 * THE THRESHOLD IS 7, AND IT IS MEASURED RATHER THAN CHOSEN. Against the WIDEST
 * pill (left edge 253.1) the row clears by 8.6 at six dots, 1.6 at seven and
 * -5.4 at eight — so the collision starts at EIGHT, not at the nine Julian's own
 * "3 on your list" showed. Compressing from seven keeps a real margin instead of
 * landing on the boundary.
 *
 * The count is bounded and closed: `contract.MAX_ITEMS` is 20 and `render.js`'s
 * `PER_PAGE` is 2, so there are never more than TEN pages. That is what makes
 * "can never meet" a checkable claim rather than a hope — the acceptance sweeps
 * all ten.
 *
 * `:has()` rather than a class the renderer sets: the row's own child count IS
 * the page count, so there is no second fact to keep in sync. Supported on every
 * engine this app runs on (Safari 15.4+, 2022) — verified live before shipping. */
.pager__dots:has(.pager__dot:nth-child(7)) {
  gap: 2px;
}

.pager__dot {
  width: 6px;
  height: 6px;
  border-radius: 3px;
  background: #e1e4e8;
  border: 0;
  padding: 0;
  transition: width 140ms ease;
}

.pager__dot--on {
  width: 26px;
  background: var(--accent);
}

/* Cards are more compact than Phase 1's so two fit above the actions. */
.pager .card {
  padding: 15px;
  gap: 16px;
}

.pager .card__shot {
  width: 76px;
  height: 92px;
  /* 12px here, against the shared `--r-thumb`. 18a draws this one corner
     radius and the thumb token is 14 everywhere else it is used. */
  border-radius: 12px;
}

/* ONE LINE, ELLIPSISED, ON THE ANSWER CARD (frame 18a; product-owner ruling).
 *
 * The base `.card__name` clamps to TWO and says why: this catalogue's names run
 * past 90 characters, so two lines is what keeps a name readable. That reasoning
 * is unchanged and it still governs the compare and see-all lists — this is the
 * answer card only, where the frame spends the second line on the second complete
 * card instead. MEASURED: the two-line name was 43.7px; one line is 24.2, and
 * 19.5px x 2 cards is most of what buys 18a's density back.
 *
 * `display: block` UNDOES THE BASE'S `-webkit-box`, and it has to be stated. The
 * base sets `display: -webkit-box` with `-webkit-line-clamp: 2`; leaving that in
 * place and adding `white-space: nowrap` gives a box that neither wraps nor
 * ellipsises cleanly. This selector is (0,2,0) and the base is (0,1,0), so it
 * wins — but only on the properties it actually declares. */
.pager .card__name {
  font-size: 21px;
  margin-top: 7px;
  letter-spacing: -0.4px;
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pager .card__price {
  font-size: 22px;
  letter-spacing: -0.5px;
  margin-top: 7px;
  gap: 8px;
}

.pager .card__meta {
  font-size: 14px;
  margin-top: 4px;
}

/* ══════════════════ 23a — THE FULL ANSWER, OVER THE PAGE ══════════════════
 *
 * `Read the rest` used to un-clamp the answer in place and grow the head, which
 * moved the cards. 23a layers it instead, so the page behind is untouched and the
 * ✕ returns to a screen that is identical because nothing about it changed.
 *
 * THE SCRIM STOPS BELOW THE ASK ROW BY STACKING, NOT BY GEOMETRY. `.surface` is a
 * sibling of the screens at `z-index: 20`; anything here below that is covered by
 * the scrim and the ask row is not. That is the frame's "everything behind sits
 * under one scrim, the ask row alone is lifted above it" with no second rule to
 * keep in sync — and the two action BUTTONS are in this screen's flow, so they go
 * under it, which is what the frame draws. */
.answer-full[hidden] {
  display: none;
}

.answer-full__scrim {
  position: absolute;
  inset: 0;
  z-index: 10;
  background: rgba(10, 37, 64, 0.34);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}

/* 362pt HELD AT THE TOP OF THE BAND, and the height is the ruling rather than a
 * proportion: it is what leaves the second product card and the pagination dots
 * visible under the sheet. MEASURED: the sheet ends at 376 and the dots begin at
 * 438.5, so it clears them by 62.5.
 *
 * A FIXED HEIGHT AND NOT `max-height`: the scroll area inside is `flex: 1;
 * min-height: 0`, and with an auto height a short answer would collapse the sheet
 * and a long one would grow it past the dots. The sheet is a window on the answer,
 * so it is the window that is fixed. */
.answer-full__sheet {
  position: absolute;
  left: 14px;
  right: 14px;
  top: 14px;
  height: 362px;
  z-index: 11;
  box-sizing: border-box;
  padding: 18px 20px 16px;
  display: flex;
  flex-direction: column;
  background: var(--paper);
  border-radius: 26px;
  box-shadow: 0 18px 44px rgba(10, 37, 64, 0.26);
}

.answer-full__head {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  flex: none;
}

.answer-full__echo {
  flex: 1;
  min-width: 0;
  margin: 0;
  font: 500 15px/1.35 var(--font);
  color: var(--ink-faint);
}

.answer-full__close {
  width: 34px;
  height: 34px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 50%;
  /* `--chip-bg`, NOT THE FRAME'S #F4F4F6. That literal is a near-miss of
     `--skeleton-a` and there is a measured token for this ground already — and
     more to the point, the ✕ on the answer head three lines behind this sheet is
     `.iconbtn`, which uses `--chip-bg`. Two ✕ controls on one screen in two
     slightly different greys is the parallel palette this rule exists to stop. */
  background: var(--chip-bg);
  font: 600 18px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
}

/* THE ANSWER SCROLLS HERE, not the sheet and not the page. `min-height: 0` is what
 * makes `flex: 1` actually shrink inside a column flex parent — without it the
 * text sets the height, the sheet's own 362 is ignored and the overflow moves to
 * the page, which is the whole thing this modal exists to prevent.
 *
 * The mask fades the last 8% so a cut line reads as "there is more" rather than as
 * a rendering fault. It is decoration over a real scroller, never a substitute for
 * one. */
.answer-full__scroll {
  position: relative;
  flex: 1;
  min-height: 0;
  margin-top: 10px;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-mask-image: linear-gradient(180deg, #000 92%, transparent);
  mask-image: linear-gradient(180deg, #000 92%, transparent);
}

.answer-full__text {
  margin: 0;
  padding-right: 16px;
  font: 600 19px/30px var(--font);
  color: var(--ink);
  text-wrap: pretty;
}

/* THE ACTION BLOCK AND THE ASK ROW LOSE THEIR GRADIENTS WHILE THIS IS OPEN, and
 * the frame is explicit about why: with them the band reads as two stacked panels
 * under the dim instead of one dimmed surface. `.surface` sits above the scrim, so
 * its gradient would be the one thing on screen that the dim does not touch. */
.answer-full-open .surface {
  background: none;
}

/* THE LIST PILL GOES UNDER THE DIM, and it is the one thing on this screen that
 * does not fall out of the existing stacking. It floats at `z-index: 21` — above
 * the surface, so it clears the bar it sits proud of — which also puts it above
 * this scrim, and it rendered fully lit over a dimmed band.
 *
 * 23a lifts THE ASK ROW and nothing else: the ask row is lifted because a spoken
 * or typed follow-up must stay available with the answer open. The pill is a
 * destination, not a way to answer, and the frame draws every other control under
 * the dim. 9 is below the scrim's 10. */
.answer-full-open .list-count {
  z-index: 9;
}

/* ══════════════ 24c — THE LOCATED VARIANT: THE CARDS PAY FOR THE BLOCK ═══════
 *
 * 24c is 18a with a location block above the cards, and the frame trims the cards
 * to pay for it rather than letting them clip. MEASURED before this: the located
 * screen carried 18a's full-size cards and the second one was cut by 18.8px.
 *
 * ONE CLASS ON THE SCREEN, not a second screen and not a JS branch: same regions,
 * same order, different metrics. Every value below is 24c's.
 *
 * THE PACKSHOT DROPS 76x92 -> 68x70 AND THAT IS THE SMALLEST PART OF IT. The
 * card's height is set by its TEXT column, which is taller than either packshot —
 * so the name coming down to 19px and the three margins tightening is what
 * actually buys the room; the shot follows so the card does not look re-cropped. */
.answer--located .pager .card {
  padding: 12px;
  gap: 14px;
}

.answer--located .pager .card__shot {
  width: 68px;
  height: 70px;
}

.answer--located .pager .card__name {
  font-size: 19px;
  margin-top: 4px;
}

.answer--located .pager .card__meta {
  margin-top: 3px;
}

.answer--located .pager__page {
  gap: 7px;
}

/* 6, against 18a's 8: the located screen has one more block to fit and the frame
   takes the difference here rather than off a card. */
.answer--located .pager-foot {
  padding-top: 6px;
}

/* ---------------------------------------------------------- answer actions */

/* NO BOTTOM PADDING. The surface below is already reserved for by the body, with
   16px of clearance above it — this 10px was paying for that gap a second time,
   and 18a puts 14 between its button row and its ask field, which the existing
   clearance already provides. The 10px goes to the pager, where 18a's second
   complete card lives. */
.answer-actions {
  flex: none;
  display: flex;
  gap: 10px;
  padding: 0 20px;
}

/* THE TWO ANSWER CONTROLS ARE FILLED BLACK (design 2b, item 1), not the white
 * `.btn--ghost` they inherit from. Scoped `:not(.btn--accent)` so the accent
 * state stays accent — the add control has an orange state, and a blanket rule
 * here would paint over it and leave the two buttons indistinguishable.
 *
 * (0,2,0) against `.btn--ghost`'s (0,1,0), so this wins on specificity rather
 * than on source order. */
.answer-actions .btn {
  flex: 1;
  /* THE SAME NUMBER THE FLOATING PILL CLEARS — see `--answer-actions-h` on `#app`.
     A second literal 56 here is how the pill ends up sitting on the button the day
     this height changes. */
  height: var(--answer-actions-h);
  border-radius: var(--r-control);
  font: 700 16px var(--font);
}

.answer-actions .btn:not(.btn--accent) {
  background: var(--ink-black);
  border: 0;
  color: #fff;
}

/* THE ACTION ROW'S HEIGHT, DECLARED ON `#app` BECAUSE TWO UNRELATED SUBTREES READ
 * IT. `.answer-actions .btn` sets the buttons; `.list-count` clears them. Custom
 * properties inherit DOWN, and the pill is a SIBLING of the screens — declared on
 * the row itself this would be invisible to the pill, and the pill would silently
 * fall back to its literal and sit on the buttons the day the row changes height.
 * That is the failure this variable exists to make unwriteable. */
#app {
  --answer-actions-h: 56px;
}

/* -------------------------------------------------------------- list count */

/* Item 6: tappable. `pointer-events: none` was the mechanism that made it inert
 * and is what had to go — the class also needs the button reset, since this is a
 * `<button>` now rather than a `<div>`. Design 2l draws it at `--ink` with white
 * 700 14px, which is what it already was. */
/* IT CHOOSES A CLEAR BAND; IT DOES NOT RESERVE ONE (product-owner ruling).
 *
 * The pill floats — `data-floating` keeps it out of `--bottom-h` entirely — but
 * floating over a CONTROL is worse than the reservation it replaced. MEASURED at
 * the fixed offset: on the answer screen it covered 110 x 23.5px of "Compare",
 * 26.9% of the target, at all three bands.
 *
 * DEFAULT: the gap above the ask bar, which is free on home and on every screen
 * whose content ends above the surface. `- 2px` laps the bar's own top edge, which
 * is a gradient fade rather than a control. */
.list-count {
  position: absolute;
  right: 14px;
  bottom: calc(var(--surface-h) - 2px);
  padding: 8px 16px;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--ink);
  color: #fff;
  font: 700 14px var(--font);
  font-family: var(--font);
  z-index: 21;
  cursor: pointer;
  /* THE EDGE HAS TO SURVIVE TWO GROUNDS, and the old `--shadow-tile` survived
     neither reliably: at `rgba(0,0,0,.04)` it is invisible on a near-black button,
     where the pill's own `--ink` is 1.27:1 against `--ink-black` — the shape
     vanished and it read as white text floating on the button.

     A `--paper` RING plus a real drop shadow. On a white card the ring merges with
     the card and the visible boundary is the pill's navy against white; on the
     dark button the ring is the boundary. One treatment, both grounds, and it is
     the same ring `.deal-badge--bogo` already uses for the same reason. */
  box-shadow:
    0 0 0 2px var(--paper),
    0 4px 14px rgba(10, 37, 64, 0.3);
}

/* ABOVE THE ACTION ROW WHERE THE GAP IS NOT FREE. The answer screen — plain and
 * located both — puts "Add to list" and "Compare" in exactly the band the pill
 * defaults to. `--answer-lines` was removed as dead code; this is the same rule
 * applied to the pill instead of to the content: the furniture moves, the content
 * does not pay.
 *
 * RIGHT-ALIGNED IN THE PAGER-FOOT BAND, and the 2px is measured rather than
 * chosen. MEASURED at 646: the foot runs 448.5 -> 484 (35.5px) and the pill is
 * 33.5 tall, so it fits that band with 2px to spare. At 4px above the action row
 * its top landed at 446.5 and lapped the second card's bottom edge by 2px — 0.5%
 * of a card, but a card IS a tap target and the dots and swipe line are not. At
 * 2px the top sits exactly on the foot's own top and overlaps no control at all.
 *
 * ── TWO CLAIMS THAT STOOD HERE WERE WRONG. CORRECTED, NOT DELETED. ──────────
 *
 * "the dots and swipe line are not [tap targets]" — THE DOTS ARE. Each one is
 * `<button aria-label="Page N">` and tapping it pages the answer. Only the swipe
 * line is inert.
 *
 * "Horizontally it clears by construction: the dots and the swipe line are
 * centred and narrow (the line ends near x=255), the pill is hard right from
 * x=263." The SWIPE LINE clears — 255.2 against 262.8. THE DOTS DO NOT, and the
 * claim was made on an answer with few pages. The row is centred and its width
 * GROWS with the page count: 26 + 14(n-1), so 54 at frame 18a's three dots and
 * 152 at ten. MEASURED at 393x646 on a real 20-result answer: the row ran
 * 120.5 -> 272.5, the last dot was 100% covered, and `elementFromPoint(269, 455)`
 * returned `list-count` — Page 10 could not be tapped.
 *
 * "by construction" was the tell: nothing was constructing it. The clearance was
 * an accident of the page count that happened to be on screen.
 *
 * FIXED ON THE DOT ROW, not here — `.pager__dots` now reserves a symmetric
 * column for the pill and compresses its gap past six dots, so the two can never
 * meet at any count in the closed 1..10 range. The pill's own position is
 * unchanged, because there is nowhere on this screen to move it to. */
#app[data-screen='answer'] .list-count {
  bottom: calc(var(--bottom-h, var(--surface-h)) + 8px + var(--answer-actions-h, 56px) + 2px);
}

/* HOME NEEDS THE SAME TREATMENT NOW, AND 13b IS WHY (product-owner ruling).
 *
 * The default band was free on home when the card stack ended at 492.2. 13b's
 * taller cards push it to 538 at 646, the pill's default band is 516.5 -> 550,
 * and the two now overlap: MEASURED, the pill covers 11% of "Past purchases",
 * which is a tap target and the last destination on the screen. Frame 13b did not
 * break the pill; it spent the gap the pill's default was quietly assuming.
 *
 * SO THE PILL CLEARS THE CARD STACK, exactly as it clears the action row on the
 * answer screen — same shape, same 8 + 2, and the stack's height read from a
 * published value rather than guessed at.
 *
 * THE TWO BROWSER BANDS ONLY. MEASURED after 13b: the gap between the last card
 * and the surface is 10 at 646 and 10 at 687, and 89 at 844. The default is still
 * free at 844 and moving it there would be a change to the standalone band for no
 * reason — the same rule every 13b declaration follows. */
:root[data-band='646'] #app[data-screen='home'] .list-count,
:root[data-band='687'] #app[data-screen='home'] .list-count {
  bottom: calc(var(--bottom-h, var(--surface-h)) + 8px + var(--home-cards-h, 263px) + 2px);
}

.list-count[hidden] {
  display: none;
}

/* -------------------------------------------------------------------- deals */

.deals-head {
  flex: none;
  padding: 22px var(--pad-screen) 0;
}

.deals-title {
  font: 800 30px var(--font);
  letter-spacing: -0.8px;
  margin: 0;
  color: var(--ink);
}

.deals-tabs {
  display: flex;
  gap: 8px;
  margin-top: 14px;
  overflow-x: auto;
  scrollbar-width: none;
}

.deals-tabs::-webkit-scrollbar {
  display: none;
}

/* Frame 3c's padding and weight, with the 44px tap floor KEPT. The frame's own
 * 12px/18px at 15px already clears 44, so nothing is given up by asserting it — and
 * the floor is the thing that stops a later type change from quietly shrinking the
 * target below the platform minimum. `--r-pill` rather than the frame's literal
 * 24px: both are fully round at this height. */
.deals-tab {
  flex: none;
  min-height: 44px;
  padding: 12px 18px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink);
  font: 600 15px var(--font);
  cursor: pointer;
  white-space: nowrap;
}

.deals-tab[aria-selected='true'] {
  background: var(--ink);
  border-color: var(--ink);
  color: #fff;
}

/* THE TOP PADDING IS THE BADGE'S, not rhythm. The `% off` and `Buy 1 Get 1 Free`
 * badges are absolutely positioned above their card's top edge (-11px and -16px in
 * frames 3c and 3n), so without headroom the first card's badge is clipped by the
 * scroll container — and clipped is what it looks like, not missing, which reads as
 * a rendering bug rather than a layout one.
 *
 * 22px covers the deeper of the two hangs plus its 3px white ring. The CLEARANCE is
 * what is asserted in the browser — the first badge's rendered top against the
 * list's own content top — never this number. */
.deals-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: 22px 22px calc(var(--bottom-h, var(--surface-h)) + 16px);
}

/* ------------------------------------------------- the deals card (3c / 3n) */

.deal-card {
  position: relative;
  /* The badge hangs outside the box on purpose, so this must not clip. */
  overflow: visible;
  flex: none;
  width: 100%;
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--paper);
  box-shadow: var(--shadow-card);
  padding: var(--pad-card);
  display: flex;
  align-items: center;
  gap: 16px;
  text-align: left;
  font-family: var(--font);
  cursor: pointer;
}

.deal-card__shot {
  width: 96px;
  height: 96px;
  flex: none;
  border-radius: 16px;
  object-fit: contain;
  background: var(--paper);
  /* No frame — see `.card__shot`. */
}

.deal-card__shot--empty {
  background: repeating-linear-gradient(
    45deg,
    var(--skeleton-a) 0 7px,
    var(--skeleton-b) 7px 14px
  );
}

.deal-card__main {
  flex: 1;
  min-width: 0;
}

/* THE GUTTER UNDER THE BADGE, and it is per-badge rather than global. Frame 3n's
 * `Buy 1 Get 1 Free` hangs above the card's top-RIGHT corner, clearing the brand
 * line entirely, so it reserves 4px and pads the CARD instead.
 *
 * THE 76px `off` GUTTER IS GONE WITH THE OVERHANG. It reserved room on the brand
 * line's right end because "50% off" used to sit over exactly that spot. The
 * browser frames moved that badge INSIDE the card's top-left corner, where it
 * overlaps the packshot column and nothing else — so the reservation now only
 * truncates brands to protect a strip nothing is painted on. */

.deal-card__main[data-badge='bogo'] {
  padding-top: 8px;
}

.deal-card__brand {
  font: 700 12.5px var(--font);
  letter-spacing: 0.6px;
  color: var(--ink-faint);
  text-transform: uppercase;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ONE LINE, ellipsised — the shared `.card__name` clamps to two, because an answer
 * card is the shopper reading a name they asked for. A deals card is a row in a
 * merchandising list scanned at speed, and the frames set every one on a single
 * line. The product screen still shows the name in full. */
.deal-card__name {
  font: 700 21px/1.2 var(--font);
  color: var(--ink);
  margin-top: 5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.deal-card__main[data-badge='bogo'] .deal-card__name {
  font-size: 19px;
}

.deal-card__meta {
  font: 500 14.5px var(--font);
  color: var(--ink-muted);
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.deal-card__price {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 8px;
  white-space: nowrap;
}

.deal-card__now {
  font: 800 26px var(--font);
  color: var(--ink);
  letter-spacing: -0.5px;
}

/* THE STRUCK PRICE IS THE ACCENT HERE, not `--ink-faint` as on the shared card.
 * That is the frames' choice and it is a deliberate one: on an answer card the old
 * price is a footnote, and on a deals card the pair IS the offer, so the struck
 * number has to be legible rather than receding. */
.deal-card__was {
  font: 600 14.5px var(--font);
  color: var(--accent);
  text-decoration: line-through;
}

/* `--good` and not the frame's #1DA05B: both are the same green statement and this
 * palette already has one. A near-miss adopted is how a parallel token set starts. */
.deal-card__save {
  font: 700 13.5px var(--font);
  color: var(--good);
  margin-left: auto;
}

/* THE ANCHORING LIVES ON THE MODIFIERS, NOT HERE, because the two badges no
 * longer hang in the same place: `--off` sits INSIDE the card's top-left corner
 * and `--bogo` still overhangs the top-right. A shared `right`/`top` here would
 * be a value only one of them wants, silently inherited by the other. */
.deal-badge {
  position: absolute;
  z-index: 2;
  padding: 8px 14px;
  border-radius: 20px;
  letter-spacing: -0.2px;
  white-space: nowrap;
}

/* INSIDE THE CARD, TOP-LEFT (product-owner instruction, and the browser frames
 * draw it). It used to hang off the top-RIGHT corner, overlapping the card edge
 * and the brand line under it; that is what the 76px brand gutter and the
 * `overflow: visible` escapes on `.card`, `.suggestion` and `.walking-cards
 * .suggestion` were all paying for. Only `--bogo` still needs them.
 *
 * THE SEPARATING RING IS GONE WITH THE OVERHANG, and that is a consequence
 * rather than a second decision: the 3px `--paper` ring existed to keep the
 * badge from reading as one shape with the card border it overlapped. Nested in
 * the corner it overlaps no border, and the ring would instead cut a white notch
 * out of the card it sits in.
 *
 * The corner radii are the frame's and they are not arbitrary: 22px top-left
 * matches the card's own corner so the badge nests into it, 0 on the two inner
 * corners, 18px on the free bottom-right. */
.deal-badge--off {
  top: 0;
  left: 0;
  background: var(--accent);
  color: #fff;
  font: 800 12.5px var(--font);
  padding: 4px 13px;
  border-radius: 22px 0 18px 0;
}

/* INK ON THE YELLOW, NOT THE FRAME'S WHITE. MEASURED in the browser: #fff on
 * #FFCF0C is 1.48:1, which fails WCAG AA outright and is unreadable in the retail
 * lighting §14 is written for; `--ink` on the same yellow is 10.49:1. The frame's
 * colour is kept because the colour is the brand decision; the text colour is a
 * legibility one. */
.deal-badge--bogo {
  top: -16px;
  /* EXPLICIT NOW THAT `--off` HAS MOVED. This used to come from the shared rule;
   * it is stated here because BOGO is the only badge left that overhangs, and the
   * `overflow: visible` escapes elsewhere in this file exist for this rule alone. */
  right: -8px;
  background: var(--promo-bogo);
  color: var(--ink);
  font: 800 15px var(--font);
  /* Drop shadow removed, separating ring kept — see `.deal-badge--off`. */
  box-shadow: 0 0 0 3px var(--paper);
}

/* ------------------------------ add to Home Screen (BD, frames 2o and 2q) */

/* A FULL-SCREEN OVERLAY OVER THE WELCOME SCREEN, and it owes the notch the same
 * inset every screen does — the defect `.sheet` already paid for once: two
 * `position: absolute; inset: 0` overlays over one viewport, one of which ignored
 * the safe area, put 25px of its own head behind the status bar in standalone and
 * nowhere else.
 *
 * z-index above the welcome content and below the drawer (70): this is the first
 * thing on screen and nothing should paint over it, but the drawer is not reachable
 * from here anyway. */
.a2hs {
  position: absolute;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: flex-end;
  background: rgba(10, 37, 64, 0.42);
  padding: calc(20px + env(safe-area-inset-top, 0)) 16px
    calc(16px + env(safe-area-inset-bottom, 0));
}

/* `[hidden]` IS ONLY A UA `display: none` AND THIS RULE'S OWN `display: flex`
 * OUTRANKS IT — the exact failure CLAUDE.md records twice. So the attribute is
 * honoured explicitly. */
.a2hs[hidden] {
  display: none;
}

.a2hs__card {
  width: 100%;
  max-height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--paper);
  border-radius: 26px;
  padding: 24px 22px 18px;
  box-shadow: 0 18px 50px rgba(8, 20, 32, 0.28);
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.a2hs__title {
  margin: 0;
  font: 800 25px/1.15 var(--font);
  letter-spacing: -0.7px;
  color: var(--ink);
}

.a2hs__body {
  margin: 0;
  font: 500 15.5px/1.4 var(--font);
  color: var(--ink-muted);
}

/* NUMBERED BY THE LIST, not by a hand-written glyph: the count differs between the
 * two flows (three steps against four) and a counter cannot disagree with the
 * number of items the way a literal can. */
.a2hs__steps {
  margin: 4px 0 6px;
  padding: 0;
  list-style: none;
  counter-reset: a2hs;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* FRAME `2o`: each step is a CARD — 1px #EDEDEF, radius 18px, padding 14px 16px,
 * shadow 0 2px 10px rgba(0,0,0,.04), row, gap 14px. It was a bare grid row, which is
 * why the numbers and text floated with nothing to attach the icon to. */
.a2hs__step {
  counter-increment: a2hs;
  display: flex;
  align-items: center;
  gap: 14px;
  border: 1px solid var(--line);
  border-radius: 18px;
  padding: 14px 16px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
}

/* FRAME: 26px orange disc, 800 14px white. (`2q` uses 24px/13px in its tighter card;
 * one size for both — see `a2hsStepIcon`.) */
.a2hs__step::before {
  content: counter(a2hs);
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  font: 800 14px/26px var(--font);
  text-align: center;
}

/* FRAME: `flex:1` — the text column, between the number and the glyph. */
.a2hs__step-body { flex: 1; min-width: 0; }

/* The frame's second line, and only where the frame has one. */
.a2hs__step-hint {
  display: block;
  margin-top: 2px;
  font: 500 13.5px/1.3 var(--font);
  color: var(--ink-faint);
}

/* THE ARROW (BJ), pointing at browser chrome the page cannot measure.
 *
 * `width: 390px` AND `right: 0` TOGETHER ARE THE ANCHOR. At its natural width, with
 * its right edge pinned, the arrowhead's x of 349 sits a FIXED 41px from the right
 * of the viewport at ANY width — 375 and 430 alike. Browser controls sit at a fixed
 * inset from the right edge, so a fixed inset follows them and a `width: 100%` would
 * drift as the viewport grows. `flex: none` because the overlay is a flex container
 * and the arrow must not be stretched by it.
 *
 * `overflow: visible` matters: the arrowhead's stroke extends past the viewBox and
 * would be clipped at the tip, which is the one part of the drawing that has to be
 * crisp. */
.a2hs__arrow {
  position: absolute;
  right: 0;
  width: 390px;
  flex: none;
  overflow: visible;
  pointer-events: none;
}

/* NON-SAFARI: the share icon is in the top-right of the address bar, ABOVE the
 * viewport. The tip is at y=2 in a 200-tall box pinned to the top, so it lands 2px
 * from the top edge — as close to something outside the page as the page can get. */
.a2hs__arrow--other {
  top: 0;
  height: 200px;
}

/* SAFARI: the three dots are at the bottom-right, BELOW the viewport. The tip is at
 * y=144 of 150, so pinning the box to the bottom puts it 6px from the bottom edge. */
.a2hs__arrow--safari {
  bottom: 0;
  height: 150px;
}

/* ROOM FOR THE SAFARI ARROW TO RUN PAST THE CARD.
 *
 * The overlay is `align-items: flex-end`, so the card sits at the bottom — which is
 * exactly where Safari's arrow has to travel. Without this the arrow would be drawn
 * OVER the card, which is worse than no arrow. The non-Safari variant needs nothing:
 * its arrow goes up, into scrim that is already empty.
 *
 * 96px is frame 2q's own `bottom: 96px` on the arrow's container, reused as the
 * clearance rather than as a coordinate. */
.a2hs[data-flow='safari'] {
  padding-bottom: calc(96px + env(safe-area-inset-bottom, 0));
}

/* Both controls are the same size, for the same border-box reason the read-back's
 * row needed: a `flex-basis` is not involved here, but unequal padding would still
 * make two stacked buttons read as different weights of the same choice. */
.a2hs .btn {
  width: 100%;
  height: 54px;
  min-height: 54px;
  border-radius: 16px;
  font: 700 16px var(--font);
  padding: 0 12px;
}

/* -------------------------------------------------------------- the sheet */

/* A FULL-SCREEN OVERLAY, SO IT OWES THE NOTCH THE SAME 22px EVERY SCREEN DOES.
 *
 * `.screen` carries `padding-top: env(safe-area-inset-top, 0)` and this did not,
 * while both are `position: absolute; inset: 0` over the same viewport. MEASURED
 * by simulating a 47px inset: every screen's content starts at 47 and the sheet's
 * head started at 22 — 25px of the Compare title behind the status bar, 37px on a
 * 59px-inset device. In a browser tab the inset is 0, so the two agree and the
 * defect is invisible; it only exists in standalone, which is where it was
 * reported from. */
.sheet {
  position: absolute;
  inset: 0;
  background: var(--paper);
  z-index: 45;
  display: none;
  flex-direction: column;
  padding: calc(22px + env(safe-area-inset-top, 0)) 20px 0;
}

.sheet[data-open='true'] {
  display: flex;
}

.sheet__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  flex: none;
}

.sheet__title {
  font: 800 26px var(--font);
  letter-spacing: -0.6px;
  color: var(--ink);
}

.sheet__sub {
  font: 500 14px/1.4 var(--font);
  color: var(--ink-faint);
  margin-top: 4px;
}

.sheet__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px 0 30px;
}

/* ------------------------------- ongoing lists in the capture sheet (C) */

.capture-pick__title {
  font: 700 13px var(--font);
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--ink-faint);
  padding: 14px 2px 2px;
}

/* Visually secondary to the three capture routes above it: starting a list is the
 * sheet's purpose, resuming one is the escape hatch. */
.capture-pick {
  background: var(--paper-warm);
}

/* ------------------------------------------------- the store mark (item 7) */

/* The height is RESERVED from the intrinsic aspect ratio (1003x363 = 2.76), so the
 * block occupies its final size before the PNG decodes. Without it the headline below
 * — which has its own carefully measured reservation — is pushed down on load, which
 * is the one thing that reservation exists to prevent. */
.welcome-brand {
  padding: 52px 30px 0;
  display: flex;
  justify-content: center;
}

/* THE COMPACT MARK: a quiet "Welcome to" over a 38px logo, per design `2f`.
 *
 * Named for what it IS and not for where it is used. It was `--home` while the home
 * screen was the only caller; the welcome screen now uses it too, and a modifier
 * named after one of its two callers is a name that will be read as a constraint.
 *
 * On home it is a budget decision — four tiles have to fit the viewport and the
 * difference between a 190px mark and a 105px one is most of what paid for the
 * fourth tile. On the welcome screen it is a consistency one: that screen is the
 * first thing anyone sees and home is the second, and the mark changing size across
 * that single tap is the sort of inconsistency a tester notices and cannot name. */
.welcome-brand--compact {
  padding: 22px 30px 0;
  flex-direction: column;
  align-items: center;
  gap: 3px;
}

/* ── THE TWO CALLERS SEPARATE AGAIN (browser frames 16a and 13b) ─────────────
 *
 * The note above argues for one mark at one size across the welcome -> home tap,
 * and that argument was right for the frames it was written against. The browser
 * frames overrule it with different numbers per screen: 16a reserves a 72pt block
 * with a 32pt mark and the kicker over it; 13b compresses home to a 46pt ROW with
 * a 30pt mark and no kicker at all, because home is now spending every point it
 * can find on the four cards.
 *
 * So `--compact` keeps only what the two still share — a centred column — and each
 * screen states its own geometry. The alternative was a second modifier, which is
 * the same divergence with one more name to keep straight.
 *
 * THE MARK ITSELF STAYS ONE SIZE, and that part of the old note survives intact.
 * The frames draw it at 32 on welcome and 30 on home; the product owner ruled the
 * two back together at 30, because holding it constant across the welcome -> home
 * tap was a deliberate decision and 2px buys nothing against it. So the mark is
 * pinned once, below, and only the BLOCK around it differs per screen.
 *
 * The orbs do NOT come back together, and that is the same ruling: 13b's whole
 * design is built on spending orb space on the cards, so equalising those would
 * undo the frame rather than tidy it. */
[data-screen='welcome'] .welcome-brand--compact {
  /* 72pt reserved, so the headline below never moves when the logo decodes —
     the same reservation the block already had, at the frame's number. */
  height: 72px;
  box-sizing: border-box;
  padding: 14px 30px 0;
  gap: 4px;
}

[data-screen='welcome'] .welcome-brand__kicker {
  font: 600 14px var(--font);
  letter-spacing: 0.3px;
}


/* Small, light, and above the mark — a greeting, not a heading. It must not compete
 * with the typing headline two rows below, which is the thing on this screen that is
 * meant to be read. */
.welcome-brand__kicker {
  font: 600 12px var(--font);
  letter-spacing: 0.4px;
  color: var(--ink-faint);
}

.welcome-brand__logo {
  width: 190px;
  height: auto;
  /* Belt and braces with the `width`/`height` attributes: either alone reserves the
   * box in a modern browser, and together they also survive a stylesheet that has
   * not loaded yet. */
  aspect-ratio: 1003 / 363;
}

/* ONE MARK, ONE SIZE, ON BOTH SCREENS (product-owner ruling). 82.9px wide is 30px
 * tall at the mark's 1003/363 aspect — 13b's height, expressed as the width the
 * aspect ratio is driven from so the reservation stays exact.
 *
 * 16a draws welcome's at 32 and 13b draws home's at 30. Holding it constant across
 * the welcome -> home tap was a deliberate decision and survives the redesign: 2px
 * is not visible and is not worth two values. 30 rather than 32 because home is the
 * screen with no room to give, and the welcome block is a 72pt reservation that
 * does not care either way. */
.welcome-brand--compact .welcome-brand__logo {
  width: 82.9px;
}

/* ═══════════════════════ THE HOME SCREEN, PER MEASURED BAND ═══════════════════
 *
 * THE PROBLEM, MEASURED at 393x646 with the real four cards: the home body is
 * 797px of content in a 646px viewport — a 151px overflow, with "Past purchases"
 * 110px BELOW the top edge of the persistent surface and completely off screen.
 * The screen that every trip starts on could not show the destinations it exists
 * to offer. The frames are an 844 design and this is the 646 band: iOS Chrome,
 * where the status bar, the URL bar and the bottom toolbar take 206pt.
 *
 * IT WAS ONE KNOB PER BAND (`--home-fit`, 0.71 at 646 and 0.78 at 687), AND THE
 * REDESIGN BATCH RULED ON IT. That factor multiplied every compressible piece,
 * which kept the design's PROPORTIONS at each band and gave up its absolute
 * sizes — the note that stood here said so and asked the batch that lands after
 * it to decide. Frame 13b decides: app-scale type and touch heights at both
 * browser bands, with the ORB paying for them. The variable is gone rather than
 * left at 1, and what replaces it is the block below.
 *
 * What survives from that pass unchanged, because 13b does not touch it: the
 * accent card is `overflow: hidden` for its own rings, so a title that does not
 * fit its box is clipped SILENTLY — nothing scrolls, warns or overflows where a
 * scrollHeight check could see it. 13b's 94pt box around 17pt of padding leaves
 * 60 for a 17pt kicker and a 37pt title, which fits with 6 to spare, and that
 * clearance is the thing to re-measure if either number ever moves.
 *
 * ══════════════ WHAT 13b SPECIFIES, AT BOTH BROWSER BANDS ══════════════
 *
 *   store row   46      the mark alone; no kicker at 646 (band-conditional COPY)
 *   headline    66      800 25/1.3, letter-spacing -0.8, padding 0 34
 *   orb         137     painted; margin-top 26; 105 across, a 150 orb at 0.7
 *   cards       7 padding-top, 12 gap, heights 94 / 78 / 60
 *
 * 13b's own caption: "Orb at 105pt across. The 59pt it releases goes back into
 * the cards, which keep app-scale type and touch heights and gain real gaps."
 *
 * BOTH BROWSER BANDS AND NOT JUST 646. 687 has 41pt more and nothing in the
 * frames asks for a third geometry — the same reasoning 21g established for the
 * welcome screen, where the fixed blocks are band-independent and the slack
 * absorbs the difference. 844 keeps the base rules above; every rule below names
 * a browser band, and a test asserts that none of them reaches 844.
 */

/* THE STORE ROW IS TWO GEOMETRIES, NOT ONE, AND CONFLATING THEM IS WHAT SHIPPED
 * "Welcome to" SIDEWAYS ONTO A REAL PHONE.
 *
 * The two rules below were one rule naming both browser bands, beside a kicker
 * `display: none` naming only 646. 13b's 46pt ROW is a *kicker-absent* geometry —
 * it is `flex-direction: row` precisely because the mark is the only thing in it.
 * Applied at 687, where the kicker is deliberately kept, it laid the greeting out
 * BESIDE the logo instead of above it.
 *
 * MEASURED on the device (Julian, iPhone/Chrome) and reproduced at 393x687: the
 * kicker's box sits at left 118 width 74.1 and the logo's left edge is 192.1 —
 * they abut, on one line. At 646 the same markup measures `display: none`, which
 * is why every review-browser reading of this screen was clean: the defect only
 * exists at the band nobody forced.
 *
 * THE RULING (product owner): absent at 646, PRESENT at 687 and 844, and where it
 * is present it STACKS above the mark as 16a draws it. So the row geometry is
 * band-scoped alongside the copy rather than shared with it — the two facts move
 * together or this recurs. */
:root[data-band='646'] [data-screen='home'] .welcome-brand--compact {
  height: 46px;
  box-sizing: border-box;
  padding: 0 22px;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 0;
}

/* 13b drops "Welcome to" from home to buy the 46pt row, and that is the CHROME
 * band only: Safari has 41pt more and the installed app has 198, and on both the
 * greeting stays. The markup and `COPY.welcomeKicker` are untouched — deleting
 * either would take the line off every band at once, which is why the acceptance
 * asserts the kicker is RENDERED at 687 and 844 rather than measuring around it. */
:root[data-band='646'] [data-screen='home'] .welcome-brand__kicker {
  display: none;
}

/* 687 — THE KICKER STAYS, SO THE BLOCK STACKS. 15 for the kicker, 3 of gap and a
 * 30pt mark is 48 of content; 64 centres it with 8 either side. The 18pt this
 * costs over 646's row is the first claim on the 41pt that band has spare, and
 * the orb below takes the rest. */
:root[data-band='687'] [data-screen='home'] .welcome-brand--compact {
  height: 64px;
  box-sizing: border-box;
  padding: 0 22px;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
}

:root[data-band='687'] [data-screen='home'] .display,
:root[data-band='646'] [data-screen='home'] .display {
  font: 800 25px/1.3 var(--font);
  letter-spacing: -0.8px;
}

/* THE HEADLINE'S BLOCK. 66pt, and the reservation `headline.reserveHeight` writes
 * lives on the node inside it — a smaller font can only ever wrap to FEWER lines,
 * so shrinking here cannot cross a line cliff upward. */
:root[data-band='687'] [data-screen='home'] .home-head,
:root[data-band='646'] [data-screen='home'] .home-head {
  height: 66px;
  box-sizing: border-box;
  padding: 0 34px;
  margin-top: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* THE ORB IS THE ELASTIC PIECE, SCALED AS ONE TRANSFORM. `.home-speak` is a
 * 230x196 box holding a 150px orb and its arc; every one of those numbers is
 * geometry, so a single `scale()` moves them together and cannot drift. The
 * declared height is what the transform paints — a transform does not change
 * layout size, so without it the box would still occupy 196 and the scale would
 * buy nothing.
 *
 * 0.7 IS 13b's OWN NUMBER AND IT BELONGS TO THE 646 BAND. 13b draws a 646pt
 * content area; 105 across is what fits there, and the frame's caption says so in
 * as many words — "the 59pt it releases goes back into the cards".
 *
 * SO 687 IS NOT 646 WITH ROOM TO SPARE, AND SHIPPING IT THAT WAY IS WHAT JULIAN
 * SAW. MEASURED at 393x687 before this change: the stack ended at 538 against a
 * content box ending at 581 — 43pt of dead air below "Past purchases" and above
 * the ask bar, with the orb still at its 646 size in a viewport 41pt taller. Read
 * on the device as three complaints (small orb, high cards, a gap over the bar);
 * it is one unallocated 41pt.
 *
 * THE ALLOCATION, and it is arithmetic rather than taste. The content box at 687
 * is 687 - 98 - 8 = 581. The fixed pieces below the mark are the 66pt headline,
 * the orb's 26pt margin and the 263pt card stack = 355. The row above takes 64
 * (the stacked kicker, above). So the orb block gets 581 - 2 - 64 - 355 = 160,
 * and 160/196 is 0.816 — a 122pt orb. The 2 is 13b's own clearance at 646,
 * held constant so both bands end the same distance off the reservation. */
:root[data-band='646'] [data-screen='home'] .home-speak {
  transform: scale(0.7);
  transform-origin: top center;
  height: 137px;
}

:root[data-band='687'] [data-screen='home'] .home-speak {
  transform: scale(0.816);
  transform-origin: top center;
  height: 160px;
}

/* 26, per 13b. THE MARGIN IS ON THE ROW AND NOT ON THE ORB — `.home-speak` is the
   transformed box and a margin there would be scaled by the same 0.7. */
:root[data-band='687'] [data-screen='home'] .home-speak-row,
:root[data-band='646'] [data-screen='home'] .home-speak-row {
  margin-top: 26px;
}

:root[data-band='687'] [data-screen='home'] .home-cards,
:root[data-band='646'] [data-screen='home'] .home-cards {
  padding-top: 7px;
  gap: 12px;
}

/* THE STACK'S OWN HEIGHT, PUBLISHED WHERE THE FLOATING PILL CAN READ IT.
 *
 * 7 + 94 + 12 + 78 + 12 + 60 = 263, which is the four rules above added up and
 * nothing else. It is stated here, next to the numbers it is made of, so the two
 * cannot drift apart in silence — a test does the arithmetic.
 *
 * ON `#app` AND NOT ON `.home-cards`, for the same reason `--answer-actions-h` is
 * on `#app`: the pill is a SIBLING of the screen sections, so a value declared
 * inside a screen is invisible to it and the rule would silently fall back to its
 * literal. That is the publication table in the handoff, and it has already cost
 * one rule that never matched. */
:root[data-band='687'] #app,
:root[data-band='646'] #app {
  --home-cards-h: 263px;
}

:root[data-band='687'] [data-screen='home'] .home-card--primary,
:root[data-band='646'] [data-screen='home'] .home-card--primary {
  height: 94px;
  padding: 17px 22px;
}

:root[data-band='687'] [data-screen='home'] .home-card--primary .home-card__title,
:root[data-band='646'] [data-screen='home'] .home-card--primary .home-card__title {
  font-size: 29px;
  letter-spacing: -1.1px;
  margin-top: 3px;
}

:root[data-band='687'] [data-screen='home'] .home-card--primary .home-card__kicker,
:root[data-band='646'] [data-screen='home'] .home-card--primary .home-card__kicker {
  font: 600 13px var(--font);
  letter-spacing: 0.6px;
}

:root[data-band='687'] [data-screen='home'] .home-card__title,
:root[data-band='646'] [data-screen='home'] .home-card__title {
  font-size: 21px;
  letter-spacing: -0.6px;
}

/* THE PAIR'S TWO LINES ARE CENTRED IN 13b, NOT SAT ON THE CARD'S FLOOR.
 *
 * The base rule bottom-aligns them (`justify-content: flex-end`) and pays for it
 * with `padding: 18px 20px`; 13b's frame draws `padding:0 20px` with
 * `justify-content:center`. Taking the padding without the alignment took the
 * floor out from under the text: MEASURED at 393x646, "Why pay more?" ended at
 * 465.0 in a card ending at 466.0 — ONE point of clearance, with 32 of dead space
 * stacked above the title. That is the "Deals/Recipes text misplaced" the device
 * reported, and it is at BOTH browser bands, not just the one the kicker broke.
 * Centred: 78 around 45 of content is 16.5 either side. */
:root[data-band='687'] [data-screen='home'] .home-card--half,
:root[data-band='646'] [data-screen='home'] .home-card--half {
  height: 78px;
  padding: 0 20px;
  justify-content: center;
}

/* The chevron and the hint follow their cards down. 13b draws the pair's `›` at
   top 14 (the base says 16, for an 80pt card) and the accent card's hint at
   bottom 16 in `600 14px` (the base says 18 and 700, for a 96pt card). Two points
   each, which is exactly what the two cards lost. */
:root[data-band='687'] [data-screen='home'] .home-card--half .home-card__go,
:root[data-band='646'] [data-screen='home'] .home-card--half .home-card__go {
  top: 14px;
}

:root[data-band='687'] [data-screen='home'] .home-card__hint,
:root[data-band='646'] [data-screen='home'] .home-card__hint {
  bottom: 16px;
  font-weight: 600;
}

:root[data-band='687'] [data-screen='home'] .home-card--primary .home-card__go,
:root[data-band='646'] [data-screen='home'] .home-card--primary .home-card__go {
  top: 13px;
}

:root[data-band='687'] [data-screen='home'] .home-card--row,
:root[data-band='646'] [data-screen='home'] .home-card--row {
  height: 60px;
  padding: 0 22px;
}

/* ------------------------------------------------- home: the menu (design 2f) */

/* THE HAMBURGER COSTS THE LAYOUT NOTHING, and that is the point of it being
 * absolute. The home screen's budget is spent to the pixel by four equal tiles; a
 * menu in flow would take back some of what moving "I'm done shopping" off this
 * screen just recovered. */
.home-top {
  position: relative;
  flex: none;
}

.home-menu {
  position: absolute;
  left: 16px;
  top: 12px;
  width: 44px;
  height: 44px;
  border: 0;
  background: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  cursor: pointer;
}

.home-menu__bar {
  width: 22px;
  height: 2.5px;
  border-radius: 2px;
  background: var(--ink);
}

/* The drawer is `position: absolute` over the SCREEN, not the body, so the body's
 * scroll position cannot move it and it covers the tiles rather than displacing
 * them. */
.home-drawer {
  position: absolute;
  inset: 0;
  z-index: 70;
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
}

.home-drawer[hidden] {
  display: none;
}

.home-drawer__scrim {
  position: absolute;
  inset: 0;
  border: 0;
  padding: 0;
  background: rgba(10, 37, 64, 0.34);
}

/* COVERS THE PERSISTENT SURFACE, IT NO LONGER CLEARS IT — and that is a change of
 * property, not a loosening of one.
 *
 * This panel used to be a floating card with `max-height: calc(100% - var(--surface-h)
 * - 24px)`, so it stopped above the bottom bar. Frames `2p` and `2r` draw a FULL
 * HEIGHT panel off the left edge, and at `z-index: 70` against the surface's `20` it
 * paints over it. So the old clearance is not merely unnecessary — it would leave
 * 104px of dead panel at the bottom of a drawer whose footer belongs there.
 *
 * The property that replaces it: the panel spans the full height AND outranks the
 * surface, so nothing in the drawer is hidden behind it. Both halves are asserted —
 * one number cannot establish either on its own, and `--surface-h` being absent from
 * this rule is exactly what a regression to the floating card would restore.
 *
 * `.trip-end` inside it still must not reserve `--surface-h`; that half is unchanged.
 *
 * THE LEFT CORNERS ARE SQUARE, AND THE OLD COMMENT HERE WAS WRONG (BE1). It said
 * the 46px matched "`#app`'s own radius, so the panel's outer edge follows the
 * device's". MEASURED on a handset: `#app`'s computed radius is **0px**. The 46px
 * only ever exists inside the simulated-phone media query
 * (`hover: hover` and `min-width: 430px`), so on a real handset the panel's rounded
 * top-left corner sat at 0,0 against a SQUARE app and showed the background through
 * the notch it cut. That is why this was reported from a phone and invisible in the
 * desktop preview.
 *
 * Square is correct in BOTH cases rather than a handset special case: `#app` is
 * `overflow: hidden`, so in the simulated-phone view a square panel is CLIPPED to
 * the 46px device curve anyway. One value, right on both surfaces, and nothing to
 * keep in sync. The right corners stay the design's 30px. */
.home-drawer__panel {
  position: relative;
  width: min(322px, 86%);
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: calc(19px + env(safe-area-inset-top, 0)) 24px
    calc(42px + env(safe-area-inset-bottom, 0));
  border-radius: 0 30px 30px 0;
  background: var(--paper);
  box-shadow: 30px 0 70px rgba(8, 20, 32, 0.3);
}

.home-drawer__logo {
  height: 30px;
  width: auto;
  display: block;
}

/* SANS Y SIN ESPACIADO, que es lo que el frame RENDERIZA.
 *
 * El primer intento fue mono/versalitas/`letter-spacing: 1.4px`, tomado del `<div>`
 * que envuelve el texto, razonando que el `<a>` con estilo en línea era andamio del
 * visor para navegar entre frames. Poner las dos capturas una al lado de la otra lo
 * desmintió: el estilo del `<a>` es el que gana y es el que se ve — Plus Jakarta
 * 13.5px, sin versalitas de CSS y sin espaciado.
 *
 * Leer la fuente y deducir cuál de dos reglas se aplica es exactamente lo que
 * CLAUDE.md dice que no basta. Las versalitas del texto son del CONTENIDO, no de una
 * regla, así que no hay `text-transform` aquí. */
.home-drawer__since {
  margin-top: 26px;
  font: 500 13.5px/1.3 var(--font);
  color: var(--ink-muted);
}

.home-drawer__since[hidden] {
  display: none;
}

.home-drawer__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-m);
}

.home-drawer__title {
  margin: 0;
  font: 800 19px var(--font);
  color: var(--ink);
}

/* --------------------------------------------------- see all (F, 9.3) */

.see-all-sub {
  font: 500 14px/1.35 var(--font);
  color: var(--ink-faint);
  padding: 4px 0 0;
}

/* ------------------------------------------ readback preferences (E, 3k) */

/* MOVED UP into `.readback-head`, under the heading (design 3k), so the horizontal
 * padding is now the head's own and this rule supplies only the gap. It used to sit
 * between the chip row and the heading with its own screen padding. */
.readback-prefs__prompt {
  flex: none;
  margin: 5px 0 0;
  font: 500 15px/1.35 var(--font);
  color: var(--ink-muted);
}

/* A single scrolling ROW, not a wrapping grid. Seven chips wrap to three lines on
 * a 375px screen, which pushes the items themselves off the bottom — and the items
 * are what the read-back exists to confirm. `flex: none` on the chips is what
 * makes them keep their width instead of being squeezed to fit. */
.readback-prefs {
  /* `flex: none` is load-bearing, not tidiness. The read-back body is a flex
   * COLUMN, so the default `flex: 0 1 auto` let this row be shrunk — measured at
   * 42px against a 62px natural height — and `overflow-y: hidden` then clipped the
   * bottom of every 48px chip. The rules were all present and the row still was
   * not readable: "the chips exist" is not "the chips are visible". */
  flex: none;
  display: flex;
  gap: 9px;
  padding: 12px var(--pad-screen) 2px;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}

.readback-prefs::-webkit-scrollbar {
  display: none;
}

/* THE FRAME'S CHIP TREATMENT (3k). Unselected is a TINTED, accent-outlined chip
 * with a `+`, not the neutral grey `.btn--chip` — the row reads as a set of offers
 * rather than as a set of buttons, and the `+` says each one ADDS a constraint.
 * Selected is `--ink` with a `✓`, which `.chip--on` already was.
 *
 * `--paper-warmer`, `--accent-line` and `--accent-ink` rather than the frame's
 * #FFF6EF / #FFD3B5 / #B4551F: the first two are near-misses on tokens this palette
 * already has (1/255 apart in one channel, in the case of the ground), and only the
 * text colour had no relative — that one is a new token, documented in tokens.css
 * with its measured contrast. */
.chip {
  flex: none;
  display: flex;
  align-items: center;
  gap: 7px;
  background: var(--paper-warmer);
  border-color: var(--accent-line);
  color: var(--accent-ink);
}

/* The `+` / `✓` glyph. `--accent` on the tinted ground is a FILL-weight colour used
 * at fill size here, which is why the label beside it uses `--accent-ink` instead. */
.chip__mark {
  font-size: 16px;
  line-height: 1;
  color: var(--accent);
}

/* SELECTED. DECLARED AFTER `.chip`, and that is the whole point of where it sits.
 *
 * Both are (0,1,0) and both set `background`, `border-color` and `color`, so
 * SOURCE ORDER is the only tie-break. This rule used to live ~200 lines earlier
 * and the tint that beat it was `.readback-prefs .chip` at (0,2,0) — which is why
 * a second, byte-identical `.chip--on` existed further down under
 * `.readback-prefs`. When the tint was generalised to bare `.chip` for the
 * compare row, that second copy looked like a duplicate and was removed; it was
 * not a duplicate, it was the cascade override, and without it a SELECTED chip
 * kept the unselected tint while only its `✓` turned white. Caught in the
 * browser, on computed style — the stylesheet read correctly the whole time. */
.chip--on {
  background: var(--ink);
  border-color: var(--ink);
  color: #fff;
}

/* The glyph inverts with the chip. NOT scoped to the read-back any more: the
 * compare row (item 7) is the same component on a second host, and a selected
 * chip there needs the same white `✓`. */
.chip--on .chip__mark {
  font-size: 14px;
  color: #fff;
}

.readback-prefs__note {
  flex: none;
  font: 500 13.5px/1.35 var(--font);
  color: var(--ink-faint);
  padding: 8px var(--pad-screen) 0;
}

.readback-prefs__note[hidden] {
  display: none;
}

/* ------------------------------------------------------------ product screen */

.product-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px var(--pad-screen) 0;
}

.product-title {
  font: 800 26px/1.12 var(--font);
  letter-spacing: -0.8px;
  color: var(--ink);
  margin-top: 6px;
}

.product-price {
  font: 800 32px var(--font);
  color: var(--ink);
  letter-spacing: -1px;
  margin-top: 10px;
  display: flex;
  align-items: baseline;
  gap: 9px;
  white-space: nowrap;
}

.product-price__was {
  font: 600 16px var(--font);
  color: var(--accent);
  letter-spacing: normal;
  text-decoration: line-through;
}

/* The packshot and its `% off` pill, one centred column (design 2m). */
.product-shot {
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

/* The product screen's own packshot. These five declarations were an inline
 * `style` in `render.productBody` — a sixth copy of the shot rules that the
 * stylesheet could not reach and that outranked it. Moved here so the frame ruling
 * above applies to all four shots from one place. No border: see `.card__shot`. */
.product-shot__img {
  width: 130px;
  height: 150px;
  flex: none;
  border-radius: 16px;
  object-fit: contain;
  background: #fff;
}

/* THE PILL IS IN FLOW HERE, not hanging off a corner. `.deal-badge` is absolute
 * for the card layouts it was written for, so this un-positions it — same colour,
 * same shape, same component, placed by the column instead of by the card. */
.product-shot .deal-badge {
  position: static;
  font: 800 15px var(--font);
  padding: 7px 13px;
  /* THE SYMMETRIC PILL IS RESTORED EXPLICITLY. `--off` now carries the frame's
   * nested corner (`22px 0 18px 0`), which is a shape that only means anything
   * tucked into a card corner. This selector is (0,2,0) and wins on the
   * properties it declares — but it declared no radius, so without this line the
   * corner leaks onto the product screen, where the pill is in FLOW under the
   * packshot and has no corner to nest into. */
  border-radius: 20px;
}

/* The add control (designs 2m/2n): a filled pill on the header row, not a
 * full-width button at the foot of the body. */
.product-add {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 11px 18px;
  border: 0;
  border-radius: var(--r-chip);
  background: var(--ink-black);
  color: #fff;
  font: 800 15px var(--font);
  font-family: var(--font);
  cursor: pointer;
}

.product-add__plus {
  font-size: 17px;
  line-height: 1;
}

.section {
  margin: 20px 22px 0;
  padding-top: 16px;
  border-top: 1px solid var(--line-faint);
}

.section__label {
  font: 700 12px var(--font);
  color: var(--ink-faint);
  letter-spacing: 0.7px;
  margin-bottom: 10px;
}

.section__body {
  font: 500 15px/1.5 var(--font);
  color: var(--ink-muted);
}

/* Values with a category-relative position. Never a score, never a verdict. */
.metric__rel {
  font: 700 13px var(--font);
  margin-top: 2px;
}

.metric__rel--high {
  color: var(--good);
}

.metric__rel--low {
  color: var(--good);
}

.metric__rel--moderate {
  color: #e9a100;
}

.claims {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.claim {
  font: 700 13px var(--font);
  padding: 7px 12px;
  border-radius: var(--r-pill);
  background: var(--good-tint);
  color: var(--good);
}





/* 168px, from design `2f`. It had been shrunk to 150 to buy room for the rotating
 * headline; moving "I'm done shopping" into the menu gave that room back, and the
 * orb is the one element on this screen that says the product listens. */
/* 150px, from `10a`/`10b`. BOTH SCREENS FOLLOW, and that is the point of the
 * modifier: `3a` draws welcome's orb at 224 and `2f` drew home's at 168, so the
 * design set has never had them equal, while a shopper crossing from one to the
 * other in a single tap sees the one element that says "this listens" change size.
 * Equalising them was a deliberate deviation before this change and stays one; the
 * only thing that moved is the common value, from `2f`'s 168 to `10a`'s 150. */
.orb--sm {
  width: 150px;
  height: 150px;
}

/* THE TWO SCREENS UN-EQUALISE AGAIN, and the browser frames are explicit about it:
 * 16a draws welcome's orb at 210 and 13b draws home's at 105 across. The note above
 * records equalising them at 150 as a DELIBERATE deviation from the design set, so
 * this is that deviation being overturned by a later ruling rather than forgotten —
 * reported to the product owner, not applied quietly.
 *
 * The reasoning behind the split is visible in the frames: welcome has a whole
 * screen to give the orb and nothing to fit under it, while home is spending every
 * point it can find on four destination cards. `13b` says so in its own caption —
 * "the 59pt it releases goes back into the cards". */
.orb--welcome {
  width: 210px;
  height: 210px;
}

/* --------------------------------------------- "tap to speak", on an arc (10a) */

/* THE LABEL CURVES UNDER THE ORB, which `2f` had as a flat line below it.
 *
 * A 230x196 box holds both: the orb absolutely placed at `left: 40px` (230 - 150
 * halved, so it is centred), and an SVG of the same width pulled 40px UP so the
 * bottom of its arc falls clear of the orb. The arc is the lower semicircle of
 * r=100 about (115,115) — `sweep-flag 0` is what takes it under rather than over —
 * so the text reads left-to-right along the underside.
 *
 * `overflow: visible` on the SVG is load-bearing: the glyphs sit outside the
 * 230x230 viewBox once letter-spacing is applied, and the default `hidden` clips
 * the first and last of them. */
.home-speak {
  position: relative;
  width: 230px;
  height: 196px;
}

.home-speak .orb {
  position: absolute;
  left: 40px;
  top: 0;
}

.home-speak__arc {
  position: absolute;
  left: 0;
  top: -40px;
  width: 230px;
  height: 230px;
  overflow: visible;
  pointer-events: none;
}

.home-speak__arc text {
  font: 700 15px var(--font);
  letter-spacing: 2.4px;
  fill: var(--ink);
  /* The copy string stays sentence-case (`Tap to speak`) so it reads as prose
   * everywhere else and the copy audit still finds it; the frame's caps are a
   * presentation choice and belong here. */
  text-transform: uppercase;
}


/* ---------------------------------------------------- the scanner (§8.19) */

.scan-head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 22px var(--pad-screen) 0;
}

.scan-head__title {
  font: 800 22px var(--font);
  color: var(--ink);
  letter-spacing: -0.4px;
}

.scan-stage {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  margin: 16px 20px 0;
  border-radius: var(--r-card);
  overflow: hidden;
  background: #0b0b0d;
}

.scan-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The guide matches the crop the decoder actually reads (80% x 32% of the frame),
 * so aiming at the guide is aiming at the decoder. It also does the job the torch
 * would do on Android: a clear target beats a brighter picture. */
.scan-guide {
  position: absolute;
  left: 10%;
  top: 34%;
  width: 80%;
  height: 32%;
  border-radius: 12px;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
}

.scan-guide__corner {
  position: absolute;
  width: 26px;
  height: 26px;
  border: 3px solid var(--accent);
}

.scan-guide__corner--tl {
  top: -2px;
  left: -2px;
  border-right: 0;
  border-bottom: 0;
  border-radius: 12px 0 0 0;
}

.scan-guide__corner--tr {
  top: -2px;
  right: -2px;
  border-left: 0;
  border-bottom: 0;
  border-radius: 0 12px 0 0;
}

.scan-guide__corner--bl {
  bottom: -2px;
  left: -2px;
  border-right: 0;
  border-top: 0;
  border-radius: 0 0 0 12px;
}

.scan-guide__corner--br {
  bottom: -2px;
  right: -2px;
  border-left: 0;
  border-top: 0;
  border-radius: 0 0 12px 0;
}

.scan-foot {
  flex: none;
  padding: 16px 22px calc(28px + env(safe-area-inset-bottom, 0));
}

.scan-hint {
  font: 600 16px/1.45 var(--font);
  color: var(--ink-muted);
  text-align: center;
}

.weighed-note {
  margin: 14px 22px 0;
  padding: 12px 14px;
  border-radius: var(--r-control);
  background: var(--accent-bg);
  border: 1px solid var(--accent-line);
  font: 600 14px/1.4 var(--font);
  color: var(--ink);
}


/* --------------------------------------------- product name and location */

/* Five lines, then a control. The full name is always in the DOM. */
.product-title--clamped {
  display: -webkit-box;
  -webkit-line-clamp: 5;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.name-more {
  appearance: none;
  border: 0;
  background: none;
  padding: 6px 0 0;
  font: 600 14px var(--font);
  color: var(--accent);
  cursor: pointer;
}

.name-more[hidden] {
  display: none;
}

/* ========================================================== Phase 4b ======
 * The list: capture sheet, read-back, walking screen, full list.
 *
 * Measured from the design import ("Add list" 3b, "Spoken list, read back for
 * confirmation" 3k, "Up next" 2c). The design owns type scale, colour, radii,
 * spacing and motion; the spec and the phase brief own behaviour.
 *
 * ERGONOMICS (§14) drive the sizes here more than taste does: the phone is on a
 * cart handle, read while walking, operated with one hand that is also steering.
 * One very large element per screen, everything else small, and controls in the
 * lower two-thirds where a reaching thumb lands.
 */

.btn--tall {
  min-height: 66px;
  font: 700 19px var(--font);
}

/* ---------------------------------------------------------- capture (§8.5) */

.capture-route {
  width: 100%;
  min-height: 96px;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 0 20px;
  margin-bottom: 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--paper);
  box-shadow: var(--shadow-tile);
  text-align: left;
}

/* NO ACCENT BORDER ANY MORE (design 3b). The emphasis rule dates from when one
 * of the three routes was live and two were "soon" — it marked the one that
 * worked. All three work now, so it was outlining every row and 3b draws them
 * flat, with the ICON carrying the difference instead. */
.capture-route--soon {
  opacity: 0.55;
}

/* The 54px leading mark on each row. Drawn rather than fetched: these are four
 * primitives — a circle, two rectangles and an arc — and an icon set for four
 * shapes is a network request and an asset to keep in step for no gain. */
.capture-route__icon {
  width: 54px;
  height: 54px;
  flex: none;
  border-radius: 16px;
  background: var(--accent-tint);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Route 1 is the ORB — the same object the listening screen and the surface's mic
 * use, because it is the same promise: this is the one you talk to. */
.capture-route__icon--orb {
  border-radius: 50%;
  background: radial-gradient(circle at 50% 60%, var(--accent-hot), var(--accent-warm));
  box-shadow: 0 4px 12px rgba(255, 90, 0, 0.3);
}

.capture-route__icon::after {
  content: '';
  border: 2.5px solid var(--accent);
}

/* The orb is a filled shape, not an outline around one. */
.capture-route__icon--orb::after {
  content: none;
}

/* A screen: a rectangle with a weighted foot. */
.capture-route__icon--shot::after {
  width: 22px;
  height: 22px;
  border-radius: 5px;
  border-bottom-width: 6px;
}

/* A sheet of paper, landscape. */
.capture-route__icon--paper::after {
  width: 26px;
  height: 20px;
  border-radius: 6px;
}

/* An open ring — "again", the gap being where the arrow would sit. */
.capture-route__icon--again::after {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border-right-color: transparent;
}

/* `--line-strong` and not the frame's #C6CCD3, which is 9/255 from it summed
 * across channels. A chevron at 24px is a glyph, not body text, so borrowing a
 * line colour for it is the reuse rather than a near-miss adopted. */
.capture-route__go {
  flex: none;
  font-size: 24px;
  line-height: 1;
  color: var(--line-strong);
}

.capture-route__main {
  flex: 1;
  min-width: 0;
}

.capture-route__label {
  font: 700 19px var(--font);
  color: var(--ink);
}

.capture-route__sub {
  margin-top: 2px;
  font: 500 13.5px var(--font);
  color: var(--ink-muted);
}

.capture-dismiss {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 14px;
  border: 0;
  background: none;
  font: 600 16px var(--font);
  color: var(--ink-muted);
}

/* --------------------------------------------------------- read-back (§8.6) */

.readback-head {
  flex: none;
  padding: 22px var(--pad-screen) 0;
}

.readback-said {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.readback-said__label {
  font: 500 14px var(--font);
  color: var(--ink-faint);
}

.readback-said__text {
  margin: 0;
  font: 600 15.5px/1.35 var(--font);
  color: var(--ink-muted);
}

.readback-heading {
  margin: 18px 0 0;
  font: 800 28px/1.2 var(--font);
  color: var(--ink);
  letter-spacing: -0.8px;
  text-wrap: pretty;
}

.readback-count {
  margin-top: 5px;
  font: 500 15px var(--font);
  color: var(--ink-muted);
}

/* THE LIST IS THE SCREEN (design 3k, BB). A 10-item list showed ONE item at
 * 375x664 and two at 390x844 — measured, on a real capture — because 96px cards
 * and a 164px stack of full-width buttons left the list 137px of a 1048px
 * scrollHeight. 3k's answer is smaller cards, a side-by-side button row, and a
 * fade instead of a cut.
 *
 * THE FADE IS A MASK ON THE LIST, NOT A BLUR OVER IT, which is what 3k does:
 * `-webkit-mask-image: linear-gradient(180deg,#000 84%,transparent)`. A blurred
 * overlay would need a stacking context and would blur the button row's own
 * background; a mask fades the list's own pixels and costs no layout.
 *
 * `padding-bottom` IS THE FADE ZONE, and it is load-bearing rather than tidy. The
 * mask dims the bottom 16% of the SCROLLPORT permanently, so without a matching
 * pad the last item can never be scrolled into the opaque region and the shopper
 * cannot read the last thing on their own list. The pad lets it scroll clear. */
.readback-rows {
  flex: 1 1 auto;
  min-height: 120px;
  overflow-y: auto;
  padding: 12px var(--pad-screen) 34px;
  display: flex;
  flex-direction: column;
  gap: 7px;
  -webkit-mask-image: linear-gradient(180deg, #000 84%, transparent);
  mask-image: linear-gradient(180deg, #000 84%, transparent);
}

/* THE ONLY SCREEN THAT RESERVES `--surface-h` WITH NO SLACK (BH1).
 *
 * `.screen--with-surface .screen__body` reserves `calc(var(--surface-h) + 16px)` for
 * every screen with a persistent surface. This screen is the one whose whole purpose
 * is the list below it, so it spends the 16px on rows instead. STILL EXACTLY ONE
 * RESERVATION — this narrows the existing one, it does not add a second, which is the
 * mistake CLAUDE.md records three times. The clearance above the surface is asserted
 * in the browser; this value on its own establishes nothing. */
[data-screen='list-readback'] .screen__body {
  /* `--bottom-h` and not `--surface-h`: this screen narrows the SLACK to zero, it
     does not narrow the BAND. The pill is hidden here (the read-back states its own
     count) so today the two are equal — but the ruling is "no gap above whatever is
     down there", and reading the surface alone would make that false the moment
     anything else is pinned on this screen. */
  padding-bottom: var(--bottom-h, var(--surface-h));
}

/* ---- 3k's smaller item card, SCOPED TO THIS SCREEN ----------------------
 *
 * `.list-row` is shared with the full-list screen (`render.listRow`), which has a
 * check control, a tap target and no fade, and which is NOT what this frame
 * governs. Unscoped, these rules would shrink that screen too — so every one of
 * them is under `.readback-rows`, and a test asserts the full list is untouched. */
.readback-rows .list-row {
  gap: 14px;
  padding: 8px 14px;
}

.readback-rows .list-row__text {
  font: 700 17px var(--font);
}

.readback-rows .list-row__brand {
  font: 600 13px var(--font);
}

.readback-rows .list-row__where {
  margin-top: 1px;
  font: 500 13.5px var(--font);
}

.readback-rows .list-row__constraints {
  margin-top: 2px;
  font: 500 12.5px var(--font);
}

.readback-rows .list-row__remove {
  width: 32px;
  height: 32px;
  font: 600 15px var(--font);
}

/* AS LOW AS THEY GO, WITHOUT CROWDING THE BAR (item 5).
 *
 * The list is what this screen is for, so every pixel above these buttons belongs
 * to it. Two things pay for that: the third button is gone, and the top padding
 * drops from 14px to 8px — the buttons already read as a group without it, because
 * they sit against the bottom of the screen.
 *
 * `--surface-h` IS DELIBERATELY ABSENT. `.screen--with-surface .screen__body`
 * reserves it for this screen already; reserving it again here is the mistake
 * CLAUDE.md records three times, worst case 232px of reservation for a 104px
 * surface. What this owes is only the gap BELOW the last button, and 12px + the
 * body's own 16px is 28px of clear air above the bar — measured, and enough that
 * the two never read as one control.
 *
 * The 12px between the buttons is unchanged: they are two different decisions and
 * a smaller gap makes a mis-tap likelier on the one that is not reversible. */
/* The typing escape on the voice screen. Centred and quiet: it is the alternative
 * to the thing the screen is for, not a competitor to it. */
/* BLACK, NOT THE NEUTRAL `.btn--chip` GROUND (item 14). This is the only route
 * off the listening screen for a shopper who will not speak in a shop, and it was
 * reading as one more suggestion chip among the examples above it. */
.voice-type .btn--chip {
  background: var(--ink-black);
  border-color: var(--ink-black);
  color: #fff;
}

.voice-type {
  display: flex;
  justify-content: center;
  padding: 0 24px 18px;
}

/* SIDE BY SIDE, AT 3k's SIZE (BB). Stacked full-width, these two cost 164px of a
 * 664px screen — a quarter of the viewport spent on two controls, against a list
 * that had 137px. 3k puts them on one row at 56px, which returns 108px to the list.
 *
 * `--surface-h` IS STILL NOT RESERVED HERE. `.screen--with-surface .screen__body`
 * already reserves it for this screen and reserving it twice is the mistake
 * CLAUDE.md records three times, once at 232px for a 104px surface.
 *
 * THE TOP GRADIENT is the other half of the fade: the list's mask dissolves its own
 * bottom pixels, and this dissolves the gap between the two so there is no seam
 * where the list stops and the buttons start. */
/* THE ROW SITS AS LOW AS IT CAN (BH1), and the 34px it reclaims was dead space.
 *
 * MEASURED at 390x844 before: the buttons' bottom edge sat 132px above the screen
 * bottom while the nav surface's top is at 98px — 34px of nothing between the two.
 * That is `.screen--with-surface .screen__body`'s 16px of slack over `--surface-h`
 * plus this block's own 12px.
 *
 * THE GAP IS SAFE-AREA-INVARIANT AND THAT IS WHY THIS IS SAFE. `.surface` pads
 * `calc(20px + env(safe-area-inset-bottom, 0))` and this block keeps the same `env()`
 * term, so on a handset with a home indicator BOTH move down by the inset and the
 * clearance is unchanged. Dropping the `env()` here to save a few more pixels would
 * put the buttons under the nav bar on Julian's phone and nowhere else — the browser
 * reports an inset of 0, so it would look correct right up to the demo.
 *
 * The clearance is what a test asserts, never this padding. */
.readback-actions {
  flex: none;
  padding: 8px var(--pad-screen) calc(4px + env(safe-area-inset-bottom, 0));
  display: flex;
  flex-direction: row;
  gap: 10px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0), var(--paper) 34%);
}

/* One row of two equal controls. `height` and `min-height` BOTH, because
 * `.btn--tall` sets `min-height: 66px` and `.btn--primary` sets `height: 66px` —
 * setting only one of them leaves the other winning and the row 66px tall, which
 * is the whole defect. Asserted on the COMPUTED box, not on this rule. */
.readback-actions .btn {
  flex: 1 1 0;
  width: auto;
  min-width: 0;
  height: 56px;
  min-height: 56px;
  border-radius: 18px;
  font: 700 16px var(--font);
  /* EQUAL PADDING IS WHAT MAKES THEM EQUAL WIDTH, and this is measured rather
   * than cosmetic. Under `box-sizing: border-box` a `flex-basis: 0` cannot resolve
   * below the item's own padding + border, so those become per-item FLOORS on the
   * basis: `.btn--ghost`'s `0 20px` plus its 1px border floored it at 42px against
   * the primary's 12px, and the "equal" row measured 141.5px / 171.5px — a 30px
   * difference from two rules that both said `flex: 1 1 0`. Identical padding here
   * removes the floor difference. Asserted on the computed widths.
   *
   * The transparent border is the last 2px of the same arithmetic: `.btn--ghost`
   * carries a 1px border and `.btn--primary` none, which left them 156 / 158.
   * Reserving the same border on both makes the two boxes identical rather than
   * nearly so, which is what lets the test assert equality instead of a tolerance. */
  padding: 0 12px;
  border: 1px solid transparent;
}

/* BOTH BUTTONS BLACK FILLED (BH2) — 3k paints both `#0A0A0C` and that is the ruling.
 * This overrules the primary/ghost deviation the BB handoff recorded.
 *
 * THE GHOST IS RESTYLED, NOT RECLASSED, and that is deliberate: the equal-width
 * arithmetic above depends on both items resolving the SAME padding and border, and
 * swapping the class would change which base rule supplies them. Only the paint
 * changes here — `background`, `color`, `border-color` — so the 164/164 the browser
 * measured cannot move. Re-measured after the change rather than assumed. */
.readback-actions .btn--ghost {
  background: var(--ink-black);
  color: #fff;
  border-color: var(--ink-black);
}

/* ------------------------------------------------------------- a list row */

/* WRAPS. "chicken breasts" beside "Poultry & Packaged Meats" does not fit on one
 * 375px row, and with `flex-wrap: nowrap` the two collided — the item text ran over
 * its own destination. Wrapping puts the destination on its own line instead, which
 * costs a row of height and is legible.
 *
 * `align-items: center` stays for the single-line case, which is most of them. */
.list-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 18px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: var(--paper);
  box-shadow: var(--shadow-tile);
}

/* -------------------------------------------------------------------------
 * THE INFERRED MARKER. The visual half of invariant #5.
 *
 * These are items nobody said out loud — we added them because the shopper
 * named an occasion. Inference is allowed to be generous ONLY because this is
 * unmissable and removal is one tap, so it needs real contrast: it has to read
 * at a glance from a moving cart, not merely be present in a screenshot. Hence
 * an accent left edge AND a tinted row AND a labelled kicker, rather than a
 * subtle grey word.
 * ------------------------------------------------------------------------- */
.list-row--inferred {
  border-color: var(--accent-line);
  background: var(--accent-bg);
  box-shadow: inset 4px 0 0 var(--accent);
}

.inferred-mark {
  display: inline-block;
  margin-bottom: 4px;
  font: 800 11.5px var(--font);
  letter-spacing: 0.8px;
  text-transform: uppercase;
  color: var(--accent);
}

.list-row--done .list-row__text {
  color: var(--ink-faint);
  text-decoration: line-through;
}

.list-row__main {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  padding: 0;
  text-align: left;
}

.list-row__text {
  font: 700 21px var(--font);
  color: var(--ink);
  text-wrap: pretty;
}

.list-row__constraints {
  margin-top: 3px;
  font: 500 13.5px var(--font);
  color: var(--accent);
}

/* UNDER THE NAME (design 3k), not right-aligned on the row.
 *
 * It sat at `margin-left: auto` with `flex-wrap` on the row, which existed for one
 * case: "chicken breasts" beside "Poultry & Packaged Meats" does not fit 375px and
 * the two collided. Stacked, that collision cannot happen at any width, so the wrap
 * rule and its comment are gone rather than kept beside a layout that no longer
 * needs them. */
.list-row__where {
  margin-top: 2px;
  font: 500 14.5px var(--font);
  color: var(--ink-faint);
}

/* The recognised brand, above the shopper's own words. Accent because it is the one
 * thing on the row that came from the catalog rather than from them — and smaller
 * than the name, because their words are still the heading. */
.list-row__brand {
  font: 600 14.5px var(--font);
  color: var(--accent);
  margin-bottom: 1px;
}

/* The item text may shrink and wrap; it must never be squeezed to zero by a long
 * destination. `min-width` is what stops the flex algorithm from doing that. */
.list-row__main {
  min-width: 8ch;
}

.list-row__remove,
.list-row__check {
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: 50%;
  border: 0;
  background: var(--chip-bg);
  color: var(--ink-muted);
  font: 600 17px var(--font);
  display: flex;
  align-items: center;
  justify-content: center;
}

.list-row__check {
  border: 2px solid var(--line-strong);
  background: var(--paper);
}

.list-row__check[aria-pressed='true'] {
  border-color: var(--accent);
  background: var(--accent);
  color: #fff;
}

/* --------------------------------------------------- walking screen (§8.7) */

.walking-head {
  flex: none;
  padding: 22px var(--pad-screen) 0;
}

.walking-head__row {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* `flex: none`, AND THAT IS WHAT MOVES THE LIST CONTROL. While the position text
   took the row's free space it pushed "My list" against the close button; the
   frame gives that space to the middle slot instead. */
.walking-head__pos {
  flex: none;
  font: 600 14px var(--font);
  letter-spacing: 1.2px;
  color: var(--ink-faint);
}

/* The centring slot. `min-width: 0` so a long position string shortens this
   rather than overflowing the row. */
.walking-head__mid {
  flex: 1;
  min-width: 0;
  display: flex;
  justify-content: center;
}

/* CENTRED IN THE SPACE BETWEEN THE POSITION AND THE CLOSE BUTTON, which is what
   the frame draws — not centred on the screen. The two flankers are different
   widths ("ITEM 1 OF 3" against a 38px circle), so screen-centring the label
   would need a matched spacer the frame does not have. */
.walking-head__list {
  border: 0;
  background: none;
  padding: 8px 4px;
  display: flex;
  align-items: center;
  gap: 6px;
  font: 800 17px var(--font);
  color: var(--accent);
  white-space: nowrap;
}

/* THE AFFORDANCE. It is what makes this read as a way through rather than a
   caption — the same glyph and weight as the label, per the frame. */
.walking-head__chev {
  font: 800 17px var(--font);
  color: var(--accent);
  line-height: 1;
}

.walking-progress {
  display: flex;
  gap: 5px;
  margin-top: 14px;
}

/* THREE STATES, FILLING LEFT TO RIGHT (design 2j).
 *
 * It filled right to left, and the cause was that only `--on` existed: every
 * segment was `flex: 1` and coloured purely by `done`, so the colour appeared
 * wherever the done items happened to sit. Each segment now carries its own state
 * and order is document order, which is left to right. */
.walking-progress__seg {
  flex: 1;
  height: 5px;
  border-radius: 3px;
  background: var(--line-faint);
}

/* Checked — the darker colour. */
.walking-progress__seg--done {
  background: var(--accent);
}

/* THE CURRENT item — lighter, so it reads as "you are here" against both a
 * finished segment and an empty one. It has to sit visibly between the two, which
 * is why --progress-now is more saturated than --accent-line. */
.walking-progress__seg--now {
  background: var(--progress-now);
}

/* The hero sits at the TOP and the suggestion follows it immediately; the slack
 * is absorbed below, by the suggestion region. Letting the hero block stretch
 * instead pushed the card to the bottom of the screen with 400px of nothing
 * between them, which reads as a layout that failed to load. */
/* The map FLOATS rather than sharing a flex row, and this is a measurement, not a
 * preference. As a flex sibling the 128px map left the hero column 181px wide on a
 * 375px screen — and at 181px the longest real aisle name ("Snacks & Beverages
 * Corner", 25 characters) cannot fit on one line even at the 24px floor. Design
 * `2c` runs the aisle full width under the item.
 *
 * Floating gets both: the item wraps beside the map, and the aisle line clears it
 * and gets the full 331px. */
/* FIXED HEIGHT — item 6's entire mechanism.
 *
 * The two product cards below must never move: identical y on every item, on every
 * name, on every zone name. Nothing about font ramps guarantees that on its own,
 * because a ramp still has a floor and a long enough name reaches it. So the
 * region is given a height and `render.fitWalkingBody` makes its CONTENT yield —
 * name ramp 54->30, then the aisle 34->24, then the position sentence 17->13, then
 * the name truncates. Position is the invariant; the text bends around it.
 *
 * `overflow: hidden` is the backstop, not the mechanism: if a future change adds a
 * row the fit cannot shrink, the cards still hold their place and the overflow is
 * visible in the browser rather than silently displacing them.
 *
 * The old float is gone (the map no longer floats right), so `::after` clearing
 * went with it. */
.walking-body {
  flex: none;
  /* 298px, and the number is arithmetic rather than taste. The viewport is 812,
   * the head takes 85 and the persistent surface reserves --surface-h (104), so
   * 623 is left for this region plus both cards. The cards need 16 + 98.9 + 44 +
   * 165.7 = 324.6, and 623 - 325 = 298.
   *
   * At 322 the pairing card ended at 731.7 with the surface starting at 708 — it
   * was COVERED by 24px, which item 6 forbids as explicitly as it forbids moving.
   * Locking the y is not enough on its own; the locked position has to be one
   * where both cards are actually visible.
   *
   * SOLVED, not tuned: with both cards at a FIXED 130px (see
   * `.walking-cards .suggestion`) the block below is 16 + 130 + 16 + 130 = 292, so
   * 85 + 326 + 292 = 703 and the pairing card's bottom lands exactly on the
   * surface's top edge. Trying to
   * reach this by shrinking the region alone failed twice, because the cards' own
   * heights were the variable — card 1 measured 99px on one item and 127px on
   * another, and the pairing card 166 to 191 as its reason line wrapped to three
   * lines. A locked y over a variable height locks the top and lets the bottom
   * move, which is the half of "never covered" that matters. */
  /* NO FIXED HEIGHT ANY MORE, and that is the fix rather than a relaxation.
   *
   * 326px was arithmetic against ONE fixed card height (130px for both cards),
   * and the two cards do not hold the same content: the pairing card carries a
   * REASON row the recommended card does not, and invariant #18 makes that row
   * mandatory. Forcing both to one height is what pushed 37px of the pairing
   * card's content outside its own border — measured 17 of 17 items, worst 41px.
   *
   * So the CARDS block below is `flex: none` and sized by its own bounded
   * content, and this region takes whatever is left. The cards still cannot move,
   * because their block's height is a constant; it is simply a constant derived
   * from the content rather than imposed on it. `fitWalkingBody` already ramps
   * this region's text against its measured `clientHeight`, so it needs no number
   * here and survives a viewport that is not 812 tall. */
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  padding: 22px var(--pad-screen) 0;
}

.walking__hero {
  flex: 1;
  min-width: 0;
}

.walking__kicker {
  font: 800 14px var(--font);
  letter-spacing: 0.8px;
  color: var(--accent);
}

/* One very large element per screen (§14 rule 2). This is it: the item, in the
 * customer's own words. The design's second line ("1 gallon, whole") is
 * deliberately absent — a size they never stated is a product-level claim on a
 * category-level intent. */
.walking__item {
  margin: 10px 0 0;
  font: 800 52px/0.98 var(--font);
  letter-spacing: -2px;
  color: var(--ink);
  /* NEVER BREAK OR HYPHENATE INSIDE A WORD.
   *
   * `overflow-wrap: break-word` rendered "Cheetos" as "Cheeto / s", and `hyphens:
   * auto` produced "bev-erages", "condi-ments" and "ba-nanas". A word split across
   * two lines is not a smaller word, it is two wrong words — and on the one string
   * the shopper is reading at arm's length while pushing a cart.
   *
   * The adaptive fit (`render.fitHero`) is what makes a long word fit: it shrinks the
   * type to the floor. Below the floor a word stays whole and overflows, which is
   * visible and reportable rather than silently mangled — `render.wordTooLong`
   * detects exactly that case. */
  text-wrap: balance;
  overflow-wrap: normal;
  word-break: keep-all;
  hyphens: none;
}

.walking__constraints {
  margin-top: 10px;
  font: 600 17px var(--font);
  color: var(--accent);
}

/* `clear: right` is what hands this line the full screen width. Without it the
 * aisle sits in the 181px column beside the map and wraps at every real name. */
.walking__where {
  clear: right;
  margin-top: 22px;
  display: flex;
  align-items: baseline;
  gap: 12px;
  flex-wrap: wrap;
}

/* BLOCK, and with an explicit line-height. Both are needed by the adaptive fit:
 * `scrollHeight` is not a line measure on an inline element, and a computed
 * `line-height: normal` parses to NaN — together they made the aisle shrink to its
 * floor on every string, including "Aisle 3". */
.walking__aisle {
  display: block;
  font: 800 34px/1.1 var(--font);
  letter-spacing: -1px;
  color: var(--accent);
}

/* Invariant #8: an unresolvable location states its own reason. It is never a
 * guessed aisle and never silently blank. */
.walking__nowhere {
  font: 600 17px/1.35 var(--font);
  color: var(--ink-muted);
}

.walking__done {
  font: 800 30px/1.2 var(--font);
  letter-spacing: -1px;
  color: var(--ink);
}

/* The completion screen's two actions, TOGETHER and below the headline.
 *
 * They used to be at opposite ends of the screen: "Create new list" rendered into the
 * card region and collided with the headline, while "Show the list" stayed in the
 * docked CTA pinned at the very bottom. Design `2c`'s spacing — a 57px primary, a
 * 12px gap, then the secondary. */
.walking-done-actions {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 28px;
}

.walking-done-actions .btn {
  width: 100%;
}

/* The docked CTA is the WALKING screen's control, and the completion screen is not
 * the walking screen. Leaving it there is what put a primary action 300px below the
 * two it belongs with. */
[data-screen='walking'][data-done='true'] .walking-actions {
  display: none;
}

/* Nothing to scroll clear of once the CTA is gone — and `--bottom-h` says so by
 * measurement rather than by this rule knowing it. `.walking-actions` is
 * `display: none` in this state, so it leaves the band and the number drops to the
 * surface on its own. */
[data-screen='walking'][data-done='true'] .walking-cards {
  padding-bottom: calc(var(--bottom-h, var(--surface-h)) + 16px);
}

/* FULL WIDTH, ABOVE THE HERO — and the float is gone, which is what pays for it.
 *
 * MEASURED at 375x812 with the real `fitHero`: the 128px floated thumbnail cost
 * 0 px of height because it sat BESIDE the hero, but the hero then had to wrap
 * its 52px type around a 128x90 box. Removing the float reclaims a median of
 * 62 px — a whole line — across six item-name lengths, 0 px for a one-line name
 * like `milk` and 72 px for `bananas` and `cheddar cheese`.
 *
 * The strip is not free, so the net is small rather than zero, and the reason for
 * spending it is the LANDMARK: 128px cannot carry a neighbouring run, and "Dairy
 * Aisle, next to Aisle 2" is what makes the strip more than decoration.
 *
 * `.walking-cards` below is already scrolling — it overflows by 171 px at
 * baseline — so every pixel here lands on an existing clip. The real net is
 * recorded in docs/LOCATION.md. */
.walking-map {
  margin: 0 0 10px;
}


.walking__note {
  margin-top: 10px;
  font: 500 14px/1.35 var(--font);
  color: var(--ink-muted);
}

/* The suggested product. When there is none this region is EMPTY and collapses
 * — no placeholder, no dashed frame. The pairing slot is not here at all. */
/* The DEAL fallback, visually distinct from a pairing (item 6). A pairing claims two
 * things go together; a deal claims only that this is discounted on the way. Sharing
 * one treatment would let the fallback borrow the pairing's authority. */
.suggestion--deal {
  border-color: var(--accent);
  background: linear-gradient(180deg, rgba(255, 90, 0, 0.05), rgba(255, 90, 0, 0));
}

.suggestion--deal .suggestion__label {
  color: var(--accent);
}

/* Tier 3, visually distinct from BOTH the others. A pairing claims a relationship, a
 * deal claims a discount, and this claims only that the aisle sells a lot of it — so
 * it gets the quietest treatment of the three. */
.suggestion--popular .suggestion__label {
  color: var(--ink-faint);
}

/* The pairing's reason and its own aisle, on one line. A flex gap rather than a
 * punctuation character in the copy: the reason is built by the SERVICE from the
 * shopper's words, so gluing a separator onto it in the client would put customer-
 * facing punctuation outside copy.js. Rendered without a gap they read as one
 * string — "Goes with cerealDairy Aisle". */
.suggestion__reason {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin-top: 6px;
  font: 500 13.5px/1.35 var(--font);
  color: var(--ink-muted);
}

/* The pairing's OWN aisle (§9.4) — a different category in a different place, so
 * this is deliberately not the current item's. */
.suggestion__reason-aisle {
  font-weight: 700;
  color: var(--accent);
  /* A MARGIN, NOT THE ROW'S `gap`, and the reason is CSS specificity rather than
     taste. `.suggestion__reason` is `display: flex` with `gap: 4px 10px`, but
     `.walking-cards .suggestion__reason` overrides `display` to `-webkit-box` for
     the two-line clamp — and `gap` does not apply to a `-webkit-box`. So the
     separation silently vanished on the walking screen only, rendering "Goes with
     vegan cream cheeseDairy Aisle": exactly the glued string the elements were
     wrapped in spans to prevent, reintroduced by a rule that changed the layout
     model out from under it. A margin survives both. */
  margin-left: 6px;
}

/* The scrolling card area: recommended, then pairing. The SCROLL lives here and
 * not on either card host, so the two move together and neither can be clipped
 * independently of the other. */
.walking-cards {
  /* NOTHING ON THIS PAGE SCROLLS VERTICALLY. The only scroll is horizontal,
   * between items, and it is a swipe rather than a scroll container.
   *
   * This region scrolled 207px, measured. Two causes, and neither was the cards
   * being too big: the block reserved `--surface-h` a SECOND time (
   * `.screen--with-surface .screen__body` already reserves it, which is the same
   * double-reservation `.rx-dots` and `.rx-body` each carry a note about), and it
   * reserved `--walking-cta-h` for a docked button that design 2j removed from
   * this state — `[data-region="walking-cta"]` is `hidden` while an item is
   * shown, so 68px was held for a control with no height.
   *
   * `flex: none` WITH AN EXPLICIT HEIGHT, and the height is not decoration.
   * Sized by its content instead, this region is 0 tall until the cards are
   * appended — so `fitWalkingBody` ran against a 607px body, found that a 54px
   * name fitted, and never ramped; the cards then landed, the body shrank to 283,
   * and the hero overflowed it by up to 79px with the type still at its maximum.
   * MEASURED on "vegan cream cheese": a 3-line 54px name in a 159px row.
   *
   * 16 + 125 + 16 + 167 = 324 — the two card heights and their margins, the same
   * arithmetic as the card rules below rather than a tuned number. Reserved even
   * when an item has no cards, so the hero's type size does not jump between an
   * item that has them and one that does not.
   *
   * THE 324 IS NOW THE ARITHMETIC RATHER THAN ITS ANSWER, AND IT IS CAPPED BY THE
   * BAND. Two changes, and the second is the one that matters:
   *
   * `--walk-card` and `--walk-pair` name the two card heights the sum is over, so
   * the reservation and the cards cannot drift apart — that was already the
   * comment's intent and the file had the two numbers written out twice.
   *
   * AND IT CANNOT EXCEED WHAT THE BAND LEAVES. 324 is arithmetic against an 844
   * viewport: `85` of head plus `324` of cards plus the bottom reservation is 523,
   * which fits 844 with room for the hero and does not fit 646 at all. `min()`
   * takes whichever is smaller, so the reservation is the design's at 844 and the
   * available space at 646, and `--walk-hero-min` is the floor the hero keeps
   * either way — `fitWalkingBody` ramps its type against whatever it is given, so
   * a smaller region is a smaller headline rather than a clipped one.
   *
   * WHICH BRANCH THIS ACTUALLY GOVERNS: standalone only. The browser variant sets
   * `height: auto` on this block and lets the page scroll (product ruling
   * 2026-08-16, further down this file), and the browser is ~90% of shoppers. So
   * this is the locked-card mode's reservation, and the cap is what stops it
   * locking the cards somewhere they do not fit. */
  --walk-card: 125px;
  --walk-pair: 167px;
  --walk-hero-min: 150px;
  flex: none;
  height: min(
    calc(16px + var(--walk-card) + 16px + var(--walk-pair)),
    calc(
      var(--band-h, 844px) - 85px - var(--walk-hero-min)
        - var(--bottom-h, var(--surface-h)) - 16px
    )
  );
  overflow: hidden;
  /* Horizontal padding only. The surface clearance lives on
   * `.screen--with-surface .screen__body` and reserving it twice is what created
   * the dead space this region then scrolled through. */
  padding: 0 22px;
}

.walking-suggestion:empty,
.walking-pairing:empty {
  display: none;
}

.suggestion {
  /* The `Buy 1 Get 1 Free` pill hangs above the top-right corner (item 17), so
   * the card is its containing block and must not clip it. The `% off` pill used
   * to be the other reason and no longer is — it nests inside the top-left
   * corner now — but BOGO still overhangs and this stays for it. */
  position: relative;
  overflow: visible;
  width: 100%;
  margin-top: 16px;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 14px 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--paper);
  box-shadow: var(--shadow-tile);
  text-align: left;
}

.suggestion__shot {
  width: 56px;
  height: 68px;
  flex: none;
  border-radius: 12px;
  object-fit: contain;
  background: #fff;
  /* No frame — see `.card__shot`. */
}

.suggestion__main {
  flex: 1;
  min-width: 0;
}

/* ITEM 18: the marker-pen highlight behind "RECOMMENDED" and "PAIR IT WITH…".
 * The same treatment `.where-mark` carries for the location, and the same reason
 * for `box-decoration-break` — "PAIR IT WITH…" wraps on a narrow handset and the
 * mark has to be painted on both lines rather than forcing one.
 *
 * `--ink` on the yellow rather than `--ink-faint`: the highlight lifts the ground
 * to a mid tone, and the faint grey the label used to carry against white measures
 * poorly on it. `display: inline` (not the block the label was) is what makes the
 * mark hug the words instead of running the column's full width. */
.suggestion__label {
  display: inline;
  font: 700 12.5px var(--font);
  letter-spacing: 0.4px;
}

/* SCOPED OFF THE TWO FALLBACK TIERS. `.suggestion--deal .suggestion__label` and
 * `.suggestion--popular .suggestion__label` colour their labels deliberately —
 * a deal claims a discount and "popular" claims only that the aisle sells a lot,
 * and the three tiers must not read alike. Both are (0,2,0), so an unscoped
 * highlight here would have painted yellow behind an accent-orange label on the
 * deal card and left the colour rules looking broken rather than overridden.
 * (0,3,0) wins where it should and never fires where it should not. */
.suggestion:not(.suggestion--deal):not(.suggestion--popular) .suggestion__label {
  color: var(--ink);
  background: linear-gradient(
    180deg,
    rgba(255, 214, 0, 0) 12%,
    rgba(255, 214, 0, 0.72) 12%,
    rgba(255, 214, 0, 0.72) 92%,
    rgba(255, 214, 0, 0) 92%
  );
  padding: 2px 6px;
  border-radius: 3px;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

.suggestion__name {
  margin-top: 4px;
  /* 20px per design 2j. */
  font: 700 20px/1.2 var(--font);
  color: var(--ink);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* TWO LINES ALWAYS RESERVED, and this is what locks the PAIRING card's y.
   *
   * MEASURED: with the name free to be one or two lines the recommended card was
   * 98.9px or 118.7px, so the pairing card below it sat at 538 or 558 depending on
   * how long the product happened to be called. The region above was already
   * fixed, so card 1 held at 423 while card 2 moved 20px — item 6 says BOTH cards
   * never move, and reserving the second line is what makes card 1's height a
   * constant instead of a function of its content. */
  min-height: 48px;
}

.suggestion__price {
  margin-top: 5px;
  font: 800 19px var(--font);
  color: var(--ink);
  display: flex;
  align-items: baseline;
  gap: 8px;
}

/* Item 17. Smaller than the current price and struck, so the discount is legible
 * at walking pace — the same accent-struck treatment `.card__was` and
 * `.deal-card__was` carry. */
.suggestion__was {
  font: 600 13.5px var(--font);
  color: var(--accent);
  text-decoration: line-through;
}

.suggestion__more {
  flex: none;
  font: 600 15px var(--font);
  color: var(--accent);
}

/* THE BAR MERGE (§F, design `2c`) — reverses amendment 21's stacking, not its
 * ruling. Amendment 21 kept the persistent surface on the walking screen because
 * §5.1 makes that screen list mode's home and a home with no way to ask a question
 * is a dead end. That stays true: the surface is still here, still reachable. What
 * changes is that "Got it" no longer occupies its OWN in-flow bar above it.
 *
 * Measured before: 214px of the viewport went to two stacked bars (a 94px in-flow
 * CTA block plus a 98px absolute surface). Docking the CTA directly above the
 * surface — 2c's 12px gap — hands the difference back to the suggestion area,
 * which is where the pairing card goes.
 *
 * `bottom` is the surface's own height plus the gap. It is expressed against
 * --surface-h rather than hardcoded so the two cannot drift apart and leave the
 * CTA floating over the controls. */
.walking-actions {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--surface-h) + 6px);
  padding: 0 20px;
  z-index: 21;
}

.walking-actions .btn {
  width: 100%;
}

/* -------------------------------------------------------- full list (§8.8) */

.list-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* 14 rather than 22 at the top: this screen's job is to show as many items as
     it can, and the title needs clearance from the status bar, not framing. */
  padding: 14px var(--pad-screen) 6px;
}

/* The list SCROLLS and the shop button does not. `flex: 1 1 auto` + `min-height: 0`
 * is what makes the scroll live here rather than on the screen body — a scrolling
 * body would carry the pinned button up and down with it, which is the opposite of
 * pinned. */
.list-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 0 var(--pad-screen) 8px;
  display: flex;
  flex-direction: column;
  /* 10 -> 7. Twelve rows at 74px each carry eleven gaps; three pixels off each is
     a third of another row, and rows are what this screen is for. */
  gap: 7px;
}

/* D: the sticky "Shop list" button. Sits above the persistent surface rather than
 * over it — §6's four controls stay reachable, and a primary action overlapping them
 * is a mis-tap generator on a phone. */
.list-shop {
  flex: none;
  /* THE SURFACE IS RESERVED ONCE, and it was reserved twice.
   *
   * This held `calc(var(--surface-h) + 8px)` while
   * `.screen--with-surface .screen__body` was already holding
   * `calc(var(--surface-h) + 16px)` — 232px of reservation for a 104px surface.
   * MEASURED at 375x812: 128px of dead space below the button, which is 1.7 rows
   * of a list the shopper is trying to read. Exactly the double-reservation that
   * made the walking screen's card region scroll 207px.
   *
   * 8px is the gap between the button and the surface's top edge, nothing more. */
  padding: 12px var(--pad-screen) 8px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--paper-warm) 42%);
}

.list-shop .btn {
  width: 100%;
}

/* Nothing to shop, nothing to press. An empty list shows its own empty state and
 * this collapses rather than offering a button that leads to a blank walk. */
.list-shop[hidden] {
  display: none;
}

.list-group {
  margin-top: 12px;
  font: 700 14px var(--font);
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* --------------------------------------------------- §9.1 category browse ---
 * The answer to a department word: the next choice, above the grid.
 *
 * The chip row SCROLLS HORIZONTALLY rather than wrapping. A department can
 * return several subcategories, and a wrapping row pushes the product grid off
 * a 375x812 screen — the grid is still the answer for shoppers who ignore the
 * chips, and it must not be displaced by them.
 */
.browse {
  padding: 4px 0 2px;
}

.browse__prompt {
  padding: 0 22px 10px;
  font: 600 15px var(--font);
  color: var(--ink-soft);
}

.browse__chips {
  display: flex;
  gap: 10px;
  overflow-x: auto;
  padding: 0 22px 4px;
  scrollbar-width: none;
}

.browse__chips::-webkit-scrollbar {
  display: none;
}

.browse__chip {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 0 0 auto;
  max-width: 70vw;
}

.browse__name {
  font: 600 15px var(--font);
  color: var(--ink);
}

/* The API's `aisle`, verbatim, and the only location string on this card.
 * `map_segment` is a polygon key and is never rendered (invariant #11). */
.browse__aisle {
  font: 500 13px var(--font);
  color: var(--accent);
}

/* ------------------------------------------------------------------ recipes
   Designs `3d` (Recipes — one at a time, big visuals) and `3l` (Recipe detail —
   ingredients, then two actions), imported 2026-08-12 into
   docs/design/coop-app-screens.html.

   Values are taken from the design's own inline styles and expressed through the
   existing tokens where a token already holds that exact value; where the design
   uses a one-off (452px card image, 178px hero, 62px action buttons) the literal
   is kept so a later design diff is legible. */

/* ---------------------------------------------------------------- 3d list */

.rx-head {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 22px 22px 0;
}

/* THE SHARED BACK HEADER (§CT): a back arrow, then the screen's name, no X.
 *
 * The generic names are ADDITIONAL SELECTORS on the recipe rules rather than a
 * copy of them, because there is one treatment here and a second definition is
 * the one that goes stale. `.rx-*` keeps working and stays the model it was.
 *
 * WHY THE ARROW REPLACES THE X RATHER THAN JOINING IT. An X says "dismiss this,
 * you are done"; an arrow says "go back where you were". On Deals, the typed
 * screen and the add-list sheet the shopper is going back — and CP's transitions
 * make that literal, since these actions are all in `BACK_ACTIONS` and now slide
 * the screen out to the right. Two controls that both leave, one of which lies
 * about the direction, is worse than one. */
.backhead {
  display: flex;
  align-items: center;
  gap: 14px;
  /* Rows left, not spread. Named explicitly because a `.backhead` that is ALSO
   * something else can inherit the other thing's `space-between` — see the
   * `.sheet__head` rule below, where it did. */
  justify-content: flex-start;
  /* NO PADDING HERE, deliberately, unlike `.rx-head` above. Each of the three
   * surfaces this is used on already has its own inset — `.deals-head`, the typed
   * screen's wrapper, `.sheet__head` — and adding a second would be the
   * pay-for-it-twice mistake CLAUDE.md records three instances of. */
}

/* THE SHEET'S HEAD IS BOTH, and `justify-content` collided. MEASURED: with the X
 * gone there are two children, and `.sheet__head`'s `space-between` pushed the
 * title 134px away from the arrow, hard against the right edge. Both selectors are
 * one class, so which won was down to source order — which is the specificity trap
 * CLAUDE.md records three CSS instances of, and reading the stylesheet would not
 * have shown it. (0,2,0) settles it regardless of order. */
.sheet__head.backhead {
  justify-content: flex-start;
  align-items: center;
}

.backhead__back,
.rx-back,
.rx-hero__back {
  border: 0;
  background: none;
  font: 600 26px var(--font);
  color: var(--ink);
  padding: 0;
  cursor: pointer;
  line-height: 1;
}

.backhead__title,
.rx-head__title {
  font: 800 30px var(--font);
  color: var(--ink);
  letter-spacing: -0.8px;
  margin: 0;
}

.rx-filters {
  display: flex;
  gap: 9px;
  padding: 16px 22px 0;
  overflow-x: auto;
  scrollbar-width: none;
  flex: 0 0 auto;
}

.rx-filters::-webkit-scrollbar { display: none; }

.rx-filter {
  flex: none;
  padding: 12px 18px;
  border-radius: 24px;
  border: 1px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink);
  font: 600 15px var(--font);
  cursor: pointer;
}

.rx-filter[aria-pressed='true'] {
  background: var(--ink);
  border-color: var(--ink);
  color: #fff;
}

.rx-subhead {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 28px 22px 0;
  flex: 0 0 auto;
}

.rx-subhead__label {
  font: 700 20px var(--font);
  color: var(--ink);
}

.rx-subhead__count {
  font: 500 14px var(--font);
  color: var(--ink-faint);
}

.rx-carousel {
  display: flex;
  gap: 16px;
  padding: 16px 22px 0;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* WITHOUT THIS, EVERY CARD AFTER THE FIRST SITS FLUSH AGAINST THE SCREEN EDGE.
     `padding-left` only insets the first card; `scroll-snap-align: start` snaps to
     the scrollport's start edge, which ignores padding unless scroll-padding says
     otherwise. So the carousel looked correctly inset until it was scrolled. */
  scroll-padding-left: 22px;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  flex: 1 1 auto;
  min-height: 0;
  align-items: flex-start;
}

.rx-carousel::-webkit-scrollbar { display: none; }

.rx-card {
  width: 300px;
  flex: none;
  scroll-snap-align: start;
  border: 0;
  background: none;
  padding: 0;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  /* Full height of the carousel track, so the image can take the slack above a
     body that always stays visible. */
  height: 100%;
  min-height: 0;
}

/* The design specifies a 452px image on a 390x844 phone, where the title, meta and
   availability line below it land in the last 90px and just fit.

   Here it is a FLEXIBLE height with 452px as the cap, because a hard 452 pushed all
   three text lines off the bottom on a 375x812 viewport — including the availability
   line, which is the one thing on this card a shopper needs before tapping. The card
   is a column, the image takes the slack, the body is never compressed. Measured in
   the browser at 812px: the image lands near 360px and every line is visible. */
.rx-card__shot {
  width: 300px;
  max-height: 452px;
  flex: 1 1 auto;
  min-height: 120px;
  object-fit: cover;
  border-radius: 28px;
  background: var(--skeleton-a);
  display: block;
}

.rx-card__body {
  padding: 16px 4px 0;
  flex: 0 0 auto;
}

.rx-card__title {
  font: 800 25px/1.15 var(--font);
  color: var(--ink);
  letter-spacing: -0.6px;
}

.rx-card__meta {
  font: 500 15.5px var(--font);
  color: var(--ink-muted);
  margin-top: 7px;
}

/* THE CONDITIONAL AVAILABILITY LINE.
   Green ONLY when `data-complete="true"`, which the client sets from the server's
   `coverage_complete`. 6 of 23 recipes have an unmatched ingredient, so an
   unconditional green all-clear would be a false availability claim on a quarter of
   the set. The incomplete form is deliberately NOT red or warning-coloured: nothing
   is wrong, we simply matched fewer rows, and colouring it as a problem would imply
   a stock claim. */
.rx-card__stock {
  font: 600 15.5px var(--font);
  color: var(--ink-muted);
  margin-top: 4px;
  min-height: 20px;
}

.rx-card__stock[data-complete='true'] { color: var(--good); }

/* No surface-height padding here: `.screen--with-surface .screen__body` already
   reserves it, and adding it again left ~160px of dead space below the dots with the
   card shrunk to match. Measured in the browser at 375x812. */
.rx-dots {
  display: flex;
  gap: 7px;
  justify-content: center;
  flex-wrap: wrap;
  padding: 18px 22px 6px;
  flex: 0 0 auto;
}

.rx-dot {
  width: 7px;
  height: 7px;
  border-radius: 4px;
  background: #dddee2;
}

.rx-dot[data-active='true'] {
  width: 22px;
  background: var(--ink);
}

/* -------------------------------------------------------------- 3l detail */

/* NO `[data-screen='recipe'] { display: flex }` HERE, and this comment is the
   guard. `.screen` is `display:none` and `.screen[data-active='true']` turns it on;
   a bare `[data-screen='recipe']` rule has equal specificity and sits LATER in the
   file, so it wins unconditionally and the recipe screen renders on top of whatever
   screen is actually active. Found in the browser as a stray back-button and a hero
   image over the welcome screen — the same `display`-outranks-the-hidden-state trap
   CLAUDE.md records for `[hidden]`.

   It was also unnecessary: `.screen` already sets `flex-direction: column`. */

.rx-hero {
  position: relative;
  height: 178px;
  flex: 0 0 auto;
  background: var(--skeleton-a);
  overflow: hidden;
}

.rx-hero__shot {
  width: 100%;
  height: 178px;
  object-fit: cover;
  display: block;
}

.rx-hero__back {
  position: absolute;
  top: 16px;
  left: 20px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 20px var(--font);
  z-index: 8;
}

/* `.rx-actions` is a flex SIBLING below this, not an overlay, so the body must not
   also reserve the surface height — `.screen--with-surface .screen__body` does that
   by default and the two together left ~100px of dead space above the buttons.
   The actions bar carries the surface clearance; the body just fills what is left. */
.rx-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* TWO CLASSES, TO MATCH THE RULE IT IS OVERRIDING. `.screen--with-surface
   .screen__body` sets `padding-bottom: calc(var(--surface-h) + 16px)` at specificity
   0,2,0; a bare `.rx-body` is 0,1,0 and loses, so the first attempt at this changed
   nothing and left 140px of dead space above the buttons (measured: computed
   padding-bottom stayed 120px). Third time this exercise that a `display`/padding
   rule lost on specificity — the lesson is to read the computed value, not the
   stylesheet. */
.screen--with-surface .rx-body {
  padding-bottom: 0;
}

.rx-detail {
  display: flex;
  flex-direction: column;
  padding: 18px 22px 20px;
}

.rx-title {
  font: 800 28px/1.15 var(--font);
  color: var(--ink);
  letter-spacing: -0.9px;
  margin: 0;
}

.rx-desc {
  font: 500 15px/1.45 var(--font);
  color: var(--ink-muted);
  margin: 6px 0 0;
}

.rx-chips {
  display: flex;
  gap: 8px;
  margin-top: 14px;
  flex-wrap: wrap;
}

.rx-chip {
  padding: 9px 14px;
  border-radius: 20px;
  background: #f1f2f4;
  font: 600 14px var(--font);
  color: var(--ink);
}

/* The total, in the design's green. It renders only once the SERVER has computed
   it — there is no placeholder and no client-side arithmetic behind it. */
.rx-chip--price {
  background: #eaf7f0;
  color: var(--good);
}

.rx-diet {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}

.rx-diet__chip {
  font: 600 11px var(--font);
  color: var(--good);
  background: var(--good-tint);
  border-radius: var(--r-pill);
  padding: 3px 10px;
  text-transform: capitalize;
}

.rx-block__head {
  font: 700 19px var(--font);
  color: var(--ink);
  margin: 18px 0 0;
}

.rx-rows { display: flex; flex-direction: column; }

.rx-row {
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 11px 0;
  border-bottom: 1px solid #f1f2f4;
}

.rx-row__thumb {
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: 11px;
  background: var(--skeleton-a);
  overflow: hidden;
}

.rx-row__img {
  width: 40px;
  height: 40px;
  object-fit: cover;
  display: block;
}

.rx-row__body { flex: 1; min-width: 0; }

.rx-row__name {
  font: 600 17px var(--font);
  color: var(--ink);
}

.rx-row__sub {
  font: 500 14px var(--font);
  color: var(--ink-muted);
  margin-top: 2px;
}

.rx-row__deal {
  color: var(--good);
  font-weight: 600;
}

/* An unmatched row is DIMMED, never struck through and never marked unavailable:
   the item stays on the list (§8.5) and we are saying nothing about stock. */
.rx-row[data-unmatched='true'] .rx-row__name { color: var(--ink-muted); }

.rx-check {
  width: 30px;
  height: 30px;
  flex: none;
  border-radius: 50%;
  border: 1.5px solid var(--line-strong);
  background: var(--paper);
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 15px var(--font);
  color: var(--ink-faint);
  cursor: pointer;
  padding: 0;
}

.rx-check[data-checked='true'] {
  background: var(--ink);
  border-color: var(--ink);
  color: #fff;
}

.rx-steps {
  counter-reset: step;
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.rx-step {
  counter-increment: step;
  display: flex;
  gap: 10px;
  font: 400 14px/1.45 var(--font);
  color: var(--ink);
}

.rx-step::before {
  content: counter(step);
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  border-radius: var(--r-pill);
  background: var(--accent-tint);
  color: var(--accent-hot);
  font: 700 12px var(--font);
  display: grid;
  place-items: center;
}

/* The two actions of design 3l, side by side and sticky. */
.rx-actions {
  flex: 0 0 auto;
  display: flex;
  gap: 11px;
  padding: 14px 22px calc(var(--bottom-h, var(--surface-h)) + 14px);
  background: var(--paper);
  box-shadow: 0 -1px 0 var(--line);
}

.rx-btn {
  flex: 1;
  height: 62px;
  border-radius: 18px;
  font: 700 18px var(--font);
  cursor: pointer;
  border: 1.5px solid transparent;
}

.rx-btn--primary {
  background: var(--ink-black);
  color: #fff;
}

.rx-btn--primary:disabled {
  background: var(--chip-bg);
  color: var(--ink-faint);
  cursor: default;
}

.rx-btn--ghost {
  background: var(--paper);
  border-color: var(--line-strong);
  color: var(--ink);
}

/* §10 ruling: the "cheapest options" toggle, beside the Ingredients heading.
   A pill rather than a switch — it is one of two named states, and both states are
   legitimate defaults for different shoppers. */
.rx-rows__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.rx-cheapest {
  flex: none;
  padding: 7px 13px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink);
  font: 600 13px var(--font);
  cursor: pointer;
}

.rx-cheapest[aria-pressed='true'] {
  background: var(--good-tint);
  border-color: var(--good-tint);
  color: var(--good);
}

.rx-cheapest:disabled {
  color: var(--ink-faint);
  cursor: default;
}

/* Ingredients | Instructions (item 5) — a segmented control below the description.
   It replaced an inline step expansion AND the "Save instructions" button, so the
   method is readable without an account. */
.rx-tabs {
  display: flex;
  gap: 4px;
  margin-top: 16px;
  padding: 4px;
  background: var(--chip-bg);
  border-radius: 14px;
}

.rx-tab {
  flex: 1;
  padding: 9px 12px;
  border: 0;
  border-radius: 11px;
  background: none;
  color: var(--ink-muted);
  font: 600 14px var(--font);
  cursor: pointer;
}

.rx-tab[aria-pressed='true'] {
  background: var(--paper);
  color: var(--ink);
  box-shadow: var(--shadow-card);
}

/* One primary action, full width (item 6). */
.rx-btn--block { flex: 1 1 100%; }

/* The post-add state, matching the answer screen's accent treatment: the control
   that added becomes the way out. */
.rx-btn--accent {
  background: var(--accent);
  color: #fff;
}

/* The read-back's close control. Positioned rather than in flow because the head is
   a stack of text blocks and the X belongs at its top-right corner. */
.readback-close {
  position: absolute;
  top: 14px;
  right: 18px;
  z-index: 4;
}

[data-screen='list-readback'] .readback-head { position: relative; }

/* THE HOME-SCREEN CARD. Over the walking screen, once, in a browser tab only. */
.pwa-gate {
  position: absolute;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(255, 255, 255, 0.96);
}

.pwa-gate[hidden] {
  display: none;
}

.pwa-gate__card {
  width: 100%;
  max-width: 320px;
  padding: 24px 22px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--paper);
  box-shadow: var(--shadow-tile);
}

.pwa-gate__title {
  margin: 0 0 10px;
  font: 800 24px/1.15 var(--font);
  letter-spacing: -0.5px;
  color: var(--ink);
}

.pwa-gate__body {
  margin: 0 0 16px;
  font: 500 14px/1.45 var(--font);
  color: var(--ink-muted);
}

.pwa-gate__steps {
  margin: 0 0 20px;
  padding-left: 20px;
  font: 500 14px/1.5 var(--font);
  color: var(--ink);
}

.pwa-gate__steps li + li {
  margin-top: 8px;
}

/* --------------------------------------------- flow (a): the one-item card
 *
 * The same overlay shape as `.pwa-gate` above, and deliberately so: both are
 * one-off cards over the walking screen, and a second geometry for the same
 * relationship would be a second thing to keep in step. It sits BELOW the gate's
 * z-index because the gate is a decision about the whole session and this is a
 * decision about one word — but in practice they are never both up, because
 * `openAddConfirm` hides the gate.
 *
 * NO `--surface-h` RESERVATION. The persistent surface is reserved once, by
 * `.screen--with-surface .screen__body`, and this card is a full-bleed overlay
 * that deliberately covers it: the mic and the keyboard are how the shopper got
 * here, and offering them again over a question they have not answered is a
 * second capture on top of the first. The property to assert is that the card's
 * controls clear the bottom of the screen, which the centred flex does.
 */
.add-confirm {
  position: absolute;
  inset: 0;
  z-index: 70;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(255, 255, 255, 0.96);
}

.add-confirm[hidden] {
  display: none;
}

.add-confirm__card {
  width: 100%;
  max-width: 320px;
  padding: 22px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--paper);
  box-shadow: var(--shadow-tile);
}

.add-confirm__said {
  margin-bottom: 14px;
}

.add-confirm__said[hidden] {
  display: none;
}

.add-confirm__said-label {
  font: 700 11px var(--font);
  letter-spacing: 1.4px;
  text-transform: uppercase;
  color: var(--ink-muted);
}

/* The shopper's own words, byte-identical. Quiet, because the ITEM is the thing
   being decided about and the echo is only proof of being heard. */
.add-confirm__said-text {
  margin: 4px 0 0;
  font: 500 14px/1.4 var(--font);
  color: var(--ink-muted);
}

.add-confirm__title {
  margin: 0 0 14px;
  font: 800 22px/1.15 var(--font);
  letter-spacing: -0.5px;
  color: var(--ink);
}

.add-confirm__item {
  margin-bottom: 20px;
}

/* THE ITEM IS THE LARGEST THING ON THE CARD. It is what the question is about,
   and it is read by someone holding a phone in one hand and a cart in the other. */
.confirm-item {
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  background: var(--chip-bg);
}

.confirm-item__text {
  font: 800 26px/1.15 var(--font);
  letter-spacing: -0.6px;
  color: var(--ink);
  /* A long single word shrinks the card rather than breaking mid-word: "Cheetos"
     as "Cheeto / s" is two wrong words, the same rule the walking hero keeps. */
  overflow-wrap: break-word;
  word-break: keep-all;
}

.confirm-item__constraints {
  margin-top: 6px;
  font: 600 13px var(--font);
  color: var(--ink-muted);
}

/* The destination, or the block closes up — no dash, no placeholder, and never a
   guessed aisle (§8.10). */
.confirm-item__where {
  margin-top: 10px;
  font: 700 14px var(--font);
  color: var(--accent-hot);
}

.confirm-item .inferred-mark {
  margin-bottom: 6px;
}

.add-confirm__card .btn + .btn {
  margin-top: 10px;
}

.add-confirm__card .btn {
  width: 100%;
}

/* THE BROWSER-TOLERANT VARIANT. The strip and the also-line are the two elements
   that yield, in that order — see `applyBrowserVariant`. The aisle name, the
   position SENTENCE and the full-map link all stay, so the shopper loses the
   drawing of where along the run they are and keeps the words for it.

   `display: none` rather than not rendering them: the location block is
   `location.js`'s component and only it may build the strip's internals, so the
   walking screen expresses this as a rule about its own scope. The ramp measures
   the region after this applies, so the reclaimed height is real. */
/* THE BROWSER SHOWS EVERYTHING AND SCROLLS. Product ruling 2026-08-16, reversing
 * the earlier one: completeness beats the locked-card invariant here, because the
 * browser tab is the FIRST thing every tester sees and a screen with its content
 * cut reads as broken before it reads as compact.
 *
 * THE INVARIANT NOW APPLIES TO STANDALONE ONLY. There, the cards stay locked at a
 * constant y and nothing scrolls — that is `[data-variant='standalone']`, which is
 * the base rules untouched. Everything below is scoped to the browser branch and
 * changes nothing about the other one.
 *
 * Nothing is dropped and nothing is clipped: the strip and the also-line are back,
 * the cards take the height their content needs, and the page scrolls to reach
 * whatever does not fit. */
[data-screen='walking'][data-variant='browser'] .screen__body {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* The region above the cards stops being a fixed box: with the page scrolling
   there is nothing to protect below it, so its content is shown rather than
   ramped into a height it does not have.

   OVERTURNED AT THE BROWSER BANDS (2026-08-31, frames 19a-19e) — see the override
   further down, which restores the locked box for 646/687. Left UNSCOPED here on
   purpose: `test_the_floating_pill_clears_the_home_cards_at_the_two_browser_bands`
   asserts file-wide that NO rule addresses the standalone band, because that band
   keeps the base design by never being addressed. That guard is a plain SUBSTRING
   test over this whole file, COMMENTS INCLUDED, so a negated selector trips it
   exactly as a positive one does — and so would this sentence if it spelled the
   attribute out. The browser bands are therefore named POSITIVELY, 646 and 687,
   which is also §9's own convention for the 13b block. The standalone band is not
   mentioned anywhere in this file, in a rule or in prose.

   MEASURED, why the override was needed at all: with the frame's 124px cards
   already in place, these two rules still grew `.walking-body` to 299 against the
   frame's ~195, the cards started at 384 instead of 280, and the pairing card ended
   84px inside the ask bar. The card heights alone did not fix it — the region above
   them had to go back to being a bounded box, which is what `fitWalkingBody` ramps
   against. */
[data-screen='walking'][data-variant='browser'] .walking-body {
  flex: none;
  height: auto;
  overflow: visible;
}

[data-screen='walking'][data-variant='browser'] .walking-cards {
  height: auto;
  overflow: visible;
}

/* WHAT WAS HERE, AND WHY IT IS GONE — FRAMES 19a-19e REVERSED IT.
 *
 * These two rules gave the browser variant `height: auto; min-height: 125px;
 * overflow: visible` and then took the line clamps OFF the name and the reason,
 * on the ruling that "the browser shows everything and scrolls". The reasoning
 * was sound for the design it had: the pairing card carried a mandatory REASON
 * row, so an equal FIXED height either clipped that row or left dead space.
 *
 * THE FRAMES REMOVE THE PREMISE. The bottom card now ends at its price and
 * carries no reason row at all, so the two cards hold the same rows and a fixed
 * 124px is no longer a squeeze — it is the frame's own number, twice, and the
 * whole 538pt budget closes on it exactly.
 *
 * MEASURED, why they had to GO rather than be overridden — at 393x646 with a real
 * five-item list:
 *   browser variant  cards grew to 169 and 271px, `.screen__body` overflowed by
 *                    172 (pasta) and 274 (milk), and the second card sat at
 *                    543-814 against an ask bar whose top edge is 548. At rest
 *                    `elementFromPoint` at that card's centre returned
 *                    `div.surface`: the label, the product name and the "See all"
 *                    button were 100% covered;
 *   standalone       the same card was 167px inside a 124px slot, overhanging
 *                    `.walking-cards` by 27 and clipped, with 19pt still under the
 *                    ask bar.
 * Both are one defect: a card free to grow, in a budget that has no slack.
 *
 * They are DELETED, not left dormant. They sit AFTER the frame block below and
 * would have won on source order — the same trap the removed squeeze-rules note
 * records two comments down. A rule that no longer has a purpose is the next
 * person's puzzle. */

/* ===========================================================================
   FRAMES 19a-19e — THE UP NEXT CARDS
   ===========================================================================

   Values below are FRAME VALUES, read from the design's own style attributes in
   `docs/design/coupr-browser-screens.dc.html` (imported 2026-08-31, turn 19).
   They are not derived and must not be moved without a ruling.

   THE VERTICAL BUDGET CLOSES EXACTLY, WHICH IS WHY THE CARD HEIGHT IS LOAD-
   BEARING. The frame's phone is 393x852 = 56 status + 57 URL + **646 content** +
   93 toolbar, and inside the 646 sit **538** of screen and the **98pt** ask bar.
   The 538 sums precisely from its own blocks, in both 19a and 19e:

     19a  44 head + 13 progress + 26 UP NEXT + 94 item + 44 section
          + 22 also-line + 27 see-exactly + 8 + (124 + 12 + 124) = 538
     19e  36 head +  9 progress + 18 UP NEXT + 92 item + 40 aisle
          + 52 map(46) +  24 see-exactly + 7 + (124 + 12 + 124) = 538

   19e pays for its 46pt aisle map by TIGHTENING the type above it — head 44->36,
   UP NEXT 18->16, section 30->28px, see-exactly 17->16px — and both cards stay
   124. So the cards are the fixed term in the budget and everything else yields
   around them, which is the inverse of what the code did.

   MEASURED BEFORE THIS, at 393x646 on a real five-item list: the cards were 169
   and 271px (browser) or 167 and 125px (standalone) against the frame's 124 and
   124. That single fact is all three reported defects:

     * the second card at 543-814 against an ask bar starting at 548, with
       `elementFromPoint` at its centre returning `div.surface` and the label,
       the name and "See all" 100% covered  -> the OCCLUSION;
     * the same card 167px inside its 124px slot, overhanging `.walking-cards`
       by 27 and clipped                    -> part of the CLIP;
     * the product name on TWO lines at 48px tall with `-webkit-line-clamp:
       unset` and no ellipsis mechanism at all -> the WRAP.

   SCOPED AWAY FROM 844 on purpose. The frames specify 646 and nothing asks for a
   third geometry, and 844 is required to be unmoved. `data-band` is on `<html>`
   and `data-screen` on the section, so the two halves of this selector are on
   DIFFERENT elements — a selector expecting both on one never matches. */

/* THE LOCKED BOX, RESTORED AT THE BROWSER BANDS.
 *
 * This undoes the two `[data-variant='browser']` growth rules above for 646/687
 * and leaves them in force at 844. It restates the base values rather than
 * scoping the originals away, because the guard forbids naming 844 (see the note
 * on those rules). The values are `.walking-body`'s and `.walking-cards`'s own
 * base declarations — if either base changes, this has to change with it, which is
 * the cost of the guard and is written down here so it is not a surprise.
 *
 * `overflow: hidden` on the region is what makes `fitWalkingBody`'s ramp mean
 * something: the ramp measures `scrollHeight` against `clientHeight`, and a
 * `visible` box reports no overflow to measure. */
:root[data-band='646'] [data-screen='walking'][data-variant='browser'] .walking-body,
:root[data-band='687'] [data-screen='walking'][data-variant='browser'] .walking-body {
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
  overflow: hidden;
}

:root[data-band='646'] [data-screen='walking'][data-variant='browser'] .walking-cards,
:root[data-band='687'] [data-screen='walking'][data-variant='browser'] .walking-cards {
  flex: none;
  overflow: hidden;
}

/* THE CARDS BLOCK IS A COLUMN WITH A REAL GAP, AND ITS HEIGHT IS THE FRAME'S
   ARITHMETIC.
 *
 * The base block is NOT a flex container — it spaces its cards with the
 * `margin-top: 16px` on `.suggestion` and reserves
 * `16 + --walk-card + 16 + --walk-pair`. So a bare `gap` here is INERT, which is
 * exactly what happened: MEASURED, the block came back 248 tall (124 + 124 and no
 * gap at all) because this rule set `gap: 12px` on a block that does not lay out
 * with gaps, while `margin-top: 0` on the card had removed the spacing that did
 * work. Declaring the axis is what makes the gap mean anything.
 *
 * `--walk-card` and `--walk-pair` BOTH become 124 — the frame draws one height
 * twice — and the reservation becomes `124 + 12 + 124 = 260`. It is still the same
 * arithmetic over the same two custom properties rather than a tuned constant, so
 * the reservation and the cards still cannot drift apart. The band cap is
 * untouched and no longer binds: 646 leaves 297 and 260 fits inside it.
 *
 * 844 KEEPS 125/167 and its margin spacing, untouched, because no frame asks for a
 * third geometry there and 844 is required to be unmoved. */
:root[data-band='646'] [data-screen='walking'] .walking-cards,
:root[data-band='687'] [data-screen='walking'] .walking-cards {
  --walk-card: 124px;
  --walk-pair: 124px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: min(
    calc(var(--walk-card) + 12px + var(--walk-pair)),
    calc(
      var(--band-h, 844px) - 85px - var(--walk-hero-min)
        - var(--bottom-h, var(--surface-h)) - 16px
    )
  );
}

/* THE FIXED TERM. Both cards, both variants, one height — the frame draws 124
   twice and the budget closes on it. `height`, not `min-height`: a floor is what
   let the card grow into the ask bar. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion {
  height: 124px;
  margin-top: 0;
  align-items: center;
  gap: 12px;
  padding: 0 0 0 16px;
  border: 1px solid var(--card-line);
  border-radius: 24px;
  /* Frame shadow. Softer and lower than `--shadow-tile` (0 2px 10px / .04). */
  box-shadow: 0 3px 14px rgba(10, 37, 64, 0.07);
  /* The BOGO pill still overhangs the top-right corner (item 17), so the card is
     its containing block and still must not clip it. */
  overflow: visible;
}

:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__shot,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__shot {
  width: 76px;
  height: 76px;
  border-radius: 16px;
  /* NO BORDER. Frame 19a's own style attribute carries `1px solid #EEEEF1` and it
     was copied from there, which reinstated exactly what `28801fb` removed:
     "NO FRAME ON A PRODUCT IMAGE, ANYWHERE (product ruling). A packshot reads as a
     floating object: no border and no shadow, on every surface that shows one."
     The base rule below still says "No frame"; this override outranked it. A frame
     value does not beat a standing product ruling — the ruling is the newer of the
     two and applies to every surface, which a single frame's markup cannot know. */
}

/* The tier chip. `inline-block` and not `inline`, or the padding and the radius
   have nothing to apply to. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__label,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__label {
  display: inline-block;
  padding: 2px 7px;
  border-radius: 5px;
  background: var(--rec-chip);
  color: var(--ink);
  font: 800 11px var(--font);
  letter-spacing: 0.7px;
  white-space: nowrap;
}

/* BRAND ABOVE THE NAME, on both cards. One line, ellipsised: a wrapped brand
   would spend the name's row and the budget has none to give. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__brand,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__brand {
  margin-top: 3px;
  font: 500 13px var(--font);
  color: var(--ink-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ONE LINE, ELLIPSISED. THE FRAME IS EXPLICIT AND SO IS 19d: "the raw catalogue
   name can then drop its trailing size and truncate cleanly at one line."
   `min-height` is cleared as well as the clamp — the base rule reserves 48px for
   two lines, which was correct while the card's height depended on its content
   and is 24px of dead space now that it does not. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__name,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__name {
  display: block;
  margin-top: 0;
  min-height: 0;
  font: 800 18px/1.2 var(--font);
  color: var(--ink);
  letter-spacing: -0.3px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  -webkit-line-clamp: unset;
}

/* THE DISCOUNT SITS SIDE BY SIDE ON ONE LINE (19a: "$0.75  $0.89"). `baseline`,
   so the struck price sits on the same reading line as the live one. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__price,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__price {
  margin-top: 2px;
  gap: 8px;
  font: 800 20px var(--font);
  letter-spacing: -0.4px;
  white-space: nowrap;
}

/* LINE 5. `nowrap` with `flex: none` items is the frame's own row, and it is why
   `preference_rank.MAX_MARKERS` is 3: a fourth marker does not wrap here, it
   overflows the card. The cap is a layout fact, not an editorial one. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__markers,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__markers {
  display: flex;
  align-items: baseline;
  gap: 4px;
  margin-top: 4px;
  white-space: nowrap;
  overflow: hidden;
}

:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__marker,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__marker {
  flex: none;
  font: 700 10.5px var(--font);
  color: var(--marker-green);
  white-space: nowrap;
}

:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__marker-sep,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__marker-sep {
  flex: none;
  font: 700 15px var(--font);
  color: var(--marker-sep);
  white-space: nowrap;
}

/* Line 5's FLOOR, in the markers row's slot so the fifth line occupies the same
   band whichever fills it. One line, ellipsised, for the same reason as the name:
   the row has a fixed 124px card around it. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__reason-line,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__reason-line {
  margin-top: 4px;
  font: 700 10.5px var(--font);
  color: var(--ink-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* THE REGION ABOVE THE CARDS, TIGHTENED TO 19e'S SPACING.
 *
 * WHY IT HAD TO MOVE AT ALL: with the cards locked at the frame's 260, MEASURED
 * `fitWalkingBody` ran 25 steps, ramped the item name 54->30 and the aisle 34->24
 * — every ramp at its FLOOR — and still reported `fitted: false` with the region
 * overflowing by 64. So this was never a ramp that failed to fire; the region is
 * genuinely taller than the band leaves, and no amount of type-shrinking closes
 * it. The frame's own answer is a smaller region, not smaller type: 19e spends
 * 271pt above the cards where ours spent 352.
 *
 * SPACING ONLY, AND DELIBERATELY NOT THE TWO CONTROLS 19e ALSO SHRINKS. The frame
 * draws a 34x34 close control and a 22pt "See exactly where"; ours are a 44pt
 * touch target and a control whose 44pt target handoff §9 records as a deliberate
 * +6 over the drawing. Trading a touch target for vertical room is not a trade
 * this screen should make — it is read and tapped while walking — so the room
 * comes out of PADDING and MARGINS, which cost nothing to tap.
 *
 * Every value below is a reduction of dead space, not of content, and the sum is
 * what buys the 64. `fitWalkingBody` then has slack and stops ramping to its
 * floor, which is how the item name gets back toward the frame's 40px. */
:root[data-band='646'] [data-screen='walking'] .walking-head,
:root[data-band='687'] [data-screen='walking'] .walking-head {
  padding-top: 8px;
}

:root[data-band='646'] [data-screen='walking'] .walking-progress,
:root[data-band='687'] [data-screen='walking'] .walking-progress {
  margin-top: 4px;
}

:root[data-band='646'] [data-screen='walking'] .walking-body,
:root[data-band='687'] [data-screen='walking'] .walking-body {
  padding-top: 8px;
}

/* The aisle name sat 22 below the item row; 19e draws 6. */
:root[data-band='646'] [data-screen='walking'] .walking__where,
:root[data-band='687'] [data-screen='walking'] .walking__where {
  margin-top: 6px;
}

:root[data-band='646'] [data-screen='walking'] .walking__row,
:root[data-band='687'] [data-screen='walking'] .walking__row {
  margin-top: 6px;
}

/* A TRAILING BOTTOM MARGIN INSIDE A CLIPPED BOX BUYS NOTHING, and it is not
   nothing: `.walking-map` carries `margin-bottom: 10px` to separate itself from
   the cards, but the cards are OUTSIDE this box — they are the next flex item, at
   their own locked y — so the margin only lands inside `.walking-body`'s own
   `overflow: hidden`. MEASURED: the deepest element cleared the content edge by
   6.2 and `scrollHeight - clientHeight` still read 4, entirely from this margin.
   The clip audit correctly did NOT flag it (nothing overhangs, and what does is
   not text), which is exactly why it is worth removing by hand: it is the
   difference between an acceptance number that is 0 and one that is "4, but
   harmless". */
:root[data-band='646'] [data-screen='walking'] .walking-map,
:root[data-band='687'] [data-screen='walking'] .walking-map {
  margin-bottom: 0;
}

/* FRAME 19c — THE NO-PHOTO TILE, in the SAME 76pt slot as the packshot.
 *
 * "Geometry and text positions match 19a exactly, so mixed lists stay aligned"
 * (19c's own caption). That is the requirement: the tile is not a smaller or
 * differently-shaped thing, it is the same slot with different contents, so a list
 * mixing photographed and unphotographed products does not jitter.
 *
 * The type is TINY and deliberately so — 26px for the initial and 7.5px for the
 * section, both `--tile-ink`. It is a placeholder that says which product and
 * roughly where, not a label competing with the name beside it. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__tile,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__tile {
  width: 76px;
  height: 76px;
  flex: none;
  border-radius: 16px;
  background: var(--tile-bg);
  border: 1px solid var(--tile-line);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  overflow: hidden;
}

:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__tile-initial,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__tile-initial {
  font: 800 26px var(--font);
  line-height: 1;
  color: var(--tile-ink);
}

:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__tile-section,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__tile-section {
  font: 700 7.5px var(--font);
  letter-spacing: 0.7px;
  color: var(--tile-ink);
  /* A long department ("BAKERY & HOT FOODS") must not wrap the tile taller or
     spill out of it — the slot is a fixed 76 and the name is already on the card. */
  max-width: 68px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* AND OUTSIDE THE FRAME BANDS the tile still has to be a 76pt-slot-shaped thing
   rather than an unstyled div, because `packshot` builds it on every surface that
   renders a suggestion card. Sized from the base `.suggestion__shot` so the two
   agree at 844 the way they do at the frame bands. */
.suggestion__tile {
  width: 56px;
  height: 68px;
  flex: none;
  border-radius: 12px;
  background: var(--tile-bg);
  border: 1px solid var(--tile-line);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  overflow: hidden;
}

.suggestion__tile-initial {
  font: 800 22px var(--font);
  line-height: 1;
  color: var(--tile-ink);
}

.suggestion__tile-section {
  font: 700 7px var(--font);
  letter-spacing: 0.6px;
  color: var(--tile-ink);
  max-width: 50px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The action column: a full-height rule down the card's right edge, not a word
   floating beside the price. `align-self: stretch` is what makes the divider run
   the whole 124. */
:root[data-band='646'] [data-screen='walking'] .walking-cards .suggestion__more,
:root[data-band='687'] [data-screen='walking'] .walking-cards .suggestion__more {
  align-self: stretch;
  width: 58px;
  flex: none;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-left: 1px solid var(--card-line);
  font: 700 13px var(--font);
  color: var(--accent);
  white-space: nowrap;
}

/* WHAT WAS HERE, AND WHY IT IS GONE. This variant used to squeeze: it tightened
 * the head, the region padding and the Got it control, then spent 20px of the
 * cards' own measured slack, all to fit everything into a screen that would not
 * scroll. The ruling reversed — the browser shows everything and scrolls — so the
 * squeeze has nothing left to buy, and keeping it would shrink a control and a
 * heading for no reason at all. Removed rather than left dormant: a rule that no
 * longer has a purpose is the next person's puzzle.
 *
 * The height reductions in particular MUST go, not merely be overridden. They sat
 * after the auto-height rules above and would have won on source order. */

/* THE SWIPE, between items. The two content regions move together and the head
   does not: the position line and the progress bar are chrome that describe where
   you are in the list, and a shopper dragging the item should not be dragging the
   thing that tells them which item it is.

   `--swipe-dx` is written by `bindWalkSwipe` on every `pointermove`, and
   `--swipe-ms` is 0 while the finger is down — an eased transform during a drag is
   what makes a carousel feel like it is arguing with you. It becomes 180ms only
   for the release, which always lands on exactly one of three offsets. */
[data-screen='walking'] .walking-body,
[data-screen='walking'] .walking-cards {
  transform: translateX(var(--swipe-dx, 0px));
  transition: transform var(--swipe-ms, 0ms) cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* NOTHING VERTICAL FOR THE BROWSER TO CLAIM — **AND THAT IS TRUE OF STANDALONE
   ONLY, NOW.** The original comment ended "nothing on this screen scrolls, so
   nothing is given up by saying so", which was true when it was written and was
   falsified two commits later by the browser variant being made to scroll
   (`baec9e2`). A blanket `touch-action: none` then told the browser the app
   handles every pan on a screen that had become a scroll container — so on a real
   touch screen the two contend, the address bar starts hiding, Safari takes the
   pointer stream and fires `pointercancel`, and the swipe is abandoned. That is
   the whole of "swipe barely works in a browser tab", and it is a conflict
   between two of our own rules rather than anything Safari does wrong.

   THE PREMISE IS THE THING TO SCOPE, not the value. Standalone still scrolls
   nothing, so it still gives up nothing by claiming every axis. */
[data-screen='walking'] {
  touch-action: none;
}

/* The browser variant DOES scroll, so it hands the vertical axis back and claims
   only the horizontal one. `pan-y` is precisely "the browser pans vertically, the
   app handles the rest" — the standard shape for a carousel inside a scroller,
   and the one that stops the two from fighting over the same finger. */
[data-screen='walking'][data-variant='browser'] {
  touch-action: pan-y;
}

/* The build stamp on the welcome screen. A DEVELOPER surface deliberately kept
   quiet — it sits under the consent line in the same faint grey, because it must
   be readable by a tester holding the phone and invisible to a shopper who is not
   looking for it. `pre-wrap` because the expanded state is one file per line. */
.buildstamp {
  border: 0;
  background: none;
  padding: 2px 4px;
  margin: 0;
  font: 500 10px/1.5 var(--font-mono, ui-monospace, monospace);
  color: var(--ink-faint);
  text-align: center;
  white-space: pre-wrap;
  /* NO `word-break: break-all` HERE, and the suite was right to reject it. It was
     defensive and unnecessary — the longest expanded row is a 26-character
     `name:hex` token, which fits at 10px monospace — and the property the rule
     protects is that no CUSTOMER surface splits a word, which the welcome screen
     is regardless of how faint this text is. */
  max-width: 300px;
  cursor: pointer;
  opacity: 0.75;
}

/* `?build=1` — pinned over every screen, so a tester already mid-trip can answer
   "am I on the current build" without getting back to the welcome screen. */
/* PINNED AT THE TOP, NOT THE BOTTOM (product-owner ruling, from the device).
 *
 * It was `bottom: 0`, full width and opaque, which is exactly where the ask bar
 * and the persistent surface live — so the one instrument that tells a tester
 * what they are running sat on the control they were trying to use.
 *
 * THE TOP IS WHERE THE CONTROLS ARE NOT, and the width is what keeps it that way.
 * MEASURED in the top 48pt: home has the hamburger hard left and nothing else;
 * the answer screen has its ✕ hard right and nothing else. `100% - 120px` reserves
 * 60 at each edge, which clears both by construction rather than by luck — a
 * 44pt touch target plus margin. Everything it can still cross is TYPE (the
 * store mark, the echo line), never a tap target, and the acceptance measures
 * that with the same rect-overlap check the floating pill is held to.
 *
 * STILL TAPPABLE, deliberately. The tap is the second digest arm — it re-fetches
 * the module list with `cache: 'reload'` and names the stale file — so
 * `pointer-events: none` would trade one instrument for another. It is small,
 * it is out of the way, and it is only present under `?build=1`. */
.buildstamp--pinned {
  position: absolute;
  /* HARD RIGHT, 64 IN, AND CENTRING WAS THE MISTAKE.
   *
   * Centred it grew toward the screen title, which lives on the LEFT of the top
   * row — MEASURED at 393x646 on the deals screen: it covered 43% of the word
   * "Deals", and merging two lines to shorten it made that 56.2% because the chip
   * got WIDER. Height was never the problem.
   *
   * The top row is asymmetric on every screen that has one: a title or a
   * hamburger on the left, an exit control on the right. So the chip is anchored
   * to the right and reserves 64 for that control — the answer screen's ✕ runs
   * 337 -> 373, so a right edge at 329 clears it by 8, and the chip's left edge
   * lands at 153 against a title ending at 141.7. Zero overlap with either. */
  right: 64px;
  top: calc(2px + env(safe-area-inset-top, 0px));
  bottom: auto;
  z-index: 95;
  max-width: calc(100% - 124px);
  background: rgba(255, 255, 255, 0.92);
  border-radius: 10px;
  box-shadow: 0 1px 6px rgba(10, 37, 64, 0.14);
  padding: 4px 10px;
  opacity: 1;
}

/* Expanded, it is a diagnostic and stops pretending otherwise. */
.buildstamp[aria-expanded='true'] {
  opacity: 1;
  text-align: left;
  color: var(--ink-soft, var(--ink-faint));
}

/* The API-version mismatch banner. A DEVELOPER message, shown in place of an empty
   screen — pinned to the top, above every screen, and dismissible because it must not
   block the app it is describing. Deliberately loud: three debugging cycles were lost
   to this failure looking like a client bug. */
.stale-api {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 90;
  padding: 12px 44px 12px 16px;
  background: #2b1a00;
  color: #ffe3d0;
  font: 500 12.5px/1.5 var(--font);
}

.stale-api__title {
  display: block;
  font: 700 13px var(--font);
  color: var(--accent-warm);
  margin-bottom: 2px;
}

.stale-api__x {
  position: absolute;
  top: 8px;
  right: 10px;
  border: 0;
  background: none;
  color: #ffe3d0;
  font-size: 15px;
  cursor: pointer;
  padding: 4px 6px;
}

/* ==========================================================================
   THE LOCATION COMPONENT (docs/LOCATION.md)
   ==========================================================================
   One component on every screen, in compact or standard mode. The rules that
   shape this block:

     * ONE ORANGE ELEMENT. Orange means "your item is here" and nothing else, so
       the run, the sibling shelves, the neighbour landmark and every label are
       neutral grey. Product content stays visually dominant.
     * The strip renders HORIZONTALLY with the CHECKOUT END ON THE LEFT. That is
       a fixed physical landmark rather than a side, so the orange segment means
       the same thing whichever end of the aisle the shopper walked in from.
     * `See exactly where` is orange TEXT, not a button.
   ========================================================================== */

.loc {
  margin: 0;
}

/* The product screen's map section. 22 px is that screen's own convention —
 * `.cards`, `.chips` and `.metrics` all use the literal, and there is no token
 * for it — so this matches its neighbours rather than introducing a third value. */
.product-map {
  padding: 8px 22px 0;
}

.loc__where {
  margin-bottom: 10px;
}

/* 28px and -0.9 tracking, per browser frame 24c (was 27 / -0.6). Changed on the
 * component rather than scoped to the answer screen: it is the same heading in
 * the same block, and two sizes a pixel apart is how a component acquires a
 * variant nobody can justify later. */
.loc__aisle {
  font: 800 28px/1.15 var(--font);
  letter-spacing: -0.9px;
  color: var(--accent);
}

.loc-strip__frame {
  position: relative;
}

/* `preserveAspectRatio: none` on the SVG: the strip is a SCHEMATIC, deliberately
 * not to scale. A 23 m aisle at true proportions is a 6.6:1 hairline while a
 * 9.3 m fridge run is 2.2:1 and three times taller, so honouring aspect would
 * make the component's height depend on which zone the shopper happens to be
 * going to. Length resolution is what the strip is for; true size is the full
 * map's job.
 *
 * ONE HEIGHT NOW, not two: the neighbour-run row is gone (design 2j replaced it
 * with the aisle's own two sides), so there is no rows-1/rows-2 variance and the
 * block's height no longer depends on whether a landmark happened to exist. */
.loc-strip {
  display: block;
  width: 100%;
  /* `--strip-h` is the LAST step of `render.js::fitWalkingBody`'s ramp, set on
     the region rather than on this element: only `location.js` may build or select
     the strip's internals (a second renderer is how the two location paths once
     diverged), so the fit reaches it through a custom property instead. The
     default is the unramped height. */
  height: var(--strip-h, 54px);
}

/* THE TWO SIDES OF THE AISLE. Grey, with a real border so they read as fixtures.
 * --strip-line is darker than --line-strong on purpose: at 16 viewBox units
 * scaled into ~54 px these have to hold their own against the orange marker. */
.loc-line {
  fill: var(--strip-line);
}

/* THE ONE ORANGE ELEMENT. Taller than the line it sits on, so it reads as a
 * marker ON a shelf rather than a segment OF it (design 2j). */
.loc-here {
  fill: var(--accent);
}

/* Zone level: the whole line, washed, so it cannot be mistaken for a
 * shelf-precise marker. 9 of 280 segments land here and it is an honest state. */
.loc-here-zone {
  fill: rgba(255, 90, 0, 0.3);
  stroke: var(--accent-hot);
  stroke-width: 1.6;
  stroke-dasharray: 5 4;
}

/* THE END CAPS ARE HTML AGAIN, AND THE OBJECTION TO THAT IS ANSWERED RATHER THAN
 * FORGOTTEN. Read this before moving them back.
 *
 * They were sibling divs once, and were pulled INSIDE the SVG because as divs
 * they ADDED ROWS to the block — "18 and 19 px on a screen with none to spare",
 * which was the right call against that implementation. Inside the viewBox they
 * cost no height, and they acquired a different defect instead: the strip is
 * stretched into `--strip-h` with `preserveAspectRatio: none`, and `--strip-h` is
 * the LAST step of `fitWalkingBody`'s ramp, so the LABELS became a function of how
 * much room the location block happened to have.
 *
 * MEASURED, label height across every `--strip-h` the ramp can produce:
 *
 *     strip-h   54    50    46    42    38    34    30
 *     label px  9.03  7.77  7.34  6.88  6.04  5.53  4.99
 *
 * and what each band lands on: 844 and 687 never ramp and sit at 54 -> 9.03px;
 * 646 ramps to its floor and sits at 30 -> 4.99px. Frame 19e draws 12px and states
 * its own reason — "the shelf bars sit just far enough apart for CHECKOUT END and
 * FAR END to read at 12px in an 18pt channel, no wider". So they were wrong at
 * EVERY band, not only at the floor.
 *
 * THE SIBLING-DIV OBJECTION DOES NOT APPLY HERE because these are
 * `position: absolute` inside `.loc-strip__frame`, which is already
 * `position: relative`. They are painted OVER the band the SVG leaves empty and
 * contribute ZERO to layout height, so the block, the bars and the marker are
 * byte-identical to before at every band. That is the only difference between this
 * and the version that was reverted, and it is the whole difference.
 *
 * THE BAND IS COMPUTED FROM THE VIEWBOX, per variant, because the two variants put
 * the labels in different places and their viewBoxes are different heights: an
 * aisle has two lines and a 62-unit box, and the labels sit in the 20-unit gap
 * BETWEEN them (design 2j — the only place they cannot be read as belonging to one
 * side); a counter has one line and a 54-unit box, and they sit BELOW it, where
 * there is no between.
 *
 * A LANDMARK LABEL, never a direction — no left, no right, no front, no back
 * (invariant #11). */
.loc-caps {
  position: absolute;
  left: 0;
  right: 0;
  top: var(--cap-top);
  height: var(--cap-band);
  display: flex;
  align-items: center;
  justify-content: space-between;
  pointer-events: none;
}

/* THE BAND IS IN VIEWBOX UNITS AND THE VIEWBOX MOVED WITH `PAD` 5 -> 9. These
   fractions are `location.js`'s own arithmetic and have to be read from it, not
   remembered: a stale denominator here puts the labels off the channel silently,
   which is a defect with no error and no clipped pixel to notice.

   An aisle: BETWEEN the two lines. PAD 9 + LINE_H 16 = 25 units down, LINE_GAP 20
   units tall, out of a `9*2 + 16*2 + 20` = 70-unit box. The marker now overhangs
   OUTWARD, so the whole 20 is the labels' — it used to lose 4.5 units at each end
   to the marker's centred overhang. */
.loc-strip__frame[data-strip='aisle'] {
  --cap-top: calc(var(--strip-h, 54px) * 25 / 70);
  --cap-band: calc(var(--strip-h, 54px) * 20 / 70);
}

/* A counter: BELOW the single line. PAD 9 + LINE_H 16 + LINE_GAP 20 = 45 units
   down, the remaining 17 tall, out of a `9*2 + 16 + 20 + 8` = 62-unit box. */
.loc-strip__frame[data-strip='single'] {
  --cap-top: calc(var(--strip-h, 54px) * 45 / 62);
  --cap-band: calc(var(--strip-h, 54px) * 17 / 62);
}

.loc-cap {
  color: var(--ink-faint);
  font-family: var(--font);
  font-weight: 700;
  letter-spacing: 0.3px;
  line-height: 1;
  white-space: nowrap;
  /* 12px IS THE FRAME'S NUMBER, and the `min` is the honest floor rather than a
     second scaling rule: the band itself can be smaller than 12px when the ramp
     has compressed the strip (9.7px at `--strip-h: 30`), and type taller than the
     gap it sits in would touch the bars above and below. So the labels are 12px
     wherever the band holds 12px — which is both bands any real handset reaches —
     and never larger than the band. At the 646 floor that is 9.7px against the
     4.99px this replaced. */
  font-size: min(12px, var(--cap-band));
}


/* The position sentence and the way to the full map share ONE row: two stacked
 * rows cost 31 + 44 px, side by side they cost the taller. The sentence is the
 * subject so it takes the space; the link sits at the end of the line. */
.loc__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-top: 7px;
}

/* The position sentence. In BUCKETS, never a percentage: the graphic is allowed
 * to be more precise than the words, never the other way round. */
.loc__position {
  flex: 1 1 auto;
  font: 600 15px/1.3 var(--font);
  color: var(--ink);
}

/* Other places, gated on `name_share` — fires on 24% of located items rather
 * than the 90% the old note managed. Secondary weight: it qualifies the
 * destination, it is not a second destination. */
.loc__also {
  margin-top: 3px;
  font: 500 13px/1.3 var(--font);
  color: var(--ink-muted);
}

/* Orange text, no button (design instruction). Still a real <button> for the
 * accessibility tree and for a 44px touch target. */
/* 16px, per 24c (was 14) — AND THE PADDING STAYS. The frame draws this line at a
 * 22pt box; the note above is about the 44px touch target the box does not
 * provide on its own, so taking the frame's TYPE while keeping the target is the
 * same trade `.deal-badge` made with its pill. Height is not the property here. */
.loc__open {
  flex: 0 0 auto;
  padding: 4px 0;
  border: 0;
  background: none;
  font: 700 16px var(--font);
  color: var(--accent);
  white-space: nowrap;
  cursor: pointer;
}

/* COMPACT: the walking screen, whose hero already carries the aisle in 34px
 * type. The heading is dropped there; the position line is NOT — it is the thing
 * that screen was missing. */
.walking-map .loc__position {
  font-size: 15px;
}

/* ==========================================================================
   THE GOT IT CONTROL (design 2j / 2k)
   ==========================================================================
   Navy pill beside the item name, with the white circle inside it as the tap
   target. On tap it becomes the green checked state and holds for 1s before
   advancing — the check IS the confirmation, so advancing instantly would mean
   the shopper never sees what they confirmed.

   One <button> wrapping both, not a decorative pill plus a mystery circle: the
   accessibility tree gets a single control with a single label. */
/* ROTATED 90 DEGREES: the label on top, the circle below it.
 *
 * Horizontally the pill was 62px tall and roughly 150px wide, and width is the
 * scarce axis on this row — the item name is the largest element on the screen
 * and shares the row with it. Measured at 375px: the name's TEXT ran into the
 * pill on 6 of 28 items, worst 97px ("Sourdough bread"), while the name's own BOX
 * never overlapped it at all, because flex had already shrunk the box and the
 * glyphs simply overflowed. Standing the control up gives that width back. */
.gotit {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  flex: none;
  /* Narrow enough that the name gets the width, wide enough that "Got it" sits on
     one line at 17px and the circle clears its own padding.
     HEIGHT IS AS SCARCE AS WIDTH ON THIS SCREEN — the first rotation used 92px
     and cost the region 30px it did not have, which showed up as the location
     block being clipped rather than as anything wrong with the control. 74px is
     8 + 21 (label) + 5 + 32 (circle) + 8. */
  width: 82px;
  height: 74px;
  padding: 8px 0;
  border: 1.5px solid transparent;
  border-radius: 24px;
  background: var(--ink);
  cursor: pointer;
  transition: background 0.18s ease, border-color 0.18s ease;
}

.gotit__label {
  font: 800 17px var(--font);
  color: var(--paper);
  white-space: nowrap;
  transition: color 0.18s ease;
}

.gotit__circle {
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font: 700 20px var(--font);
  color: var(--paper);
  background: var(--paper);
  transition: background 0.18s ease;
}

/* A SLOW, STEADY, SUBTLE PULSE. The shopper looks at this screen fifteen times a
   trip, so the brief is "draws the eye without being annoying" — which rules out
   anything fast, anything that moves the layout, and anything that repeats a
   sharp attack.

   A HALO rather than a scale: `box-shadow` is composited and does not reflow, so
   it cannot nudge the item name beside it, and a soft ring reads as attention
   rather than as a button twitching. 2.6s with a symmetric ease is slow enough to
   be felt rather than watched.

   It stops once CHECKED — the control has been pressed and is about to advance;
   continuing to ask for attention there would be the interface talking over
   itself. */
@keyframes gotit-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 90, 0, 0); }
  50% { box-shadow: 0 0 0 9px rgba(255, 90, 0, 0.16); }
}

.gotit:not(.gotit--checked) {
  animation: gotit-pulse 2.6s ease-in-out infinite;
}

/* Respected, not approximated. A shopper who has asked their phone to stop moving
   things has asked for exactly this. */
@media (prefers-reduced-motion: reduce) {
  .gotit:not(.gotit--checked) {
    animation: none;
  }
}

/* 2k. --good and --good-tint are the design's own values now, so there is one
   success green in the app rather than two near-misses. */
.gotit--checked {
  background: var(--good-tint);
  border-color: var(--good);
}

.gotit--checked .gotit__label {
  color: var(--good);
}

.gotit--checked .gotit__circle {
  background: var(--good);
  color: var(--paper);
}

/* The item name and its control on one row. `min-width: 0` on the name is what
   lets it shrink and truncate instead of pushing the pill off the screen. */
.walking__row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-top: 12px;
}

/* TWO LINES, ALWAYS, AND AN ELLIPSIS BEYOND — not only as a last resort.
 *
 * This was `.walking__item--clipped`, applied by `fitWalkingBody` once every ramp
 * had bottomed out. That made the two-line rule conditional on the ramp reaching
 * its floor, and the ramp is driven by VERTICAL fit — so a name that was too WIDE
 * ran to three lines and into the control while the vertical check was satisfied.
 * MEASURED: `maxLines` 3 across the list, with the type still at 54px.
 *
 * Clamping unconditionally makes "at most two lines" a property of the element
 * rather than an outcome of the fit, and the fit is then only about size. */
.walking__row .walking__item {
  margin: 0;
  min-width: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* ROOM FOR THE INK, and this is a REGRESSION I INTRODUCED with the clamp above.
   *
   * `.walking__item` is `52px/0.98` — a line box deliberately TIGHTER than the
   * font's own ink, which is what gives the display type its packed look. Before
   * the clamp there was no `overflow: hidden` here, so the ink simply overflowed
   * the box and was visible. Adding `overflow: hidden` to get "at most two lines"
   * started clipping it: MEASURED at 375x812, the glyphs sit 8px ABOVE and 7px
   * BELOW the box at 54px, and 5/4px at the 30px floor — every item, every size,
   * with descenders ("oranges", "yogurt") the most obviously beheaded.
   *
   * In `em` because the overhang is proportional to the type size, which is also
   * why RAMPING can never fix it: shrinking the font shrinks the box and the ink
   * together, so a fit predicate that included ink overflow could never return
   * false and would drive every name to the floor. The box has to be right.
   *
   * 0.17/0.15 against a measured 0.15/0.13 — the margin is for another weight or
   * a font that falls back, not for tuning.
   *
   * THE RIGHT EDGE IS THE SAME DEFECT ON A DIFFERENT AXIS, and it needed a
   * different instrument to see. A Range's client rects are LINE BOXES, which end
   * at the last glyph's ADVANCE WIDTH — they do not include ink that overhangs it,
   * so measuring with them reported 0 overflow on all four sides while the final
   * letter was still being shaved. Canvas `TextMetrics.actualBoundingBoxRight`
   * measures true ink: MEASURED at 375x812 across six words and four ramp sizes,
   * ink runs up to 1.0px past the advance.
   *
   * FLAT px, NOT em, and deliberately the opposite choice from the vertical pad
   * above: the overhang does not scale with the type — it is 0.2px at 54px and
   * 1.0px at 30px, which is grid-fitting, not geometry. A proportional value would
   * be too small exactly where the overhang is largest. Left overhang is negative
   * at every size (ink starts inside the origin), so only the right needs it. */
  padding: 0.17em 2px 0.15em 0;
}

/* Retained: `fitWalkingBody` still adds it, and a name that has actually been cut
   is worth being able to select in a test and in the DOM. The clamp above is now
   unconditional, so this marks the CASE rather than causing the behaviour. */
.walking__item--clipped {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* UP NEXT becomes RESUME LIST when the shopper has swiped away from next-up.
   Same slot, same type scale, so the label and the control cannot both be on
   screen competing for the same job. */
.walking__resume {
  display: inline-block;
  padding: 0;
  border: 0;
  background: none;
  font: 800 14px var(--font);
  letter-spacing: 0.8px;
  color: var(--accent);
  cursor: pointer;
}

/* "See all" to match 2j: plain orange text at 15px, inside the card's own row
   rather than a bordered control below it. It stays a <button> with
   stopPropagation — a tap here must not open the single product screen. */
.suggestion__more {
  align-self: center;
  flex: none;
  padding: 6px 0 6px 8px;
  border: 0;
  background: none;
  font: 600 15px var(--font);
  color: var(--accent);
  white-space: nowrap;
  cursor: pointer;
}

/* ==========================================================================
   BOTH CARDS AT A FIXED HEIGHT — the other half of item 6
   ==========================================================================
   Locking the region above the cards fixes their TOP. It does nothing about their
   BOTTOM, and "never covered" is a statement about the bottom: measured, card 1
   ranged 99-127px as its name wrapped and the pairing card 166-191px as its reason
   line ran to three lines, so the pairing card's bottom moved 25px and crossed
   into the persistent surface.

   So both cards are given the same fixed height and their text is clamped to fit
   inside it. The card is the invariant; the text bends. */
/* TWO CARDS, TWO HEIGHTS, EACH FROM THE CONTENT IT ACTUALLY HOLDS.
 *
 * One shared height was the defect. The two cards do not hold the same rows: the
 * pairing card carries a REASON ("Goes with frozen peas · Poultry & Packaged
 * Meats") that invariant #18 makes mandatory, and the recommended card has no
 * such row. Measured inside the cards at 375x812:
 *
 *   recommended  14 + 16 (label) + 4 + 48 (name, 2 lines) + 5 + 24 (price) + 14
 *                = 125, and it overflowed on 0 of 17 items — it always fitted.
 *   pairing      the same 97 of content + 6 + 36 (reason, 2 lines) = 139 + 28
 *                = 167, against a 130px box: 37px of content rendered BELOW the
 *                card's own border, on 17 of 17 items, worst 41px. That is the
 *                "Popular in Produce FridgesProduce Fridges" orphan text.
 *
 * `overflow: hidden` so nothing can ever paint outside the card again, and
 * `render.js::fitCard` guarantees the content fits so that hiding never clips.
 * Both together, deliberately: the fit function is the property and the overflow
 * rule is the backstop, and a backstop that is never reached costs nothing.
 *
 * Two name lines rather than one, unchanged. Design 2j shows single-line names
 * because its copy is placeholder ("Milam's Whole Milk"); the real catalogue has
 * "Grass Fed Ground Beef 90/10", where one line drops the grade. */
/* THE CLIP MOVED OFF THE CARD AND ONTO ITS TEXT COLUMN.
 *
 * `overflow: hidden` was on the card as a backstop so no content could paint
 * outside it — and it did that job, but a corner pill hangs ABOVE the card's top
 * edge on purpose, so the card was clipping the one child that is supposed to
 * escape it. MEASURED in standalone at 375x664: 28% of the pill cut off,
 * top-right, exactly as reported.
 *
 * THAT MEASUREMENT WAS TAKEN ON THE `% off` PILL, WHICH NO LONGER OVERHANGS —
 * it nests inside the top-left corner now. The rule stays because `Buy 1 Get 1
 * Free` still hangs off the top-right at -16px, which is FURTHER out than the
 * -11px the 28% was measured at; the defect this fixes is unchanged and the
 * badge that can trip it is simply the other one.
 *
 * ONLY VISIBLE IN STANDALONE, which is why it survived the last round of
 * checks: `[data-variant='browser']` relaxes this rule to `overflow: visible`
 * for the browser-tab layout, and a browser tab is the only thing this preview
 * surface can be. The variant is set from `permission.isStandalone()`, whose
 * standalone branch cannot be exercised here at all.
 *
 * The text column is what actually needed clipping — the name and the reason are
 * what overflow a fixed-height card — and the pill is a sibling of it, not a
 * child, so it is now outside the clip. */
.walking-cards .suggestion {
  height: 125px;
  align-items: flex-start;
  overflow: visible;
}

.walking-cards .suggestion__main {
  overflow: hidden;
}

.walking-cards .suggestion--pair {
  height: 167px;
}

/* The pairing card's reason. REQUIRED and non-empty (invariant #18), so it cannot
   be dropped to save space — it is clamped instead. Two lines, then an ellipsis,
   never a mid-word break. */
.walking-cards .suggestion__reason {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The packshot must not stretch to the card's fixed height — design 2j keeps it a
   square-ish thumbnail with the text beside it. */
.walking-cards .suggestion__shot {
  align-self: center;
}

/* The card's own text column, so the clamps have something bounded to work in. */
.walking-cards .suggestion__main {
  min-width: 0;
  overflow: hidden;
}

/* The two legal links on the welcome screen. Underlined rather than merely coloured:
   the consent line sits at `--ink-faint` and a colour-only affordance at that contrast
   is not a visible link, which is the same failure as promising the documents and not
   linking them. `inherit` keeps the sentence one voice. */
.consent__link {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* ═══════════════════════════════════════ sign-in · hoja inferior, frames 3e/3m/3f
   Medidas del diseño re-importado el 2026-08-19. `3e` había cambiado respecto a la
   copia local: le quitaron los botones de Apple y Google, así que aquí no están. */

.authsheet {
  position: absolute;
  inset: 0;
  z-index: 80;             /* sobre el cajón (70) y sobre los .sheet (45): la hoja de
                              sign-in se abre DESDE el cajón y desde una receta */
  display: none;
  align-items: flex-end;   /* anclada abajo, como la dibuja el diseño */
  justify-content: center;
}

/* `data-open` Y NO `[hidden]`, igual que `.sheet`. Un `[hidden]` es solo una regla
   del navegador y pierde contra cualquier regla con su propio `display` — con
   `display:none` de base no hay nada que ganarle. */
.authsheet[data-open='true'] { display: flex }

.authsheet__scrim {
  position: absolute;
  inset: 0;
  border: 0;
  padding: 0;
  /* El mismo velo que el cajón, para que dos capas superpuestas del mismo producto
     no atenúen el fondo de dos maneras distintas. */
  background: rgba(10, 37, 64, 0.34);
  cursor: pointer;
}

.authsheet__panel {
  position: relative;
  width: 100%;
  max-height: calc(100% - 24px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 10px 24px calc(26px + env(safe-area-inset-bottom, 0));
  /* Esquinas redondeadas ARRIBA solamente: la hoja nace del borde inferior. */
  border-radius: 26px 26px 0 0;
  background: var(--paper);
  box-shadow: var(--shadow-float);
}

/* El asa. Decorativa y centrada, como el diseño. */
.authsheet__grab {
  width: 40px;
  height: 4px;
  margin: 0 auto 18px;
  border-radius: 999px;
  background: var(--line-strong);
}

.authsheet__title {
  margin: 0 0 8px;
  font: 800 26px/1.2 var(--font);
  letter-spacing: -0.02em;
  color: var(--ink);
  text-wrap: balance;
}

.authsheet__copy,
.authsheet__sentto {
  margin: 0 0 20px;
  font: 500 15.5px/1.5 var(--font);
  color: var(--ink-muted);
}

.authsheet__cta {
  width: 100%;
  height: 62px;
  margin-top: 16px;
  border-radius: 18px;
  font: 700 19px var(--font);
}

.authsheet__skip {
  display: block;
  width: 100%;
  margin: 16px 0 0;
  border: 0;
  background: none;
  font: 600 16px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
}

.authsheet__skip[disabled] { color: var(--ink-faint); cursor: default }

.authsheet__change {
  border: 0;
  background: none;
  padding: 0;
  font: 600 15.5px var(--font);
  color: var(--accent);
  cursor: pointer;
}

/* ── el campo del teléfono: prefijo, separador y campo, con el borde naranja al foco */
.authfield {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 62px;
  padding: 0 18px;
  border: 1.5px solid var(--line);
  border-radius: 18px;
  background: var(--paper-warm, #fff);
  transition: border-color 0.15s;
}

.authfield:focus-within { border-color: var(--accent) }

/* El separador vertical entre el prefijo y el número, como dibuja `3e`. Un borde en
   el prefijo y no un elemento aparte: una línea no necesita un nodo. */
.authfield__prefix {
  flex: none;
  padding-right: 12px;
  border-right: 1px solid var(--line);
  font: 600 19px var(--font);
  color: var(--ink);
}

.authfield__input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  font: 600 19px var(--font);
  color: var(--ink);
}

.authfield__input:focus { outline: none }

/* ── el código: seis casillas dibujadas y un campo real transparente encima */
.authcode { position: relative; height: 66px }

/* EL INPUT CUBRE LAS CASILLAS Y ES INVISIBLE, no `display:none`. Un campo oculto no
   recibe foco, y sin foco no hay pegado ni autorrellenado del SMS — que es justo lo
   que el §4.3 pide. Así que se estira encima con el texto transparente: el comprador
   ve los dígitos que pintan las casillas y escribe en el input de verdad. */
.authcode__input {
  position: absolute;
  inset: 0;
  width: 100%;
  border: 0;
  padding: 0;
  background: none;
  color: transparent;
  caret-color: transparent;
  font: 700 26px var(--font);
  z-index: 2;
}

.authcode__input:focus { outline: none }

.authcode__boxes {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 8px;
  height: 66px;
}

.authcode__box {
  display: grid;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--paper-warm, #f8f8f9);
  font: 700 26px var(--font);
  color: var(--ink);
}

.authcode__box--next { border-color: var(--accent); background: #fff }

/* ── los avisos, reutilizando `.notice` del propio repo */
.authsheet__panel .notice { margin: 14px 0 0 }
.authsheet__panel .notice[hidden] { display: none }

/* ── la fila del cajón */
.auth-row { margin-top: 14px }
.auth-row__ask { width: 100% }
.auth-row__note {
  margin: 8px 2px 0;
  font: 500 13px/1.45 var(--font);
  color: var(--ink-faint);
}

/* ═══════════════════════════════════════ historial · frames 3g y 3h · MAQUETA
   Los datos son inventados y la pantalla está apagada salvo con `?mock=history`.
   Ver `webapp/js/history.js` para el porqué y para las tres cosas que el diseño
   pide y el dato no tiene. */

.hx-body { padding: calc(18px + env(safe-area-inset-top, 0)) 20px 0 }

.hx-head { display: flex; align-items: center; gap: 10px; margin-bottom: 16px }

.hx-title {
  margin: 0;
  font: 800 24px/1.2 var(--font);
  letter-spacing: -0.02em;
  color: var(--ink);
}

.hx-saved {
  margin: 0 0 16px;
  font: 600 14px var(--font);
  color: var(--good);
}

.hx-saved[hidden] { display: none }

/* Los chips de sucursal. `overflow-x` propio con los chips a `flex:none`, para que
   una lista larga se desplace dentro de su fila y no empuje el ancho de la pantalla
   — la regla de este repo es que el cuerpo nunca scrollea en horizontal. */
.hx-chips {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  margin: 0 0 18px;
  padding-bottom: 2px;
  scrollbar-width: none;
}

.hx-chips::-webkit-scrollbar { display: none }

.hx-chip {
  flex: none;
  padding: 8px 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--paper);
  font: 600 14px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
}

.hx-chip--on {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--paper);
}

.hx-list { display: flex; flex-direction: column; gap: 10px }

/* «Cargar mas», al pie de la lista.
 *
 * NO RESERVA `--surface-h`, y esto es deliberado. `.screen--with-surface .screen__body` ya
 * lo reserva para esta pantalla, y este boton esta al fondo — que es exactamente donde la
 * tentacion de reservarlo otra vez aparece. Pagarlo dos veces es lo que dejo 232px de
 * reserva para una superficie de 104px en `.list-shop` y 207px de scroll en dos tarjetas
 * que cabian. La propiedad es que el boton se vea por encima de la superficie, y de eso ya
 * se encarga el cuerpo.
 *
 * `margin-top` Y NO `gap`: el `gap: 10px` de `.hx-list` lo separa como si fuera otra
 * tarjeta, y no lo es — es el final de la lista.
 */
.hx-more {
  align-self: center;
  margin: 12px 0 4px;
  min-width: 160px;
}

.hx-more[disabled] { opacity: .55 }

.hx-card {
  display: block;
  width: 100%;
  text-align: left;
  padding: 15px 16px;
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--paper);
  cursor: pointer;
}

.hx-card__top {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  margin-bottom: 5px;
}

.hx-card__store { font: 700 16px var(--font); color: var(--ink) }

/* `tabular-nums` para que una columna de importes quede alineada: sin ello, un 1 y
   un 8 tienen anchos distintos y los decimales bailan de fila en fila. */
.hx-card__total {
  font: 700 17px var(--font);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.hx-card__meta {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  font: 500 13px var(--font);
  color: var(--ink-muted);
}

.hx-card__saved { color: var(--good); font-weight: 600 }

/* ── el ticket abierto (3h) */
.hx-receipt { display: flex; flex-direction: column }

.hx-rc__head {
  padding: 0 0 16px;
  border-bottom: 1px solid var(--line);
  margin-bottom: 14px;
}

.hx-rc__total {
  font: 800 30px var(--font);
  letter-spacing: -0.02em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.hx-rc__meta { margin-top: 4px; font: 500 13.5px var(--font); color: var(--ink-muted) }

.hx-rc__saved { margin-top: 8px; font: 700 15px var(--font); color: var(--good) }

/* TRES COLUMNAS, y `align-items` importa: sin él la miniatura de 44px estira la fila y
   el nombre queda pegado arriba. `3h` las centra verticalmente. */
.hx-line {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 11px 0;
  border-bottom: 1px solid var(--line-faint);
  font: 500 14.5px var(--font);
}

.hx-line__name { color: var(--ink); flex: 1; min-width: 0 }

.hx-line__price {
  flex: none;
  text-align: right;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* `was $5.99` tachado, y el delta en verde. Los dos son inventados: no existe precio
   de referencia en la fuente. */
.hx-line__was { text-decoration: line-through; color: var(--ink-faint) }
.hx-line__delta { color: var(--good); font-weight: 600 }

.hx-rc__rest {
  padding: 14px 0 0;
  font: 600 14px var(--font);
  color: var(--ink-muted);
}


/* ══════════════════════════════════════════ el cajón: filas, separador y pie (2p/2r)
 *
 * Las filas se dibujan aunque su destino no exista, y dicen por qué al tocarlas. Todas
 * llevan `aria-disabled`, así que un lector de pantalla no las anuncia como listas.
 */

.drawer-rule {
  height: 1px;
  margin: 24px 0 0;
  background: var(--line);
}

.drawer-rule--faint {
  margin: 0;
  background: var(--skeleton-a);
}

.drawer-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 18px 0;
  cursor: pointer;
}

/* NO OPACIDAD SOBRE UNA FILA DESHABILITADA. `2p` y `2r` las dibujan a plena tinta, y
   atenuarlas diría "roto" donde el motivo dice "por llegar" — el motivo llega al
   tocar, y hasta entonces la fila no debe parecer un error. */
.drawer-row__icon {
  width: 38px;
  height: 38px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 12px;
  background: var(--chip-bg);
}

.drawer-row__icon--warm {
  background: var(--accent-tint);
}

/* LOS GLIFOS, QUE FALTABAN. La primera versión puso el cuadrado de fondo y nada
 * dentro, y como los dos fondos son casi blancos sobre blanco —`#fff1e7` y `#f1f1f3`—
 * el resultado no era "un icono que no se ve" sino ningún icono. El hueco de 38px
 * estaba ahí, ocupando su sitio, lo que hace que la fila parezca correcta en una
 * captura rápida y no lo esté.
 *
 * Los dos son geometría, como en el frame: `2p` los dibuja con bordes y radios sobre
 * divs vacíos, no con SVG, así que aquí son `::before` con las mismas medidas. */

/* Bocadillo: cuadrado con UNA esquina sin redondear, que es la cola. */
.drawer-row__icon--warm::before {
  content: '';
  width: 19px;
  height: 15px;
  border: 2px solid var(--accent);
  border-radius: 5px 5px 5px 0;
}

/* Cubo: caja SIN borde superior, pegada abajo. El `align-items: flex-end` y el
   `padding-bottom` son del frame — sin ellos queda centrada y parece una bandeja. */
.drawer-row__icon--bin {
  align-items: flex-end;
  padding-bottom: 9px;
}

.drawer-row__icon--bin::before {
  content: '';
  width: 17px;
  height: 13px;
  border: 2px solid var(--ink-muted);
  border-top: none;
  border-radius: 0 0 4px 4px;
}

.drawer-row__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.drawer-row__title {
  font: 700 17px var(--font);
  color: var(--ink);
}

.drawer-row__note {
  font: 500 13px/1.35 var(--font);
  color: var(--ink-faint);
}

/* La punta de flecha del diseño: dos bordes de un cuadrado girado 45°, que es cómo la
   dibuja el frame — sin un SVG para dos líneas. */
.drawer-row__chev {
  width: 8px;
  height: 8px;
  flex: none;
  border-right: 2px solid var(--strip-line);
  border-top: 2px solid var(--strip-line);
  transform: rotate(45deg);
}

.drawer-row__pill {
  flex: none;
  font: 700 12px var(--font);
  color: var(--accent);
  border: 1.5px solid var(--accent-line);
  border-radius: var(--r-pill);
  padding: 7px 13px;
}

/* EL `flex:1` DEL DISEÑO. Empuja el pie al fondo del panel; sin él queda flotando bajo
   la última fila y el hueco aparece DEBAJO del pie, que es al revés. */
.drawer-spacer {
  flex: 1 1 auto;
  min-height: 24px;
}

.drawer-foot {
  flex: none;
}

.drawer-foot__legal {
  display: flex;
  align-items: center;
  gap: 16px;
}

/* BOTONES CON ASPECTO DE ENLACE, no enlaces: no hay documento al que apuntar. El
   aspecto es del diseño; el elemento es lo que la ausencia de destino permite. */
.drawer-foot__link {
  appearance: none;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  font: 600 13.5px var(--font);
  color: var(--ink-muted);
}

.drawer-foot__dot {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--strip-line);
}

.drawer-foot__build {
  margin-top: 10px;
  font: 500 11.5px/1.5 var(--font);
  color: var(--strip-line);
}


/* EL ICONO DE «Login» DE `2p`: círculo de contorno con la silueta dentro, en acento.
 *
 * Es un `::before` y no un `<span>` porque `paintAuthRow` asigna `textContent` al
 * botón en cada repintado, lo que borra cualquier hijo. Un hijo habría sobrevivido al
 * primer pintado y desaparecido al cambiar de estado — visible una vez de cada dos.
 *
 * Solo en el estado sin sesión: `2r` no pone icono en «Log out», le pone el suyo en
 * otra posición dentro de la lista, que es trabajo aún no hecho. */
.auth-row[data-signed-in='false'] .auth-row__ask::before {
  content: '';
  width: 17px;
  height: 17px;
  flex: none;
  background: no-repeat center / contain
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10.6' fill='none' stroke='%23ff5a00' stroke-width='2.6'/%3E%3Ccircle cx='12' cy='9.6' r='3.1' fill='%23ff5a00'/%3E%3Cpath d='M5.9 19.4a6.4 6.4 0 0 1 12.2 0z' fill='%23ff5a00'/%3E%3C/svg%3E");
}


/* ═══════════════════════════════════════ el bloque de perfil de `2r`
 *
 * `[hidden]` EXPLÍCITO EN LAS TRES PIEZAS. Este repo tiene documentado cuatro veces que
 * el `[hidden]` del navegador es solo un `display:none` de la hoja del agente de
 * usuario, así que CUALQUIER regla de componente con su propio `display` lo gana. Estas
 * tres piezas se ocultan por estado de sesión y dos de ellas son `display:flex`, o sea
 * exactamente el caso: sin estas reglas se verían siempre, y el defecto sería «el
 * bloque de perfil aparece sin sesión», no «falta un display:none».
 *
 * La especificidad es (0,2,0) frente a la (0,1,0) de la clase base, así que gana por
 * especificidad y no por orden de fuente — que es lo que hay que hacer cuando la regla
 * base está en el mismo fichero y alguien puede reordenarlo. */
.profile-row {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 64px;
  margin-top: 12px;
  padding: 0 15px;
  border: 1.5px solid var(--line-strong);
  border-radius: var(--r-control);
  cursor: pointer;
}


.profile-row[hidden] {
  display: none;
}

.auth-row[hidden] {
  display: none;
}

.drawer-row[hidden] {
  display: none;
}

.drawer-rule[hidden] {
  display: none;
}

/* EL AVATAR LLEVA LA SILUETA, NO INICIALES. `2r` dibuja «MR» y no hay nombre del que
   sacarlas: la misma silueta del icono de «Login», sobre el tinte cálido del frame. Un
   avatar con una letra inventada sería el único dato falso de esta pantalla. */
.profile-row__avatar {
  width: 38px;
  height: 38px;
  flex: none;
  border-radius: 50%;
  background: #ffe9da no-repeat center / 19px 19px
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='9' r='3.4' fill='%23ff5a00'/%3E%3Cpath d='M4.8 20.4a7.2 7.2 0 0 1 14.4 0z' fill='%23ff5a00'/%3E%3C/svg%3E");
}

.profile-row__body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.profile-row__title {
  font: 700 17px var(--font);
  color: var(--ink);
}

/* El nombre bajo «My profile». `[hidden]` propio: `.profile-row__body` es flex, así que
   sus hijos también son ítems flex, y un `display` de componente ganaría al del agente
   de usuario — el mismo caso de las tres piezas de arriba, un nivel más abajo. */
.profile-row__who {
  margin-top: 1px;
  font: 500 12.5px var(--font);
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile-row__who[hidden] {
  display: none;
}

/* EL GLIFO DE «Log out»: puerta abierta con la flecha saliendo.
 *
 * Data URI y no pseudo-elementos, a diferencia de los otros dos: el frame lo compone
 * con TRES divs —el marco de la puerta, el asta de la flecha y su punta— y un elemento
 * solo tiene `::before` y `::after`. Antes que meter un tercer `<span>` en el markup
 * para un adorno, va el dibujo entero en una declaración. */
.drawer-row__icon--out {
  background: var(--chip-bg) no-repeat center / 20px 20px
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2368737d' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 4h4a1.5 1.5 0 0 1 1.5 1.5v13A1.5 1.5 0 0 1 18 20h-4'/%3E%3Cpath d='M4.5 12h8'/%3E%3Cpath d='M9.5 8.5 13 12l-3.5 3.5'/%3E%3C/svg%3E");
}


/* La fila del recibo de `3h`: nombre y «2 × $5.49» apilados a la izquierda, el total de
   la línea en columna a la derecha. La columna derecha lleva `tabular-nums` para que los
   importes se alineen — un recibo cuyas cifras bailan se lee como un error de cuentas. */
.hx-line__body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.hx-line__meta {
  font: 500 12.5px var(--font);
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}

.hx-line__price {
  font-weight: 600;
  color: var(--ink);
}


.hx-head__body {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.hx-eyebrow {
  font: 600 11px/1 var(--font-mono);
  letter-spacing: 1.2px;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-bottom: 4px;
}


/* La miniatura de `3h`: un cuadro redondeado a la izquierda de cada fila del recibo.
   El cuadro EXISTE aunque no haya imagen —así lo dibuja el frame— porque sin él las filas
   con foto y sin foto tendrían sangrías distintas y la columna de nombres bailaría. En
   este catálogo una parte grande de los registros no trae imagen, así que el caso vacío
   es el normal y no la excepción. */
.hx-line__shot {
  width: 44px;
  height: 44px;
  flex: none;
  border-radius: var(--r-thumb);
  background: var(--skeleton-a);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}

.hx-line__img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}


/* La línea de nombres de `3g`, bajo la fecha y el recuento. Dos líneas como máximo: el
   corte por presupuesto de `history.preview` apunta a eso, y el `line-clamp` es la red
   por debajo — un nombre inesperadamente largo no debe estirar la tarjeta y descolocar la
   rejilla de la lista. */
.hx-card__names {
  margin-top: 6px;
  font: 500 13px/1.45 var(--font);
  color: var(--ink-muted);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* El «+14» en tono más apagado: es un recuento, no un nombre. */
.hx-card__rest {
  color: var(--ink-faint);
}


/* EL AVISO DE MAQUETA. Deliberadamente feo y deliberadamente arriba: su trabajo es que
   nadie confunda estos datos con los de un comprador real, y un aviso discreto no lo hace.
   No usa el acento de la marca — el naranja aquí lo haría parecer una promoción. */
.hx-mock {
  margin: 0 0 12px;
  padding: 8px 12px;
  border-radius: var(--r-control);
  background: var(--promo-bogo);
  color: var(--ink);
  font: 700 12.5px/1.4 var(--font);
}

.hx-mock[hidden] {
  display: none;
}

/* ── 3p · add items ────────────────────────────────────────────────────────
   EVERY NUMBER HERE IS MEASURED FROM `dv-opt#3p` in the imported frame, not
   inferred. Where a token already carries the frame's exact value it is used; the
   three places the frame differs from a token are marked FRAME. `docs/BV_BW_BX.md`
   lists all of it.

   Prefixed `.additems` throughout, so it can only collide with itself — this file
   has conflicted twice on appended blocks. A test asserts the prefix.

   NO `--surface-h` RESERVATION, and here it is not a judgement call: 3p has no
   persistent surface at all, and `show()` hides it. Reserving would pay for a
   surface that is not on the screen. */
.additems {
  position: relative;
  /* THE PINNED FOOT WAS NEVER RESERVED FOR. `.additems__foot` is
     `position: absolute; bottom: 0` against the SCREEN and measures 92px, and this
     body is an ordinary scroller with no bottom padding at all — so a suggestion
     list long enough to scroll put its last row underneath "Back to my list" with
     nothing to say so. This screen has no persistent surface, so `--bottom-h` here
     IS the foot: `layout.js` measures whatever is anchored to the bottom of the
     active screen, which on this one is that control and nothing else.

     Exactly one reservation, as everywhere else — the foot sets its own padding
     because it is positioned against the screen, and it must not also pay for
     itself. */
  padding-bottom: calc(var(--bottom-h, 0px) + 12px);
  /* THE FRAME'S 24px, APPLIED HERE. Every block in `dv-opt#3p` carries `padding: …
     24px …`, and MEASURED in the browser `.screen__body` on a plain `.screen`
     contributes 0px — so assuming it would supply the inset put the tiles flush to
     both edges. The pinned foot sets its own padding, because it is positioned
     against the screen rather than sitting in this flow. */
  padding-left: 24px;
  padding-right: 24px;
}

/* frame: display:flex; align-items:center; gap:14px; padding:22px 24px 0 */
.additems__head {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 22px 0 0;
}
/* frame: 44x44, flex:none, radius 50%, #F4F4F6, font 600 20px, colour #0A2540.
   FRAME: #f4f4f6 has no token — the nearest, --line #ededef, is a border colour. */
.additems__back {
  flex: none;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  /* The frame paints #F4F4F6. NOT ADOPTED: `test_the_near_misses_were_not_adopted`
     already ruled that exact value a near-miss of `--chip-bg` (#f1f1f3), on the
     grounds that a near-miss beside a measured token is how a parallel set starts.
     3px summed across channels. */
  background: var(--chip-bg);
  color: var(--ink);
  font: 600 20px/1 var(--font);
  cursor: pointer;
}
.additems__headtext { flex: 1; min-width: 0; }
/* frame: font 800 23px/1.2, colour #0A2540, letter-spacing -.8px */
.additems__title {
  margin: 0;
  font: 800 23px/1.2 var(--font);
  color: var(--ink);
  letter-spacing: -0.8px;
}
/* frame: font 500 14.5px, colour #68737d, margin-top 2px */
.additems__count {
  margin: 2px 0 0;
  font: 500 14.5px/1.3 var(--font);
  color: var(--ink-muted);
}

/* frame: wrapper padding 18px 24px 0; field height 60px, radius 20px, #fff,
   1px solid #EDEDEF, shadow 0 3px 14px rgba(0,0,0,.06), gap 12px, padding 0 18px.
   FRAME: the shadow alpha is .06; --shadow-card is .05, so the literal is kept. */
.additems__searchwrap {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  height: 60px;
  margin: 18px 0 0;
  padding: 0 18px;
  border: 1px solid var(--line);
  border-radius: 20px;
  background: var(--paper);
  /* The frame's alpha is .06; `--shadow-card` is 0 3px 14px rgba(0,0,0,.05) — the
     same geometry. Token, for the near-miss reason above. */
  box-shadow: var(--shadow-card);
}
/* frame: 19x19, 2.5px solid #9AA4AF, radius 50%. A RING WITH NO HANDLE — that is
   how the design draws it, and it is reproduced rather than corrected. */
.additems__glass {
  flex: none;
  width: 19px;
  height: 19px;
  border: 2.5px solid var(--ink-faint);
  border-radius: 50%;
}
/* frame: placeholder font 600 17px, colour #9AA4AF */
.additems__search {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  font: 600 17px/1 var(--font);
  color: var(--ink);
  /* 16px floor or iOS zooms the page on focus; 17px is the frame's own size. */
}
.additems__search::placeholder { color: var(--ink-faint); font-weight: 600; }
.additems__search:focus { outline: none; }
.additems__search::-webkit-search-cancel-button { -webkit-appearance: none; }

/* frame: display:flex; gap:12px; padding:12px 24px 0 */
.additems__tiles { display: flex; gap: 12px; margin: 12px 0 0; }
/* frame: flex:1; height:104px; radius 20px; 1px solid #EDEDEF;
   shadow 0 2px 10px rgba(0,0,0,.04) == --shadow-tile; column; justify-content:center;
   gap:9px; padding:0 16px */
.additems__tile {
  flex: 1;
  min-width: 0;
  height: 104px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 9px;
  padding: 0 16px;
  border: 1px solid var(--line);
  border-radius: 20px;
  background: var(--paper);
  box-shadow: var(--shadow-tile);
  text-align: left;
  cursor: pointer;
}
/* frame: 38x38 circle, the orb's radial gradient, shadow 0 4px 12px rgba(255,90,0,.28) */
.additems__tileicon--orb {
  position: relative;
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  overflow: hidden;
  background: radial-gradient(circle at 50% 62%, #ff4d00 0%, #ff7a1a 26%, #ffb25e 48%,
                              #ffdca8 66%, #fff3e2 82%, #fff 100%);
  box-shadow: 0 4px 12px rgba(255, 90, 0, 0.28);
}
/* frame: 38x38, radius 12px, #FFF1E7 == --accent-tint, holding a 19x19 square with
   2.5px #FF5A00 borders and a 6px bottom border — a tray, drawn as a thick underline. */
.additems__tileicon--shot {
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 12px;
  background: var(--accent-tint);
  display: flex;
  align-items: center;
  justify-content: center;
}
.additems__tileicon--shot::before {
  content: "";
  width: 19px;
  height: 19px;
  box-sizing: border-box;
  border: 2.5px solid var(--accent);
  border-bottom-width: 6px;
  border-radius: 5px;
}
/* frame: label font 700 17px #0A2540; sub font 500 13px #68737d, margin-top 1px */
.additems__tilelabel { font: 700 17px/1.2 var(--font); color: var(--ink); }
.additems__tilesub { margin-top: 1px; font: 500 13px/1.3 var(--font); color: var(--ink-muted); }

/* frame: padding 24px 24px 0; title font 800 18px #0A2540 letter-spacing -.4px;
   sub font 500 14px #68737d margin-top 2px */
.additems__sugghead { margin: 24px 0 0; }
.additems__suggtitle {
  font: 800 18px/1.25 var(--font);
  color: var(--ink);
  letter-spacing: -0.4px;
}
.additems__suggsub { margin-top: 2px; font: 500 14px/1.35 var(--font); color: var(--ink-muted); }

/* frame: height 266px; overflow hidden; padding 12px 24px 0; column; gap 8px;
   -webkit-mask-image: linear-gradient(180deg,#000 80%,transparent).
   ADAPTED: the frame's `overflow:hidden` is a static mock. A real list has to be
   reachable, so it scrolls — the mask stays, because the fade is what tells the
   shopper there is more. */
.additems__suggs {
  margin: 12px 0 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  /* 266 IS THE FRAME'S NUMBER AND IT IS AN 844 NUMBER. MEASURED at 393x646 with
     the real screen: everything above this region (the head, the search field, the
     four route tiles and the section heading) occupies 344px and the pinned foot
     92, so there are 210px left — and a full five-row list would claim 266 and put
     two rows under the foot.

     `min()` keeps the frame's cap where there is room for it and hands the region
     the band's own arithmetic where there is not: 436 is that 344 of fixed content
     plus the 92px foot, so this computes 266 at 844 (identical to the frame),
     251 at 687 and 210 at 646. The region's own `overflow-y: auto` is what makes
     the difference reachable — the note above is still true, a real list has to be
     scrollable rather than merely present.

     NOT MEASURED WITH A FULL LIST: this corpus returned two suggestions, so the
     binding case is arithmetic over the real fixed costs rather than an observed
     overflow. The 266 it replaces was itself taken from a frame drawn with five. */
  max-height: min(266px, calc(var(--band-h, 844px) - 436px));
  overflow-y: auto;
}
/* THE FADE ONLY WHEN THERE IS SOMETHING TO FADE. The frame draws the mask
   unconditionally because its container is FULL — five rows overflowing 266px. With
   three rows nothing overflows, and an unconditional mask greys out the last real
   suggestion for no reason: MEASURED scrollHeight 203 == clientHeight 203 with the
   mask still applied, and the third row visibly washed out. The class is set after
   painting, from the measured overflow. */
.additems__suggs--more {
  -webkit-mask-image: linear-gradient(180deg, #000 80%, transparent);
  mask-image: linear-gradient(180deg, #000 80%, transparent);
}
/* frame: row flex; align-items:center; gap:12px; #fff; 1px solid #EDEDEF;
   radius 18px; shadow 0 2px 8px rgba(0,0,0,.04); padding 10px 14px */
.additems__sugg {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 14px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: var(--paper);
  /* Frame: 0 2px 8px rgba(0,0,0,.04). `--shadow-tile` is 0 2px 10px at the same
     alpha — 2px of blur apart, so the token rather than a fourth shadow. */
  box-shadow: var(--shadow-tile);
}
.additems__suggmain { flex: 1; min-width: 0; }
/* frame: name font 700 17px #0A2540 */
.additems__suggname { font: 700 17px/1.25 var(--font); color: var(--ink); }
/* frame: reason font 500 13.5px #9AA4AF margin-top 1px, rendered "<why> · <where>" */
.additems__suggwhy {
  margin-top: 1px;
  font: 500 13.5px/1.35 var(--font);
  color: var(--ink-faint);
}
/* frame: 36x36 circle, #FFF1E7 == --accent-tint, 1px solid #FFD3B5, font 600 20px,
   colour #FF8A3D.

   TWO DELIBERATE DEVIATIONS, both on existing rulings rather than taste:

   - the border takes `--accent-line` (#ffe3d0) instead of the frame's #FFD3B5. Near
     miss, same rule as the back button's grey.
   - the GLYPH takes `--accent-ink` instead of the frame's #FF8A3D. #FF8A3D on
     `--accent-tint` measures about 2.2:1, and `--accent-ink` exists for exactly this
     — its comment records 4.65:1 on the same family of tinted ground, where
     `--accent` itself measured 2.95:1 and was rejected. This is the same split
     `test_the_bogo_badge_is_ink_on_the_yellow_not_white` already made: the colour is
     the brand's decision, the TEXT colour is a legibility one, and §14 is written for
     retail lighting at arm's length. A `+` is text. */
.additems__suggadd {
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--accent-tint);
  border: 1px solid var(--accent-line);
  color: var(--accent-ink);
  font: 600 20px/1 var(--font);
  cursor: pointer;
}
/* NOT IN THE FRAME. The frame draws five suggestions and no empty state; §BW requires
   one, because an empty result is a valid answer and must read as an invitation. */
.additems__suggnote {
  margin: 12px 0 0;
  font: 500 14px/1.45 var(--font);
  color: var(--ink-muted);
}

/* frame: position:absolute; left/right 0; bottom 0; padding 18px 20px 34px;
   background linear-gradient(180deg,rgba(255,255,255,0),#fff 32%) */
.additems__foot {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* The frame's own 18px/20px/34px. It is positioned against the SCREEN, so it does
     not sit inside `.additems`'s 24px inset and sets its whole padding itself — the
     reserve-it-once rule, applied to horizontal space. */
  padding: 18px 20px max(18px, env(safe-area-inset-bottom, 0px));
  background: linear-gradient(180deg, rgba(255, 255, 255, 0), var(--paper) 32%);
}
/* frame: height 56px; #0A0A0C == --ink-black; radius 18px; centred; gap 8px;
   font 700 16px; colour #fff; content "‹" then "Back to my list" */
.additems__backtolist {
  width: 100%;
  height: 56px;
  border: 0;
  border-radius: 18px;
  background: var(--ink-black);
  color: #fff;
  font: 700 16px/1 var(--font);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  cursor: pointer;
}
/* frame: the glyph is 18px against the label's 16px */
.additems__backglyph { font-size: 18px; }

/* §CA1. The loading row is the real row's shell with shimmer bars inside, so the
   section keeps its height when the content lands. `pointer-events:none` for the same
   reason `.card--skeleton` has it: a placeholder must not look tappable. */
.additems__sugg--loading { pointer-events: none; }

/* §CD. The added state, and its treatment is BORROWED rather than invented: frame 3p
   draws no added state at all, so this takes frame 2k's — a filled circle with a white
   check, `#18AB0A`, which is `--good` EXACTLY (not a near miss, checked) — at 3p's own
   36px geometry. 2k uses it for the same meaning, "this landed".

   `!important` is absent on purpose: this selector is one class more specific than
   `.additems__suggadd`, so it wins on specificity like any other state rule. */
.additems__suggadd--added {
  background: var(--good);
  border-color: var(--good);
  color: #fff;
  font: 700 20px/1 var(--font);
  /* NOT TAPPABLE AGAIN. `disabled` already blocks the handler; this stops the cursor
     and the hover from implying otherwise. */
  cursor: default;
}

/* ══════════════════════════════════════════════════════════════════════════
 * THE FIVE VOICE SURFACES — designs 11a, 11b, 11c+11g, 11d+11e, 11f
 *
 * The full-page `data-screen="voice"` is retired as an ENTRY POINT. It is kept,
 * unchanged, as the surface a browser with no SpeechRecognition constructor
 * lands on, and as the host of the microphone-recovery panel — see
 * `voiceSurfaceFor` in main.js for the routing.
 *
 * NOTHING BELOW TOUCHES THE LIFECYCLE. Every rule here is presentation: the
 * six-second window, the restart mechanism and the abandon message are
 * `voice.js`'s and are not reachable from a stylesheet.
 *
 * `#app[data-speaking]` is the one switch, set on the ROOT rather than on a
 * screen, because two of the five surfaces have to move `.surface` — which is a
 * sibling of every `.screen`, not a child of one.
 * ══════════════════════════════════════════════════════════════════════════ */

/* ---------------------------------------------------- the tall bars (11a/11b/11d)
 *
 * Eight bars, 7x44, and the STOP CONTROL LIVES INSIDE THE ROW — the design puts
 * it there (`margin-left:14px` after the eighth bar), not in a footer of its own.
 *
 * Driven by `onLevel`, NOT by the design's `coopBar` keyframe. The frames animate
 * these on a fixed 0.62s loop because a static mock has no microphone; the brief's
 * ruling is that amplitude follows the voice and is flat when nothing is heard, so
 * a CSS loop would be a bar that lies about hearing something. The keyframe's own
 * range is what the JS reproduces: scaleY(.35) to scaleY(1).
 *
 * `transform`, never `height`: the 44px box is constant so the row cannot reflow,
 * and scaling is off the main thread. */
.vs-bars {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 7px;
  height: 44px;
}

.vs-bars__bar {
  width: 7px;
  height: 44px;
  flex: none;
  background: var(--ink);
  border-radius: 4px;
  transform: scaleY(0.35);
  transform-origin: center;
  transition: transform 0.09s linear;
}

.vs-bars__stop {
  margin-left: 14px;
  width: 40px;
  height: 40px;
  flex: none;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
}

/* AN UPWARD ARROW, NOT A STOP SQUARE (product ruling).
 *
 * WHAT THE CONTROL DOES is send: `vs-stop` ends the capture AND submits what was
 * heard, so a square — the universal "stop and discard" — describes the mechanism
 * and misdescribes the outcome. The typing modal's own submit is `&#8593;` at
 * `font: 600 21px` on a filled round button (`.vs-composer__send`), and a shopper
 * who types once and speaks once should meet the same glyph for the same act.
 *
 * THE GLYPH AND NOT AN SVG, deliberately: the composer's control is the thing
 * being matched and it is that character, so drawing a second arrow here is a
 * second arrow to keep in agreement with the first.
 *
 * Still a pseudo-element rather than a child, for the reason the square was: the
 * button has no inner node that can swallow the tap. 19px against the composer's
 * 21px, in proportion to a 40px button against a 44px one.
 *
 * `aria-label` IS UNCHANGED, on both instances, and that is not an oversight — the
 * label is a separate ruling from the icon and this pass does not touch copy. */
.vs-bars__stop::before {
  content: '\2191';
  color: #fff;
  font: 600 19px/1 var(--font);
}

/* The orange caret that trails every live transcript in the turn. Its size differs
 * per surface (11a 3x21, 11b/11d 3x20, 11g 3x22, 11f 2.5x19), so the box is set
 * where it is used and only the paint lives here. */
.vs-caret {
  display: inline-block;
  width: 3px;
  height: 21px;
  background: var(--accent);
  vertical-align: -3px;
  margin-left: 3px;
  animation: vs-caret 1s step-end infinite;
}

@keyframes vs-caret {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}

/* ══════════════════════════ 11a — HOME, IN PLACE ══════════════════════════
 *
 * Tapping the orb does not navigate. The idle chrome fades, the lower cards and
 * the nav bar slide DOWN, and the listening surface appears beneath the orb.
 * Six seconds of silence reverses exactly this, because the reverse of a
 * transition is the absence of the attribute that triggered it.
 *
 * Every duration, easing and delay below is TAKEN from the frames' `transition`
 * declarations, which the designer resolved even where the VALUES are unbound.
 * Only `--vs-slide` is inferred; tokens.css says why. */
/* ZERO-HEIGHT AND IN FLOW, which is the whole trick. The bars and the transcript
 * hang out of this box on `position:absolute`, so the listening surface costs the
 * idle layout nothing and `.home-cards` does not move to make room for something
 * that is not on screen yet. A full-screen overlay would have worked too and
 * would have had to re-derive the orb's position; this inherits it. */
.vs-home {
  position: relative;
  height: 0;
  z-index: 3;
}

.vs-home__label,
.vs-home__bars,
.vs-home__transcript {
  pointer-events: none;
}

#app[data-speaking='home'] .vs-home__bars {
  pointer-events: auto;
}

/* The headline block hosts "Listening…" on top of the H1.
 *
 * The padding used to be an inline style on the element (`padding: 14px 24px 0`)
 * and is here now for one reason: it has to be band-scoped, and an inline style
 * cannot be. The 24px is load-bearing and unchanged — `.display`'s note records
 * that at 30px of side padding the longest string in the typing sequence is three
 * lines even at the reduced type size, so the padding is half of that fix. Only the
 * TOP value is compressed on a short band. */
.home-head {
  position: relative;
  padding: 14px 24px 0;
}

/* The orb's row. Also an inline style until now, for the same reason: the
 * `margin-top` is one of the things a short band has to spend. */
.home-speak-row {
  display: flex;
  justify-content: center;
  margin-top: 28px;
}

.vs-home__label {
  opacity: 0;
  transition: opacity 0.3s ease 0.12s;
}

#app[data-speaking='home'] .vs-home__label {
  opacity: 1;
}

/* "Listening…" sits exactly where the typing headline was — the design overlays
 * the two in one 70px block and crossfades them, so the block's height is
 * reserved once and neither state can reflow the other. */
/* `top:6px` inside a block already padded 14px, matching the frame's 26px against
 * the headline's own 20px — the two are 6px apart there and 6px apart here. That
 * is the 844 geometry, where `.home-head` is top-padded and the headline sits at
 * the top of its block. The browser bands centre it instead — see the rule below. */
.vs-home__label {
  position: absolute;
  left: 30px;
  right: 30px;
  top: 6px;
  text-align: center;
  font: 600 16px var(--font);
  color: var(--ink-muted);
  letter-spacing: 0.2px;
}

/* The bars sit 10px below the orb button, as `top:206px` does against the frame's
 * 196px-tall orb container. */
.vs-home__bars {
  position: absolute;
  left: 0;
  right: 0;
  top: 10px;
  transform: translateY(10px) scaleY(0.4);
  transition:
    opacity 0.3s ease 0.16s,
    transform 0.38s cubic-bezier(0.2, 0.9, 0.25, 1) 0.16s;
  opacity: 0;
}

#app[data-speaking='home'] .vs-home__bars {
  opacity: 1;
  transform: translateY(0) scaleY(1);
}

/* 76px below the bars' lower edge (10 + 44 + 76), which is the gap the frame
 * holds between them.
 *
 * BOUNDED, AND THE BOUND IS NOT DECORATION. MEASURED at 375x667 with a real
 * 118-character utterance: five lines, 158px tall, running to y=681 — past the
 * 667px viewport and 122px THROUGH the "Type instead" control beneath it. The
 * frame shows a one-line example and answers nothing about the fifth line, and a
 * one-line fixture in the browser would have reported this as clean. `max-height`
 * comes from `vsFitHomeTranscript`, which measures the real gap to the real
 * control, so it cannot drift when the layout above the orb changes.
 *
 * `overflow: hidden` with the node scrolled to its end shows the TAIL. That is
 * the right end for a live transcript — the shopper is watching the words they
 * just said — and it is the opposite of what `-webkit-line-clamp` would do. */
.vs-home__transcript {
  position: absolute;
  left: 34px;
  right: 34px;
  top: var(--vs-tx-top, 130px);
  max-height: var(--vs-tx-max, 189px);
  overflow: hidden;
  text-align: center;
  font: 700 24px/1.32 var(--font);
  color: var(--ink);
  text-wrap: pretty;
  transition: opacity 0.3s ease 0.22s;
  opacity: 0;
}

/* THE FRAMES ARE AN 844 DESIGN. At 667 the band between the bars' lower edge and
 * the "Type instead" control is 112px in total, so the frame's 76px gap would
 * leave room for one line and the bound above would do all the work. The gap and
 * the type both come down instead — INFERRED, because no frame in the turn draws
 * a short viewport, and stated as inferred rather than presented as the design's.
 *
 * STILL NEEDED, AND NO LONGER A MEDIA QUERY. This was the file's only
 * short-viewport query and the rule it carries is still the right one — the
 * transcript genuinely has to come down and lose two points on a short band. What
 * changed is what "short" is measured against. `@media (max-height: 750px)` asks
 * about the VIEWPORT; `layout.js` chooses the band from `#app`'s own `100dvh`
 * height, and the two can disagree. MEASURED case: a 760pt viewport fails
 * `max-height: 750px` and is 73px from the 687 preset against 84 from 844, so the
 * band is 687 while the media query says "not short" — the home screen compresses
 * and the transcript does not. One authority for "which band is this", or the two
 * drift by construction. */
/* "Listening…" MUST SIT WHERE THE HEADLINE SITS, and 13b moved the headline.
 *
 * The crossfade is the point of this pair: the design overlays the two in one
 * block and swaps them, so the block's height is reserved once and neither state
 * reflows the other. `top: 6px` put the label at the TOP of that block, which was
 * right while `.home-head` was top-padded and the headline sat there too. 13b
 * makes the block a 66pt box that CENTRES its content, so the two came apart —
 * MEASURED at 646: the headline centred at 62.8-95.3 and the label at 52-72, an
 * overlap of nine points and two different baselines mid-swap.
 *
 * Centred here as well, so they occupy the same place again. Stated as a
 * band-scoped rule rather than by changing the base, because 844 keeps 13b's
 * predecessor and the 6px there is still correct against it. */
:root[data-band='646'] [data-screen='home'] .vs-home__label,
:root[data-band='687'] [data-screen='home'] .vs-home__label {
  top: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

:root[data-band='646'] .vs-home,
:root[data-band='687'] .vs-home {
  --vs-tx-top: 62px;
}

:root[data-band='646'] .vs-home__transcript,
:root[data-band='687'] .vs-home__transcript {
  font-size: 20px;
  line-height: 1.3;
}

#app[data-speaking='home'] .vs-home__transcript {
  opacity: 1;
}

/* "Type instead". `bottom:52px` is the frame's, and it is measured from the
 * bottom of a 844px mock whose home indicator occupies the last 30px — so on a
 * real handset the same control has to clear `env(safe-area-inset-bottom)`
 * instead, which is 0 in a desktop browser and ~34px on the phone. The `max()`
 * keeps the browser at the design's 52px and lifts the handset to 34+18=52 —
 * the same number from the other direction, which is why it is `max` and not a
 * sum: adding them would put it at 86px and float the control. */
.vs-home__type {
  position: absolute;
  left: 0;
  right: 0;
  bottom: max(52px, calc(env(safe-area-inset-bottom, 0px) + 18px));
  display: flex;
  justify-content: center;
  opacity: 0;
  transform: translateY(46px);
  transition:
    opacity 0.3s ease 0.2s,
    transform 0.4s cubic-bezier(0.2, 0.9, 0.25, 1) 0.2s;
}

#app[data-speaking='home'] .vs-home__type {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.vs-typebtn {
  display: flex;
  align-items: center;
  gap: 11px;
  height: 56px;
  padding: 0 24px;
  border-radius: 28px;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 6px 20px rgba(10, 37, 64, 0.09);
  cursor: pointer;
  font: 700 16px var(--font);
  color: var(--ink);
}

/* The keyboard glyph the design draws on every "Type instead": a 28x20 outline
 * with a single 13x2 bar in it. Two elements, no icon font. */
.vs-key {
  width: 28px;
  height: 20px;
  flex: none;
  border: 2.2px solid var(--ink-muted);
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.vs-key::after {
  content: '';
  width: 13px;
  height: 2px;
  background: var(--ink-muted);
  border-radius: 1px;
}

/* The idle chrome that leaves. `.home-cards` and `.surface` SLIDE; the headline,
 * the arc and the menu only fade, because the frames give the first two a
 * transform and the last three none. */
#app[data-speaking='home'] [data-screen='home'] .display,
#app[data-speaking='home'] [data-screen='home'] .home-speak__arc {
  opacity: 0;
}

#app[data-speaking='home'] [data-screen='home'] .home-menu {
  opacity: 0;
  pointer-events: none;
}

#app[data-speaking='home'] .home-cards {
  opacity: 0;
  transform: translateY(var(--vs-slide));
  pointer-events: none;
}

#app[data-speaking='home'] .surface {
  opacity: 0;
  transform: translateY(var(--vs-slide));
  pointer-events: none;
}

/* THE TRANSITIONS LIVE ON THE RESTING ELEMENTS so they run in BOTH directions.
 * One declared only under `[data-speaking]` animates the way IN and snaps back on
 * the way out, which is the six-second reversal going missing.
 *
 * `.home-cards` and `.surface` carry theirs in their own rules further up rather
 * than in a second block here: a component whose styles are split across two
 * top-level rules is one a later edit changes in the wrong half. */
[data-screen='home'] .display {
  transition:
    opacity 0.26s ease,
    transform 0.3s ease;
}

[data-screen='home'] .home-speak__arc {
  transition:
    opacity 0.22s ease,
    transform 0.3s ease;
}

[data-screen='home'] .home-menu {
  transition: opacity 0.28s ease;
}


/* ═══════════════════ 11c + 11g — THE NAV BAR BECOMES THE RECORDER ═══════════
 *
 * On any screen carrying the persistent surface, tapping the small orb does not
 * navigate: the bar's own contents are replaced in place by the recorder.
 *
 * THE SURFACE IS NOT RE-RESERVED HERE, and that is the whole reason the recorder
 * lives INSIDE `.surface` rather than beside it. `.screen--with-surface
 * .screen__body` already reserves `--surface-h` once; a recorder pinned to the
 * bottom on its own would have reserved it a second time, which is the mistake
 * three earlier blocks in this file carry a comment about.
 *
 * THE BOTTOM ARITHMETIC, both values, because `env(safe-area-inset-bottom)` reads
 * 0 in a desktop browser and ~34px on the handset:
 *
 *     .surface padding-bottom  = 20 + env
 *     recorder height          = 64          (the resting bar is 60 — see below)
 *     .surface padding-top     = 18
 *     ────────────────────────────────────
 *     total surface height     = 102 + env   → 102px browser, 136px handset
 *
 * The resting bar is 98 + env, so the recorder is FOUR PIXELS taller than the
 * control it replaces and nothing else moves. Both numbers are under
 * `--surface-h` (104px) in the browser; on the handset both exceed it, by 32px
 * resting and 36px recording. That overflow is PRE-EXISTING — it is the resting
 * bar's, not the recorder's — and the recorder adds 4px to it. It is called out
 * here rather than silently inherited. */
.vs-rec {
  display: none;
  flex: 1;
  min-width: 0;
  align-items: center;
  gap: 14px;
  height: 64px;
  border-radius: 32px;
  background: var(--rec-bg);
  border: 1px solid var(--rec-line);
  box-shadow: 0 6px 22px rgba(10, 37, 64, 0.1);
  padding: 0 8px 0 18px;
  box-sizing: border-box;
}

#app[data-speaking='bar'] .vs-rec {
  display: flex;
}

/* The resting controls go away rather than being covered: a scan button still
 * under the recorder is still tappable, and `visibility` would leave it holding
 * its width in the flex row. */
#app[data-speaking='bar'] .surface__btn,
#app[data-speaking='bar'] .surface__ask {
  display: none;
}

.vs-rec__x {
  width: 26px;
  height: 26px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  font: 600 22px var(--font);
  color: var(--rec-x);
  cursor: pointer;
  border: 0;
  background: none;
  padding: 0;
}

/* THE SCROLLING WAVEFORM. Thirty slots; a new sample enters at the RIGHT and
 * pushes the row left. The frame is a recording at 0:01 and shows exactly that
 * state: twenty-two unwritten slots at 4px in --rec-idle on the left, then the
 * eight samples it has heard in --rec-live on the right. So the two colours are
 * "this slot holds a sample" and "this slot does not" — not old versus new.
 *
 * `overflow:hidden` is the design's and it matters: the row is built to a fixed
 * 30 and never grows, so nothing here can push the elapsed time or the stop
 * control out of the bar. */
.vs-rec__wave {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 3px;
  height: 28px;
  overflow: hidden;
}

.vs-rec__bar {
  width: 3.5px;
  height: 4px;
  flex: none;
  border-radius: 2px;
  background: var(--rec-idle);
  transition: height 0.08s linear;
}

.vs-rec__bar[data-live='true'] {
  background: var(--rec-live);
}

.vs-rec__time {
  font: 500 15px var(--font);
  color: var(--ink-faint);
  flex: none;
  font-variant-numeric: tabular-nums;
}

/* The stop control is the resting mic button, in place and at the same 48px:
 * same gradient, same shadow, a white square added. The design draws them
 * identically, so this reads as the mic becoming a stop rather than one control
 * being swapped for another. */
.vs-rec__stop {
  width: 48px;
  height: 48px;
  flex: none;
  border: 0;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 60%, var(--accent-hot), var(--accent-warm));
  box-shadow: 0 4px 14px rgba(255, 90, 0, 0.35);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
}

/* THE SAME ARROW, for the same reason — see `.vs-bars__stop::before`. This is the
 * recorder bar's stop, beside the waveform in the persistent surface, and it fires
 * exactly the same `vs-stop` action: it sends. Two stop controls with two different
 * meanings for one action is the inconsistency the ruling is about, so both change
 * or neither does. 22px against the composer's 21px, on a 48px button. */
.vs-rec__stop::before {
  content: '\2191';
  color: #fff;
  font: 600 22px/1 var(--font);
}

/* ── 11g: the transcript rises above the bar once there are words ────────────
 *
 * RULED: 11c is the resting state and 11g is what it BECOMES. Both nodes carry
 * `data-shown`, set together the first time `onPartial` delivers a non-empty
 * string and cleared only when capture ends — so the glass never appears empty
 * while the shopper is still thinking, and it does not flicker back out between
 * words.
 *
 * The panel deliberately overhangs the viewport on three sides
 * (left/right -14%, bottom -70px) so its radial mask fades out past the edges
 * instead of ending on them. `backdrop-filter` is what blurs the page behind it;
 * the gradient underneath is what keeps text legible where the blur alone would
 * not be. */
.vs-glass {
  position: absolute;
  left: -14%;
  right: -14%;
  bottom: -70px;
  height: 520px;
  z-index: 5;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.28s ease;
  backdrop-filter: blur(80px) saturate(140%) brightness(1.16);
  -webkit-backdrop-filter: blur(80px) saturate(140%) brightness(1.16);
  background: linear-gradient(
    180deg,
    rgba(255, 252, 248, 0) 0%,
    rgba(255, 252, 248, 0.7) 20%,
    rgba(255, 252, 248, 1) 38%,
    rgba(255, 252, 248, 1) 100%
  );
  -webkit-mask-image: radial-gradient(
    125% 104% at 50% 108%,
    #000 74%,
    rgba(0, 0, 0, 0.92) 82%,
    rgba(0, 0, 0, 0.55) 90%,
    rgba(0, 0, 0, 0.18) 96%,
    transparent 100%
  );
  mask-image: radial-gradient(
    125% 104% at 50% 108%,
    #000 74%,
    rgba(0, 0, 0, 0.92) 82%,
    rgba(0, 0, 0, 0.55) 90%,
    rgba(0, 0, 0, 0.18) 96%,
    transparent 100%
  );
}

/* THE OFFSET IS DERIVED FROM THE BAR, NOT COPIED FROM THE FRAME. 11g puts the
 * transcript at `bottom:132px` above a mock whose bar top edge sits at 94px
 * (30px of mock bottom padding + a 64px bar) — a 38px gap. This surface's bar
 * top edge is at `20 + env + 64 = 84 + env`, so the same gap is:
 *
 *     84 + env + 38 = 122 + env   → 122px browser, 156px handset
 *
 * Copying 132px verbatim would have put the transcript 24px INSIDE the recorder
 * on the handset. */
.vs-bartx {
  position: absolute;
  left: 26px;
  right: 26px;
  bottom: calc(122px + env(safe-area-inset-bottom, 0px));
  z-index: 6;
  text-align: center;
  font: 700 25px/1.36 var(--font);
  color: var(--ink);
  text-wrap: pretty;
  text-shadow:
    0 1px 12px rgba(255, 255, 255, 0.95),
    0 0 26px rgba(255, 255, 255, 0.85);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.28s ease;
}

.vs-glass[data-shown='true'],
.vs-bartx[data-shown='true'] {
  opacity: 1;
}

.vs-bartx .vs-caret {
  height: 22px;
}

/* ═══════════ 11b / 11d / 11e — ONE MODAL SHELL, THREE CONTENTS ═════════════
 *
 * A MODAL, NOT A PAGE, in all three: the screen underneath stays exactly where
 * it was, so dismissing returns to it with no navigation and nothing to restore.
 *
 * THEY ARE VERTICALLY CENTRED (`top:50%`), which the frames are explicit about
 * and which decides the safe-area question the brief raises: a centred panel has
 * no bottom edge to collide with the home indicator, so `env(safe-area-inset-
 * bottom)` does not enter its arithmetic at all. What it does need is to FIT —
 * the shortest supported viewport is 667px — so the panel caps its own height
 * and its transcript scrolls rather than the panel growing off-screen. */
/* FIXED, NOT ABSOLUTE, AND THE DIFFERENCE IS THE WHITE BAND (DR3).
 *
 * `position: absolute; inset: 0` is bounded by `#app`, and `#app` is not always
 * the viewport: it is `height: 100dvh` with a `max-height: 844px` that now applies
 * only inside the desktop-preview query. Anywhere the two differ — a desktop
 * window taller than 844 — `body` shows through, and body is `--paper`, i.e.
 * WHITE, which its own comment in this file predicts: "whatever the BODY is
 * painted with shows through wherever `#app` does not reach". The class of failure
 * is narrower than it was (no handset can hit it any more, because no handset gets
 * the cap) and it is not gone.
 *
 * `fixed` is measured against the viewport instead, so the backdrop is full-bleed
 * by construction rather than by `#app` happening to be the right size. It is
 * safe here because `#app` creates no containing block for fixed descendants — no
 * transform, filter, will-change or contain on it or on `body`.
 *
 * REPORTED HONESTLY: I could not reproduce the band in the preview browser, where
 * `env(safe-area-inset-*)` is 0 and `#app` measured exactly the viewport at both
 * 844 and 932. This removes the CLASS of failure rather than a reproduction, and
 * it is the handset that confirms it. */
.vs-modal {
  position: absolute;
  inset: 0;
  z-index: 40;
  display: none;
}

.vs-modal[data-open='true'] {
  display: block;
}

/* A button, not a div: dismissing by tapping outside is an action and needs a
 * name and a tab stop. Same reasoning as `.home-drawer__scrim`. */
.vs-modal__scrim {
  /* FIXED, WHILE THE PANEL ABOVE STAYS ABSOLUTE, and the split is the point.
     The BACKDROP must cover the viewport — `#app` is `height: 100dvh` with the
     844px cap now confined to the desktop-preview query, so anywhere the two
     differ the white `body` shows through, exactly as body's own comment in this
     file predicts. The PANEL must stay bounded by `#app`, or on a window wider
     than 390px it would stretch across the whole screen.
     Making the whole modal fixed fixed the band and broke the width. */
  position: fixed;
  inset: 0;
  border: 0;
  padding: 0;
  background: rgba(10, 37, 64, 0.42);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  cursor: pointer;
}

.vs-modal__panel {
  position: absolute;
  left: 26px;
  right: 26px;
  top: 50%;
  transform: translateY(-50%);
  background: var(--paper);
  border-radius: 32px;
  box-shadow: 0 24px 60px rgba(10, 37, 64, 0.28);
  padding: 34px 26px 30px;
  box-sizing: border-box;
  max-height: calc(100% - 48px);
  overflow-y: auto;
}

/* 11d and 11e each trim the frame's padding a little; the shell is otherwise
 * identical, which is why they are modifiers rather than three panels. */
.vs-modal__panel--say {
  padding: 34px 26px 26px;
}

/* ══════════ 22a — THE LIST MODAL RIDES THE KEYBOARD, IT DOES NOT CENTRE ══════
 *
 * THE CENTRING IS THE BUG, and both reported symptoms are the same bug. The shell
 * is `top: 50%; transform: translateY(-50%)` — centred in the CONTENT BAND — and
 * the band is what the keyboard and Chrome's permission bar both eat into:
 *
 *   Add items sliced by the bottom  the band shrinks from below when the keyboard
 *                                   opens; a centred panel keeps half the loss and
 *                                   its last child crosses the keyboard's top edge.
 *   the banner covering the title   the band shrinks from ABOVE by ~45pt when
 *                                   Chrome asks for the microphone — which is the
 *                                   worst case 15b1 draws, 302 of the 347 — and a
 *                                   centred panel rides UP into it.
 *
 * Anchoring the card to the keyboard fixes both at once and needs no knowledge of
 * the banner at all: the card sits 10pt above the autofill bar whatever happens
 * above it, so a band that loses 45pt from the top moves the card not at all.
 * `--vs-kb` is the OCCLUDED height from `visualViewport` — keyboard plus accessory
 * bar together — so "+10" is 22a's "10pt above the autofill bar" exactly, and the
 * frame's mock 344/48 appear nowhere.
 *
 * `env(safe-area-inset-bottom)` beside it, not instead: iOS reports 0 while the
 * keyboard is up and restores it when the keyboard closes, so the sum is right in
 * both states — the same reasoning `.vs-composer` carries.
 *
 * THE MAX-HEIGHT IS THE BAND MINUS THE KEYBOARD, so a grown input (15b2) stops at
 * the top of the visible band instead of sliding under the banner. The card grows
 * UPWARD from a fixed bottom, which is what keeps the button and the tap targets
 * still relative to the keyboard while the input takes a fourth line. */
.vs-modal[data-vs-modal='type'] .vs-modal__panel {
  top: auto;
  transform: none;
  left: 14px;
  right: 14px;
  bottom: calc(var(--vs-kb, 0px) + 10px + env(safe-area-inset-bottom, 0px));
  border-radius: 26px;
  padding: 16px 18px 12px;
  max-height: calc(100% - var(--vs-kb, 0px) - 20px);
}

/* 32px at 12/12, per 22a — the say-mode panel keeps its own larger control. */
.vs-modal[data-vs-modal='type'] .vs-modal__x {
  right: 12px;
  top: 12px;
  width: 32px;
  height: 32px;
}

.vs-modal__x {
  position: absolute;
  right: 18px;
  top: 18px;
  width: 34px;
  height: 34px;
  border: 0;
  border-radius: 50%;
  background: var(--sheet-chip);
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 17px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
  padding: 0;
}

.vs-modal__transcript {
  margin-top: 24px;
  text-align: center;
  font: 700 23px/1.34 var(--font);
  color: var(--ink);
  text-wrap: pretty;
}

.vs-modal__transcript .vs-caret {
  height: 20px;
}

/* 11b's hint. It is the only copy in the turn that tells the shopper the pause
 * is theirs to take, which is the six-second window made visible. */
.vs-modal__hint {
  margin-top: 20px;
  text-align: center;
  font: 500 14.5px var(--font);
  color: var(--ink-faint);
}

/* 11d's escape hatch, above a rule. */
.vs-modal__swap {
  margin-top: 22px;
  padding-top: 20px;
  border-top: 1px solid var(--sheet-rule);
  display: flex;
  justify-content: center;
}

.vs-modal__swap .vs-typebtn {
  height: 52px;
  padding: 0 22px;
  border-radius: 26px;
  background: var(--sheet-field);
  border: 0;
  box-shadow: none;
}

/* ── 11e: list typing, in the same panel 11d occupied ───────────────────────── */
.vs-modal__title {
  font: 800 20px var(--font);
  color: var(--ink);
  letter-spacing: -0.5px;
  /* NEVER UNDER THE ✕. The control is absolutely positioned over this row, so the
     title has to reserve its width rather than merely avoid it visually — a
     longer word would otherwise run beneath it. 22a reserves 40. */
  padding-right: 40px;
}

.vs-modal__sub {
  font: 500 13.5px var(--font);
  color: var(--ink-muted);
  margin-top: 2px;
}

/* THE COMMA HINT IS THE POINT OF THIS SCREEN. 11e is LIST typing — what is typed
 * here becomes items, so the field has to say how they are separated. The nav-bar
 * composer (11f) submits a QUESTION and carries no such hint; the two look alike
 * and mean different things, which is exactly why they are two elements. */
/* THREE LINES, GROWING UPWARD (22a / 15b2). `min-height` and not `height`: 94px
 * is 12+12 of padding around three 24px lines at 16/1.5, and a fourth line grows
 * the field — and with it the card, whose bottom is pinned to the keyboard, so the
 * growth goes UP and the button never moves relative to the shopper's thumb. */
.vs-modal__field {
  margin-top: 10px;
  min-height: 94px;
  width: 100%;
  border-radius: 18px;
  background: var(--sheet-field);
  border: 1px solid var(--line);
  padding: 12px 16px;
  box-sizing: border-box;
  font: 600 16px/1.5 var(--font);
  color: var(--ink);
  resize: none;
  display: block;
}

.vs-modal__field::placeholder {
  color: var(--ink-faint);
}

.vs-modal__actions {
  display: flex;
  gap: 10px;
  margin-top: 12px;
}

.vs-modal__submit {
  flex: 1;
  height: 50px;
  border: 0;
  border-radius: 25px;
  background: var(--ink-black);
  color: #fff;
  font: 700 16px var(--font);
  cursor: pointer;
}

/* ══════════════════ 11f — THE NAV BAR'S TYPING COMPOSER ════════════════════
 *
 * Rises above the keyboard, from the keyboard glyph or the field.
 *
 * THE FRAME'S `bottom:322px` IS A MOCK KEYBOARD, NOT A MEASUREMENT. A real
 * keyboard's height is the platform's and varies by language, by predictive
 * bar, and by hardware-keyboard attachment — so the number is read at runtime
 * from `visualViewport` into `--vs-kb` (see `trackKeyboard` in main.js) and 322
 * appears nowhere.
 *
 * `env(safe-area-inset-bottom)` is added BESIDE it rather than instead: iOS
 * reports the inset as 0 while the keyboard is up (the keyboard covers the home
 * indicator) and restores it when the keyboard goes down, so the sum is correct
 * in both states and neither term has to know about the other. */
.vs-composer {
  /* Absolute, bounded by `#app`, for the width reason in `.vs-modal__scrim`.
     Only the scrim behind it is viewport-fixed. */
  position: absolute;
  left: 14px;
  right: 14px;
  /* 10, per frame 15a — it was 14. The frame anchors this card and 22a's to the
     same 10pt above the autofill bar, which is what makes the two read as one
     component appearing in two modes rather than two panels at two heights. */
  bottom: calc(var(--vs-kb, 0px) + 10px + env(safe-area-inset-bottom, 0px));
  z-index: 45;
  background: var(--paper);
  border-radius: 26px;
  box-shadow: 0 14px 40px rgba(10, 37, 64, 0.22);
  padding: 16px 18px 14px;
  box-sizing: border-box;
  display: none;
}

.vs-composer[data-open='true'] {
  display: block;
}

/* THE COMPOSER'S OWN BACKDROP AND EXIT (DR4).
 *
 * Same paint as the voice modals' scrim — `rgba(10,37,64,.42)` over a 3px blur —
 * because it is the same gesture doing the same thing, and two dismiss surfaces
 * that look different read as two different controls. Shown and hidden by the
 * composer's own state so there is one switch, not two that can disagree. */
.vs-composer__scrim {
  /* `fixed` for the same reason `.vs-modal` is — see its comment. */
  position: fixed;
  inset: 0;
  z-index: 44;
  border: 0;
  padding: 0;
  background: rgba(10, 37, 64, 0.42);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  cursor: pointer;
  display: none;
}

#app[data-composing='true'] .vs-composer__scrim {
  display: block;
}

/* The X sits ABOVE the field rather than over it: a 32px control overlapping a
 * text field is a mis-tap waiting to happen. `.vs-modal__x` supplies the paint;
 * this only moves it.
 *
 * IT DID NOT CLEAR IT, AND THE OLD NOTE MEASURED THE WRONG GAP. 15a's landing
 * reported "kicker text clearing the ✕ by 14", which is the HORIZONTAL clearance
 * on the kicker's own row — a true statement about a different property. The ✕
 * runs from the card's top + 12 to + 44 while the field began at + 39, so
 * MEASURED at 393x687 the control lapped the field by 26.0 x 5.0px, 0.8% of it,
 * at its top-right corner. Julian found it on a real iPhone.
 *
 * The fix is on the FIELD's margin (below), not here: moving the ✕ up would take
 * it off the kicker's row, and the two are meant to share one line. */
.vs-composer__x {
  top: 12px;
  right: 12px;
  width: 32px;
  height: 32px;
}

/* THE ROW THE ✕ SHARES. `padding-right` is the reservation — the control is
 * absolutely positioned over this line, so avoiding it visually is not enough; a
 * longer word would run beneath it. 40 is the frame's. */
.vs-composer__kicker {
  font: 600 12px var(--font);
  letter-spacing: 0.6px;
  color: var(--ink-faint);
  padding-right: 40px;
}



/* THREE LINES THAT GROW — A DELIBERATE DEVIATION FROM 15a (product-owner ruling).
 *
 * 15a draws one line at `min-height: 49`. On a real iPhone that card reads as a
 * slot rather than a place to ask a question, and a question is what this field
 * takes: "what do I need for pasta carbonara for six" is two lines before the
 * shopper has finished thinking. Three lines is 22a's field, which the device
 * confirmed as correct, and the two cards are already anchored to the same 10pt
 * above the autofill bar precisely so they read as one component in two modes.
 * 3 x 24.65 of 17/1.45 inside 12+12 of padding is 98.
 *
 * THE MARGIN IS 18 AND THAT IS THE ✕'s CLEARANCE, NOT RHYTHM. The control runs
 * from the card's top + 12 to + 44; 16 of padding plus a 15pt kicker plus this
 * margin puts the field's top at + 49, clearing it by 5. Assert the clearance,
 * never the margin: if the kicker's type or the card's padding ever moves, what
 * has to stay true is that the field starts below the ✕— and at 8 it did not, by
 * 5 the other way.
 *
 * IT STILL GROWS UPWARD — the card's bottom is pinned to the keyboard, so growth
 * cannot move the send button under the shopper's thumb — BUT IT IS NOW BOUNDED.
 * `autoGrowField` sets `height = scrollHeight` with no ceiling, which was already
 * unbounded at 49 and is 49px closer to the top of the screen at 98. The cap is
 * the space the card actually has: the band, less the keyboard, less the 10pt
 * anchor, less this card's own 119pt of chrome (16 + 15 + 18 + 12 + 44 + 14),
 * less 20 so it never reaches the very top edge. Past that the textarea scrolls,
 * which is the correct behaviour for a question nobody can read in one look. */
.vs-composer__field {
  margin-top: 18px;
  width: 100%;
  min-height: 98px;
  max-height: calc(var(--band-h, 646px) - var(--vs-kb, 0px) - 10px - 119px - 20px);
  overflow-y: auto;
  border-radius: 18px;
  background: var(--sheet-field);
  border: 1px solid var(--line);
  padding: 12px 16px;
  box-sizing: border-box;
  font: 600 17px/1.45 var(--font);
  color: var(--ink);
  text-wrap: pretty;
  resize: none;
  display: block;
}

.vs-composer__field::placeholder {
  color: var(--ink-faint);
}

/* THE HINT IS GONE (product-owner ruling, a deliberate deviation from 15a).
 *
 * "Return sends it" is a desktop instruction on a phone: the soft keyboard's own
 * key already says "send" — `enterkeyhint` on the field is what puts it there —
 * so the line restated a label the shopper is already looking at, and it did it
 * inside the card that had the least room. Removed from the markup and from
 * `COPY` together; a live element bound to a deleted key and a dead key bound to
 * nothing are both worse than neither.
 *
 * The button keeps its row and moves to the end of it, which is where it already
 * sat. */
.vs-composer__actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 12px;
}

.vs-composer__send {
  width: 44px;
  height: 44px;
  flex: none;
  border: 0;
  border-radius: 50%;
  background: var(--ink-black);
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 21px var(--font);
  color: #fff;
  cursor: pointer;
  padding: 0;
}

/* The composer owns the bottom of the screen while it is open, so the resting
 * bar underneath it goes rather than sitting behind a shadow. */
#app[data-composing='true'] .surface {
  opacity: 0;
  pointer-events: none;
}

/* ===================================================================== 12a–12f
 * PROFILE, ACCOUNT AND FEEDBACK.
 *
 * `--surface-h` IS DELIBERATELY ABSENT FROM EVERY RULE BELOW, and that is the
 * point rather than an omission. The persistent surface is reserved exactly once,
 * in `.screen--with-surface .screen__body`, and none of these four screens carries
 * that class because none of them draws the voice bar. A pinned control at the
 * bottom of one of these screens must NOT reserve it either: this file already
 * records `.walking-cards` paying for it twice (a two-card region that scrolled
 * 207px) and `.list-shop` reserving 232px for a 104px surface. Not reserving at
 * all is the safest way to reserve once.
 *
 * The palette is `10a`'s: warm-white ground, white cards with a hairline, 800-weight
 * navy headings, and ORANGE ONLY ON THINGS THAT ARE ON. That last rule is what
 * makes the preference pills readable at a glance — filled means on, plain means
 * off — and it is why the hero card is the only large orange surface here.
 * -------------------------------------------------------------------------- */

.pf-body {
  padding: 18px 20px 40px;
  background: var(--paper-warm);
  /* THE BOTTOM PADDING IS THE ONLY BOTTOM RESERVATION ON THIS SCREEN. */
}

.pf-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 18px;
}

.pf-title,
.ac-heading {
  margin: 0;
  font: 800 22px/1.2 var(--font);
  color: var(--ink);
  letter-spacing: -0.6px;
}

.ac-heading {
  font-size: 15px;
  color: var(--ink-muted);
  font-weight: 700;
  letter-spacing: 0;
  margin: 4px 0 18px;
}

/* ------------------------------------------------------------------ identity */

.pf-who {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 20px;
}

/* REAL INITIALS WHEN THERE IS A NAME, the silhouette when there is not — and
 * never initials derived from a phone number, which are not initials of anything.
 * The `--empty` variant is what the shopper sees when the platform holds no name. */
.pf-avatar {
  flex: none;
  width: 58px;
  height: 58px;
  border-radius: 50%;
  background: var(--ink);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font: 800 20px var(--font);
  letter-spacing: -0.5px;
}

.pf-avatar--empty {
  background: var(--chip-bg);
  color: var(--ink-faint);
}

.pf-who__body { min-width: 0; }

.pf-name {
  font: 800 25px/1.15 var(--font);
  color: var(--ink);
  letter-spacing: -1px;
  overflow-wrap: anywhere;
}

.pf-edit {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-top: 4px;
  padding: 0;
  border: 0;
  background: none;
  font: 700 13.5px var(--font);
  color: var(--accent);
  cursor: pointer;
}

.pf-edit__chev { font-size: 15px; line-height: 1; }

/* ---------------------------------------------------------------- hero card */

.pf-hero {
  position: relative;
  overflow: hidden;
  border-radius: 24px;
  padding: 18px 22px;
  min-height: 122px;
  box-sizing: border-box;
  background: linear-gradient(120deg, var(--accent-hot) 0%, #ff7a1a 62%, #ff9a3d 100%);
  box-shadow: 0 10px 26px rgba(255, 90, 0, 0.32);
  margin-bottom: 26px;
}

/* THE RINGS ARE OFF-AXIS ON PURPOSE. A radial decoration centred on its own axis
 * is invisible if it ever animates — this file already records an orb whose stir
 * ran correctly and looked completely static for exactly that reason. These do not
 * animate, and they are still placed off-centre so that adding motion later is a
 * one-line change rather than a rebuild. */
.pf-hero__ring {
  position: absolute;
  right: 34px;
  top: 56px;
  border-radius: 50%;
  border: 1.5px solid rgba(255, 255, 255, 0.34);
}

.pf-hero__ring--1 { width: 120px; height: 120px; margin: -60px -60px; }
.pf-hero__ring--2 { width: 190px; height: 190px; margin: -95px -95px; border-color: rgba(255, 255, 255, 0.24); }
.pf-hero__ring--3 { width: 270px; height: 270px; margin: -135px -135px; border-color: rgba(255, 255, 255, 0.14); }

.pf-hero__label {
  position: relative;
  font: 600 13px var(--font);
  color: rgba(255, 255, 255, 0.8);
  letter-spacing: 0.6px;
}

.pf-hero__value {
  position: relative;
  margin-top: 8px;
  font: 800 44px/1 var(--font);
  color: #fff;
  letter-spacing: -2px;
}

/* THE NOTE WRAPS INSTEAD OF BEING PINNED TO THE CORNER. The design pins «Member
 * since May 2026» bottom-right, and that string is a mock: there is no account
 * creation date in the platform's user object, so this line carries the store name
 * and the count's units instead — which are longer and, on a 375px screen, would
 * collide with a four-digit value. Static position, normal flow, no overlap. */
.pf-hero__note {
  position: relative;
  margin-top: 8px;
  font: 500 13px/1.35 var(--font);
  color: rgba(255, 255, 255, 0.9);
}

/* ---------------------------------------------------------------- blocks */

.pf-block { margin-bottom: 26px; }

.pf-block__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 6px;
}

.pf-block__title {
  font: 800 19px var(--font);
  color: var(--ink);
  letter-spacing: -0.5px;
}

.pf-count {
  font: 700 12.5px var(--font);
  color: var(--accent);
  background: var(--accent-tint);
  border-radius: 999px;
  padding: 4px 10px;
}

.pf-note {
  margin: 0 0 12px;
  font: 400 12.5px/1.5 var(--font);
  color: var(--ink-muted);
  text-wrap: pretty;
}

/* -------------------------------------------------------- preference pills */

/* The storage-location line under the preference note. Amber when there are unsaved
 * local edits, plain grey otherwise — it is information, not an error, right up until
 * the shopper changes handset. */
.pf-source {
  margin: -6px 0 12px;
  font: 600 12px/1.45 var(--font);
  color: var(--ink-faint);
}

.pf-source[data-where='device-ahead'],
.pf-source[data-where='device'] { color: var(--accent-ink); }

.pf-source[hidden] { display: none; }

.pf-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 9px;
}

/* FILLED = ON, PLAIN = OFF, and `aria-pressed` carries the same fact for anyone
 * not looking at the colour. Colour alone would make the whole "you can see that
 * organic is shaping your results" ruling sighted-only. */
.pf-pill {
  border: 1.5px solid var(--line-strong);
  background: var(--paper);
  color: var(--ink-muted);
  border-radius: 999px;
  padding: 10px 15px;
  font: 700 13.5px var(--font);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 7px;
}

.pf-pill[aria-pressed='true'] {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.pf-pill__tick {
  font-size: 11px;
  line-height: 1;
  opacity: 0;
}

.pf-pill[aria-pressed='true'] .pf-pill__tick { opacity: 1; }

/* ---------------------------------------------------------- allergy chips */

.pf-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 9px;
  margin-bottom: 12px;
}

/* NAVY AND NOT ORANGE. Orange is reserved for "on", and an allergy is not a
 * toggle — it is always on and never optional, so giving it the same colour as a
 * preference would say it can be switched off. */
.pf-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: var(--ink);
  color: #fff;
  border-radius: 999px;
  padding: 9px 9px 9px 15px;
  font: 700 13.5px var(--font);
}

.pf-chip__x {
  width: 22px;
  height: 22px;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.18);
  color: #fff;
  font: 600 13px var(--font);
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pf-empty {
  font: 400 13px var(--font);
  color: var(--ink-faint);
  margin-bottom: 12px;
}

.pf-add {
  border: 1.5px dashed var(--line-strong);
  background: none;
  color: var(--accent);
  border-radius: 999px;
  padding: 10px 16px;
  font: 700 13.5px var(--font);
  cursor: pointer;
}

/* ------------------------------------------------------------------- rows */

.pf-rows {
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  box-shadow: 0 2px 10px rgba(10, 37, 64, 0.04);
  overflow: hidden;
}

.pf-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 17px 18px;
  cursor: pointer;
}

.pf-row__title {
  font: 700 15px var(--font);
  color: var(--ink);
}

.pf-row__chev {
  font-size: 19px;
  color: var(--ink-faint);
  line-height: 1;
}

/* ============================================================== 12c account */

.ac-field { margin-bottom: 18px; }

.ac-label {
  display: block;
  margin-bottom: 7px;
  font: 700 12.5px var(--font);
  color: var(--ink-muted);
}

.ac-input {
  width: 100%;
  box-sizing: border-box;
  border: 1.5px solid var(--line-strong);
  border-radius: 14px;
  background: var(--paper);
  padding: 14px 15px;
  font: 600 15.5px var(--font);
  color: var(--ink);
  -webkit-appearance: none;
  appearance: none;
}

.ac-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.ac-select { background-image: none; }

/* READONLY AND NOT DISABLED — a disabled field is unreachable by keyboard and is
 * not announced, and the shopper has to be able to read their own number and hear
 * that it is verified. "Verified" is information, not an obstacle. */
.ac-input--locked {
  background: var(--chip-bg);
  color: var(--ink-muted);
  padding-right: 92px;
}

.ac-phone { position: relative; }

.ac-verified {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  background: var(--good-tint);
  color: var(--good);
  border-radius: 999px;
  padding: 5px 10px;
  font: 700 11.5px var(--font);
}

.ac-note {
  margin: 8px 0 0;
  font: 400 12.5px/1.5 var(--font);
  color: var(--ink-muted);
}

.ac-link {
  margin-top: 6px;
  padding: 0;
  border: 0;
  background: none;
  font: 700 13px var(--font);
  color: var(--accent);
  cursor: pointer;
}

/* THE DATE WHEEL. Three independently scrolling columns, which is what `12c`
 * draws. Scroll position is NOT read back to pick the value — a scroll listener is
 * unobservable on the review surface this project uses (measured: setting
 * `scrollLeft` moved the element and fired ZERO scroll events, and smooth scrolling
 * does not run at all), so selection is a TAP and the scroll only brings a value
 * into view. That is also the better interaction on a phone. */
.ac-wheel {
  position: relative;
  display: grid;
  grid-template-columns: 1.4fr 0.8fr 1fr;
  gap: 8px;
  border: 1.5px solid var(--line-strong);
  border-radius: 16px;
  background: var(--paper);
  padding: 6px;
  overflow: hidden;
}

.ac-wheel__col {
  height: 132px;
  overflow-y: auto;
  scrollbar-width: none;
  text-align: center;
  position: relative;
  z-index: 1;
}

.ac-wheel__col::-webkit-scrollbar { display: none; }

.ac-wheel__opt {
  display: block;
  width: 100%;
  border: 0;
  background: none;
  padding: 9px 2px;
  font: 600 14.5px var(--font);
  color: var(--ink-faint);
  cursor: pointer;
}

.ac-wheel__opt[aria-selected='true'] {
  color: var(--ink);
  font-weight: 800;
}

.ac-wheel__band {
  position: absolute;
  left: 6px;
  right: 6px;
  top: 50%;
  height: 38px;
  transform: translateY(-50%);
  border-radius: 10px;
  background: var(--accent-tint);
  z-index: 0;
}

.ac-save { width: 100%; margin-top: 6px; }

/* ══════════════════════════════════════════════ 12d / 12e / 12f — REBUILT
 *
 * EVERY NUMBER BELOW IS THE FRAME'S. The first build of these screens was assembled
 * from a written reading of `12d` and was rejected as "way off"; this one is taken from
 * the frame's own inline styles, and the handoff doc lists which properties are the
 * frame's and which are inferred.
 *
 * NO `--surface-h` ANYWHERE. `show()` hides the persistent surface on these screens
 * (that is the PRESENCE half) and they do not carry `screen--with-surface` (the
 * RESERVATION half). Both halves are required; the previous build had only the second
 * and the surface painted over 29px of a tappable row.
 * -------------------------------------------------------------------------- */

.fb-body {
  padding: 0;
  background: var(--paper-warm);
  display: flex;
  flex-direction: column;
}

/* ------------------------------------------------------------------ the brow */

/* FRAME: `padding:22px 24px 0`, space-between, and a 56px spacer on the right so the
 * centred label is centred against the button rather than against the edge. */
.fb-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 22px 24px 0;
}

/* FRAME: 56x56, radius 18px, white, 1px #EDEDEF, shadow 0 2px 10px rgba(0,0,0,.05).
 * A ROUNDED SQUARE, not the circular `.iconbtn` the first build reused. */
.fb-back {
  width: 56px;
  height: 56px;
  flex: none;
  border-radius: 18px;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

/* FRAME: a 10x10 box with only left+bottom borders at 2.5px, rotated 45deg, nudged
 * -3px so the optical centre lands on the button's centre. */
.fb-back__chev {
  width: 10px;
  height: 10px;
  border-left: 2.5px solid var(--ink);
  border-bottom: 2.5px solid var(--ink);
  transform: rotate(45deg);
  margin-left: -3px;
}

/* FRAME: 600 13px, #9AA4AF, letter-spacing .6px. A small grey KICKER — the first build
 * made this a 22px/800 heading, which is most of why the screen read wrong. */
.fb-kicker {
  font: 600 13px var(--font);
  color: var(--ink-faint);
  letter-spacing: 0.6px;
}

.fb-top__gap { width: 56px; flex: none; }

/* --------------------------------------------------------------- the question */

/* FRAME: `padding:16px 26px 0`. */
.fb-intro { padding: 16px 26px 0; }

/* FRAME: 800 30px/1.12, letter-spacing -1.2px. (Was 24px/-0.8px.) */
.fb-q {
  margin: 0;
  font: 800 30px/1.12 var(--font);
  color: var(--ink);
  letter-spacing: -1.2px;
}

/* FRAME: 500 13.5px/1.5, #68737d, margin-top 6px. */
.fb-lead {
  margin: 6px 0 0;
  font: 500 13.5px/1.5 var(--font);
  color: var(--ink-muted);
  text-wrap: pretty;
}

/* ------------------------------------------------------------------ the faces */

/* FRAME: flex row, gap 14px, `padding:20px 26px 0`, each child flex:1. */
.fb-faces {
  display: flex;
  gap: 14px;
  padding: 20px 26px 0;
}

/* FRAME: column, centred, gap 9px between the disc and its label. */
.fb-face {
  flex: 1;
  border: 0;
  background: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 9px;
  cursor: pointer;
}

/* FRAME: 88x88 circle. Unselected — white, 1px #EDEDEF, shadow 0 3px 14px
 * rgba(0,0,0,.05). The face is INSIDE the disc. */
.fb-face__disc {
  width: 88px;
  height: 88px;
  box-sizing: border-box;
  border-radius: 50%;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

/* FRAME, selected: the 120deg orange gradient, a transparent border so the box does
 * not resize, and shadow 0 10px 24px rgba(255,90,0,.32). */
.fb-face[aria-checked='true'] .fb-face__disc {
  background: linear-gradient(120deg, var(--accent-hot) 0%, #ff7a1a 62%, #ff9a3d 100%);
  border-color: transparent;
  box-shadow: 0 10px 24px rgba(255, 90, 0, 0.32);
}

/* FRAME: two 7px dots, 16px apart. */
.fb-face__eyes { display: flex; gap: 16px; }

.fb-face__eyes i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ink);
}

.fb-face[aria-checked='true'] .fb-face__eyes i { background: #fff; }

/* FRAME, three distinct mouths — and the offsets differ per mouth, which is what makes
 * all three read as centred despite different heights:
 *   sad    26x13, 3px border, no bottom, radius 14px 14px 0 0, margin-top 12px
 *   flat   24x3,  solid bar, radius 2px,                        margin-top 17px
 *   happy  26x13, 3px border, no top,    radius 0 0 14px 14px,  margin-top 9px */
.fb-face__mouth--sad,
.fb-face__mouth--happy {
  width: 26px;
  height: 13px;
  box-sizing: border-box;
  border: 3px solid var(--ink);
}

.fb-face__mouth--sad {
  border-bottom: none;
  border-radius: 14px 14px 0 0;
  margin-top: 12px;
}

.fb-face__mouth--happy {
  border-top: none;
  border-radius: 0 0 14px 14px;
  margin-top: 9px;
}

.fb-face__mouth--flat {
  width: 24px;
  height: 3px;
  border-radius: 2px;
  background: var(--ink);
  margin-top: 17px;
}

.fb-face[aria-checked='true'] .fb-face__mouth--sad,
.fb-face[aria-checked='true'] .fb-face__mouth--happy { border-color: #fff; }

.fb-face[aria-checked='true'] .fb-face__mouth--flat { background: #fff; }

/* FRAME: 600 13.5px #68737d, becoming 700 #FF5A00 when selected. */
.fb-face__label {
  font: 600 13.5px var(--font);
  color: var(--ink-muted);
}

.fb-face[aria-checked='true'] .fb-face__label {
  font-weight: 700;
  color: var(--accent);
}

/* -------------------------------------------------------------- the categories */

/* FRAME: `padding:24px 24px 0`. */
.fb-about { padding: 24px 24px 0; }

.fb-about[hidden] { display: none; }

/* FRAME: 700 15px navy, `padding:0 2px`. (Was 800 17px.) */
.fb-about__title {
  display: block;
  padding: 0 2px;
  font: 700 15px var(--font);
  color: var(--ink);
}

/* FRAME: wrap, gap 9px, margin-top 12px. */
.fb-cats {
  display: flex;
  flex-wrap: wrap;
  gap: 9px;
  margin-top: 12px;
}

/* FRAME: radius 22px, padding 10px 15px, 600 14px #68737d, white, 1px #EDEDEF,
 * shadow 0 2px 8px rgba(0,0,0,.04). */
.fb-cat {
  border: 1px solid var(--line);
  background: var(--paper);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  border-radius: 22px;
  padding: 10px 15px;
  font: 600 14px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
}

/* FRAME, selected: a 120deg two-stop gradient (NOT the three-stop one the discs use),
 * transparent border, shadow 0 4px 12px rgba(255,90,0,.28), white text at 700. */
.fb-cat[aria-pressed='true'] {
  background: linear-gradient(120deg, var(--accent-hot) 0%, #ff7a1a 100%);
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(255, 90, 0, 0.28);
  color: #fff;
  font-weight: 700;
}

/* ---------------------------------------------------------------- the composer */

/* FRAME: `padding:16px 24px 0`. */
.fb-compose { padding: 16px 24px 0; }

/* FRAME: radius 20px, white, 1.5px #EDEDEF, shadow 0 3px 14px rgba(0,0,0,.05),
 * padding 14px 18px 12px. */
.fb-compose__card {
  border-radius: 20px;
  background: var(--paper);
  border: 1.5px solid var(--line);
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
  padding: 14px 18px 12px;
}

/* FRAME: the note is 500 15.5px/1.45 navy. A REAL TEXTAREA carries it, borderless and
 * transparent so the CARD is the visible boundary — the frame has one border, not two.
 * `field-sizing: content` grows it where supported; `rows="2"` is the floor elsewhere. */
.fb-compose__text {
  display: block;
  width: 100%;
  box-sizing: border-box;
  border: 0;
  padding: 0;
  background: none;
  resize: none;
  font: 500 15.5px/1.45 var(--font);
  color: var(--ink);
  field-sizing: content;
  max-height: 30vh;
}

.fb-compose__text::placeholder { color: var(--ink-faint); }

.fb-compose__text:focus-visible { outline: none; }

/* THE CARD TAKES THE FOCUS RING, not the textarea, because the card is the control the
 * shopper sees. Without this, focus is invisible — the textarea has no border of its
 * own to highlight. */
.fb-compose__card:focus-within {
  border-color: var(--accent);
}

/* FRAME: row, gap 10px, margin-top 12px, padding-top 11px, 1px #F4F4F6 top rule. */
.fb-compose__foot {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
  padding-top: 11px;
  /* `--skeleton-a` (#efeff2) AND NOT THE FRAME'S #F4F4F6, which is a RULED near-miss.
   * I adopted the literal here and `test_the_near_misses_were_not_adopted` rejected
   * it — correctly. The ruling is that a near-miss beside a measured token is how a
   * parallel colour set starts, and it is 2/255 per channel: invisible on a shelf,
   * permanent in a stylesheet. Same for the two tiles below. */
  border-top: 1px solid var(--skeleton-a);
}

/* FRAME: flex:1, 500 12.5px #9AA4AF. A HINT IN THE FOOTER — not the textarea's
 * placeholder, which is where the first build put this string. */
.fb-compose__hint {
  flex: 1;
  min-width: 0;
  font: 500 12.5px var(--font);
  color: var(--ink-faint);
}

/* FRAME: 34x34, radius 12px, flex:none. Two of them. */
.fb-icon {
  width: 34px;
  height: 34px;
  flex: none;
  border: 0;
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

/* FRAME: mic tile #FFF3E9 with an 8x13 orange bar at radius 5px. */
/* `--accent-tint` (#fff1e7) for the frame's #FFF3E9 — the same ruled near-miss. */
.fb-icon--mic { background: var(--accent-tint); }

.fb-icon__mic {
  width: 8px;
  height: 13px;
  border-radius: 5px;
  background: var(--accent);
}

.fb-icon--mic[data-listening='true'] {
  background: var(--accent);
}

.fb-icon--mic[data-listening='true'] .fb-icon__mic { background: #fff; }

/* FRAME: keyboard tile #F4F4F6 with a 16x13 outlined rectangle at radius 4px. */
.fb-icon--kbd { background: var(--skeleton-a); }

.fb-icon__kbd {
  width: 16px;
  height: 13px;
  box-sizing: border-box;
  border: 2px solid var(--ink-muted);
  border-radius: 4px;
}

/* FRAME's `<div style="flex:1">`: the bottom block sits at the foot of the screen. */
.fb-gap { flex: 1; min-height: 16px; }

/* ------------------------------------------------------------- the bottom pair */

/* FRAME: `padding:0 24px 34px`, column, gap 11px. */
.fb-bottom {
  padding: 0 24px 34px;
  display: flex;
  flex-direction: column;
  gap: 11px;
}

/* FRAME: 58px tall, radius 22px, the three-stop orange gradient, shadow
 * 0 10px 24px rgba(255,90,0,.32), 800 18px white, letter-spacing -.3px.
 * ORANGE, NOT THE APP'S BLACK `.btn--primary` — which is what the first build used. */
.fb-cta {
  height: 58px;
  flex: none;
  border: 0;
  border-radius: 22px;
  background: linear-gradient(120deg, var(--accent-hot) 0%, #ff7a1a 62%, #ff9a3d 100%);
  box-shadow: 0 10px 24px rgba(255, 90, 0, 0.32);
  color: #fff;
  font: 800 18px var(--font);
  letter-spacing: -0.3px;
  cursor: pointer;
}

/* FRAME: 62px, radius 22px, white, 1px #EDEDEF, shadow 0 3px 14px rgba(0,0,0,.05),
 * padding 0 20px. A CARD. */
.fb-help {
  min-height: 62px;
  border-radius: 22px;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
  padding: 0 20px;
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
}

.fb-help__body { flex: 1; min-width: 0; }

/* FRAME: 700 16px navy, letter-spacing -.3px. */
.fb-help__title {
  display: block;
  font: 700 16px var(--font);
  color: var(--ink);
  letter-spacing: -0.3px;
}

/* FRAME: 500 12.5px #9AA4AF, margin-top 2px. */
.fb-help__note {
  display: block;
  margin-top: 2px;
  font: 500 12.5px/1.35 var(--font);
  color: var(--ink-faint);
}

/* FRAME `12e` ONLY: a 20px chevron in #707274. `12d` has none, so it is hidden until
 * the positive path paints it. */
.fb-help__chev {
  font-size: 20px;
  line-height: 1;
  color: #707274;
}

.fb-help__chev[hidden] { display: none; }

/* ═══════════════════════════════════════════════════════════════ 12f — SENT */

.fs-body {
  padding: 0;
  background: var(--paper-warm);
  display: flex;
  flex-direction: column;
}

/* FRAME: flex:1, centred both ways, `padding:0 34px`, and `margin-top:-40px` — the
 * negative margin is what lifts the group above the optical centre. */
.fs-centre {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 0 34px;
  margin-top: -40px;
}

/* FRAME: 150x150 with a SIX-STOP RADIAL GRADIENT centred at 50% 62%, plus a 60px
 * orange glow. The first build drew a flat 74px orange disc, which is the single
 * biggest visual difference on this screen.
 *
 * OFF-CENTRE BY DESIGN (50% 62%): a radial gradient centred on its own axis is
 * invisible if it is ever animated, which this file records costing a build. */
.fs-orb {
  position: relative;
  width: 150px;
  height: 150px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 62%,
    #ff4d00 0%, #ff7a1a 26%, #ffb25e 48%, #ffdca8 66%, #fff3e2 82%, #fff 100%);
  box-shadow: 0 0 60px rgba(255, 120, 20, 0.35);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* FRAME: 44x24, 5px white left+bottom borders, radius 2px, rotate(-45deg),
 * margin-top -6px. */
.fs-orb__tick {
  width: 44px;
  height: 24px;
  box-sizing: border-box;
  border-left: 5px solid #fff;
  border-bottom: 5px solid #fff;
  border-radius: 2px;
  transform: rotate(-45deg);
  margin-top: -6px;
}

/* FRAME: 800 32px/1.1, letter-spacing -1.3px, margin-top 34px. */
.fs-title {
  margin: 34px 0 0;
  font: 800 32px/1.1 var(--font);
  color: var(--ink);
  letter-spacing: -1.3px;
  text-align: center;
}

/* FRAME: 500 14.5px/1.6 #68737d, margin-top 12px. */
.fs-text {
  margin: 12px 0 0;
  font: 500 14.5px/1.6 var(--font);
  color: var(--ink-muted);
  text-align: center;
  text-wrap: pretty;
}

/* FRAME: a CARD — margin-top 22px, radius 20px, white, 1px #EDEDEF, shadow
 * 0 3px 14px rgba(0,0,0,.05), padding 14px 18px, row, gap 12px, align-self stretch.
 * The first build printed this as a bare line of text. */
.fs-mail {
  align-self: stretch;
  margin-top: 22px;
  border-radius: 20px;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
  padding: 14px 18px;
  display: flex;
  align-items: center;
  gap: 12px;
}

/* FRAME: 34x34 #FFF3E9 tile at radius 12px holding a 17x13 orange outlined rectangle
 * at radius 4px — an envelope. */
.fs-mail__icon {
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 12px;
  background: var(--accent-tint);
  display: flex;
  align-items: center;
  justify-content: center;
}

.fs-mail__icon i {
  width: 17px;
  height: 13px;
  box-sizing: border-box;
  border: 2px solid var(--accent);
  border-radius: 4px;
}

/* FRAME: flex:1, 500 13px/1.45 #68737d. */
.fs-mail__text {
  flex: 1;
  min-width: 0;
  font: 500 13px/1.45 var(--font);
  color: var(--ink-muted);
}

/* FRAME: 50px tall, 700 15.5px #68737d, and NO background, NO border. A plain text
 * button — the first build used the app's bordered `.btn--ghost`. */
.fs-plain {
  height: 50px;
  flex: none;
  border: 0;
  background: none;
  font: 700 15.5px var(--font);
  color: var(--ink-muted);
  cursor: pointer;
}

/* THE VISIBILITY STRIP on the answer screen (12a's ruling, shown where it matters).
 * Preferences only — allergies are never drawn as something that can be switched off.
 * No `--surface-h` here: this sits inside `.answer-head`, which is above the pager and
 * nowhere near the bottom of the screen. */
.prefs-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
  margin-top: 10px;
}

.prefs-strip[hidden] { display: none; }

.prefs-strip__label {
  font: 600 11.5px var(--font);
  color: var(--ink-faint);
  letter-spacing: 0.2px;
}

/* ON reads as a live filter; OFF (dropped for this query) reads as struck through so
 * the shopper can see it was applied AND that they turned it off, rather than the chip
 * simply vanishing — a chip that disappears looks like the app forgot. */
.prefs-strip__chip {
  border: 1px solid var(--accent-line);
  background: var(--accent-tint);
  color: var(--accent-ink);
  border-radius: 999px;
  padding: 5px 10px;
  font: 700 11.5px var(--font);
  cursor: pointer;
}

.prefs-strip__chip[aria-pressed='false'] {
  border-color: var(--line-strong);
  background: var(--paper);
  color: var(--ink-faint);
  text-decoration: line-through;
}

/* THE FAKE-IDENTITY BADGE (`--fake-identity` on the dev server only).
 *
 * FIXED AND ON TOP OF EVERYTHING. It is not decoration: a bypass someone forgets is
 * on produces a false bug report, and that has cost this project three evenings. It
 * has to be visible on the screen being tested, which is why it is not folded into
 * the build stamp — that only paints on the welcome screen or under `?build=1`.
 *
 * NO `--surface-h`. It is pinned to the TOP, nowhere near the persistent surface.
 *
 * In production the node exists and is never unhidden, because the header that
 * unhides it is added by a middleware that is not in the image. */
.fakeid {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  background: #b3001b;
  color: #fff;
  font: 800 11px/1 var(--font);
  letter-spacing: 0.8px;
  text-align: center;
  padding: 5px 8px;
  text-transform: uppercase;
  pointer-events: none;
}

.fakeid[hidden] { display: none; }

/* "Member since ..." under the visit count. Same flow rule as `.pf-hero__note`: the
 * design pins this to the bottom-right corner, and ours stays in normal flow so a
 * four-digit capped count cannot land on top of it. */
.pf-hero__since {
  position: relative;
  margin-top: 2px;
  font: 500 12.5px/1.35 var(--font);
  color: rgba(255, 255, 255, 0.78);
}

.pf-hero__since[hidden] { display: none; }

/* ══════════════════════════════════ 2o / 2q — the step glyphs (TASK EK)
 *
 * WITHOUT THESE THE CARD DOES NOT DO ITS JOB. Each row names a control in Safari's own
 * interface — the share button, the overflow dots, the share sheet's "View More"
 * chevron, "Add to Home Screen" — and a shopper who cannot recognise the shape is being
 * told to tap something they cannot find. The prompt existed without them, which made
 * it three sentences of instructions and nothing to match against.
 *
 * BUILT FROM BORDERED BOXES, exactly as `2o`/`2q` build them: a tray is a box with no
 * top border, an arrowhead is a rotated corner. Same construction means the same stroke
 * weights and radii as the frame rather than eyeballed ones.
 * -------------------------------------------------------------------------- */

.a2hs__icon {
  position: relative;
  width: 38px;
  height: 38px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* FRAME: radius 12px on #F4F4F6, contents bottom-aligned. */
.a2hs__icon--share {
  border-radius: 12px;
  background: var(--skeleton-a);
}

/* FRAME: 17x13 box, 2px border, NO TOP, radius 0 0 3px 3px — the open tray. */
.a2hs__share-tray {
  position: absolute;
  bottom: 8px;
  width: 17px;
  height: 13px;
  box-sizing: border-box;
  border: 2px solid var(--ink);
  border-top: none;
  border-radius: 0 0 3px 3px;
}

/* FRAME: 2x15 upright bar at radius 1px, overlapping the tray's top edge. */
.a2hs__share-bar {
  position: absolute;
  bottom: 15px;
  width: 2px;
  height: 15px;
  border-radius: 1px;
  background: var(--ink);
}

/* FRAME: 9x9 corner, left+top borders at 2px, rotate(45deg) — the arrowhead. */
.a2hs__share-tip {
  position: absolute;
  top: 9px;
  width: 9px;
  height: 9px;
  box-sizing: border-box;
  border-left: 2px solid var(--ink);
  border-top: 2px solid var(--ink);
  transform: rotate(45deg);
}

/* FRAME: a FILLED GREY CIRCLE (#6E6E72) with a white chevron — this one is a solid
 * disc, unlike the share tile's light rounded square. */
.a2hs__icon--more {
  border-radius: 50%;
  background: #6e6e72;
}

/* FRAME: 11x11, right+bottom borders at 2.5px, rotate(45deg), margin-top -4px. */
.a2hs__more-chev {
  width: 11px;
  height: 11px;
  box-sizing: border-box;
  border-right: 2.5px solid #fff;
  border-bottom: 2.5px solid #fff;
  transform: rotate(45deg);
  margin-top: -4px;
}

/* FRAME: radius 11px, 2px navy border, no fill, "+" at 600 20px. */
.a2hs__icon--plus {
  border-radius: 11px;
  border: 2px solid var(--ink);
  font: 600 20px var(--font);
  color: var(--ink);
}

/* FRAME `2q`: a #F4F4F6 circle with three 3.5px navy dots, 3px apart. */
.a2hs__icon--dots {
  border-radius: 50%;
  background: var(--skeleton-a);
  gap: 3px;
}

.a2hs__icon--dots i {
  width: 3.5px;
  height: 3.5px;
  border-radius: 50%;
  background: var(--ink);
}
