/* Sift design system. The single home for the shared shell.
   Rules here follow the installed emilkowalski skills (.agents/skills):
   apple-design (materials, depth, size-specific typography, reduced motion)
   and emil-design-eng / animate (curves, durations, press feedback).
   Every curve and duration below is taken from those tables, not invented.

   Previously this block was pasted byte-identical into index/profile/history.
   One file instead, so a change can't land on one page and miss another. */

/* Headline face. Self-hosted rather than <link>ed: this file is already shared
   by all three pages, so one @font-face here beats a Google Fonts link repeated
   in three <head>s, and it keeps a page opened on a shop's flaky signal from
   waiting on two cross-origin round trips (googleapis -> gstatic) before a
   headline paints. No user IP goes to a third party on a health app, either.
   Bricolage Grotesque, SIL OFL 1.1, latin variable subset (opsz 12-96, wght
   400-700), taken from:
   https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400..700 */
@font-face {
  font-family: 'Bricolage Grotesque';
  font-style: normal;
  font-weight: 400 700;
  /* Headlines swap in late rather than blocking; the fallback below is another
     grotesque, so the reflow is a weight shift, not a relayout. */
  font-display: swap;
  src: url('/bricolage-grotesque.woff2') format('woff2');
}

:root {
  /* Headings only. Body stays on the system stack. It is what the platform
     hints and renders best at small sizes, and this is a page people read
     one-handed under shop lighting. */
  --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-display: 'Bricolage Grotesque', var(--font-body);

  /* Brand palette. The skills prescribe no hues. These are Sift's, kept. */
  --color-bg: #FAF7F2;
  /* The action colour, and only the action colour. It used to be #64778B,
     which is rgb(100 119 139) -- the exact triplet --color-hairline and
     --color-wash below still carry at 18% and 10%. So the button a reader has
     to find and the 1px rule around every card were one colour at three
     alphas, and the CTA could only isolate itself by being filled in, never by
     being a different colour from the furniture around it.
     These two values are the result card's --result-primary /
     --result-primary-dark, which were already deeper for a documented contrast
     reason; adopting them here converges the app on its own card rather than
     inventing a third blue. White on this clears 5.79:1, where #64778B held
     4.61:1.
     If you change it, keep it off the hairline triplet:
     bench/palette-single-source.test.ts fails if it drifts back. */
  --color-primary: #466986;
  --color-primary-dark: #294B68;
  --color-surface: #F4EEE3;
  --color-surface-alt: #FFFFFF;
  --color-ink: #2B333D;
  --color-ink-muted: #5A6470;
  --color-accent: #D9CEB8;
  --color-hairline: rgba(100, 119, 139, 0.18);
  /* The warm hairline on a white card. It was the bare hex #E3DACB on
     .action-card and matched no token, which is the kind of value that gets
     copied into the next card and then diverges from both. */
  --color-hairline-warm: #E3DACB;
  /* The tint behind a "here is the situation" surface: the scan screen's hero
     card, the auth modal's header band. Same literal was pasted in both. */
  --color-wash: rgba(100, 119, 139, 0.10);
  --color-safe: #4F7A63;
  --color-caution: #80621B;
  --color-may-contain: #8C5428;
  --color-contains: #8F4436;
  /* Ingredient notes (index.html, .verdict-pills.notes). Deliberately NOT a
     verdict colour and not --color-primary: this axis is neither a hazard nor a
     preference, it is information. A desaturated ink-grey, the same family as
     shared.js's ACCENT.additive, so the row reads as calm rather than as a
     fourth thing to worry about. */
  --color-note: #6E6A78;

  /* Motion. Built-in CSS easings are too weak to read as intentional. */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
  --dur-press: 140ms;   /* button press feedback: 100-160ms */
  --dur-fast: 180ms;    /* hover, colour, small state changes */
  --dur-panel: 240ms;   /* modals, drawers */

  /* Tracking is size-specific: large type reads too loose, small type too
     tight. One letter-spacing for everything is wrong somewhere. */
  --track-display: -0.03em;
  --track-heading: -0.02em;
  --track-body: 0;
  /* Two rungs, and only two. --track-caps is the page-level label (0.75rem,
     600); --track-caps-small is the in-card label one step down (0.6875rem,
     700), which needs the extra tracking because caps get tighter as they get
     smaller, not because someone typed a different number. Eleven literals
     across two files used to say .06 / .08 / .085 / .09 at five sizes, which
     is drift, not a decision. */
  --track-caps: 0.06em;
  --track-caps-small: 0.08em;

  /* --- spacing scale ---
     Padding, margin and gap were being picked per declaration: 0.15, 0.2, 0.25,
     0.35, 0.375, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9,
     0.95, 1, 1.1, 1.15, 1.25, 1.5rem all shipped, which is 22 values doing the
     work of about six. Two gaps one step apart are not a decision anyone made;
     they are two authors rounding differently, and the eye reads the result as
     a page that was never aligned.

     A near-doubling ladder in rem, so it scales with the reader's text size:
     4, 8, 12, 16, 24, 32, 48, 64px at the default root. The step from 4 is
     +4 until 16 (where small increments are still legible) and doubling after
     (where they are not).

     Applied where a literal ALREADY equals a rung. Values that sit between
     rungs are left alone and listed in the engineering log rather than snapped:
     moving ~100 sites by 1-3px each is a redesign, not a refactor, and the
     off-scale values are the finding here, not the cleanup. */
  --space-1: 0.25rem;  /*  4px */
  --space-2: 0.5rem;   /*  8px */
  --space-3: 0.75rem;  /* 12px */
  --space-4: 1rem;     /* 16px */
  --space-5: 1.5rem;   /* 24px */
  --space-6: 2rem;     /* 32px */
  --space-7: 3rem;     /* 48px */
  --space-8: 4rem;     /* 64px */

  --r-sm: 8px;
  --r-md: 11px;
  --r-lg: 14px;

  /* The card shape. Was the bare literals `16px` and `0 1px 3px rgba(...)`
     pasted into .hero-card, .action-card, history's .today-count and profile's
     .account-row, which is four places that had to be edited in lockstep and
     three chances to miss one. Distinct from --r-lg (14px), which is the
     control radius; a card is a bigger box and takes a bigger corner. */
  --r-card: 16px;

  /* --- icon tiers (seventh pass, item A) ---
     Every icon is a Lucide glyph on a 24 viewBox, so its rendered line is
     stroke-width x size / 24. Eleven different sizes put that line anywhere
     from 1.25px to 3.33px. Three tiers now, and nothing in between:
       sm  18px  allergen, diet and ingredient marks, and anything drawn inside
                 a chip (1.5px line)
       md  20px  every interface icon: buttons, links, nav, dialogs (1.67px)
       lg  32px  the one big mark on an empty state, wizard step or dialog
                 head. Drawn at --icon-lg-stroke so its line lands at the md
                 weight (1.25 x 32 / 24 = 1.67px) instead of 2.67px. */
  --icon-sm: 18px;
  --icon-md: 20px;
  --icon-lg: 32px;
  --icon-lg-stroke: 1.25;

  /* --- elevation ---
     There used to be three tokens here (--shadow-card, --shadow-soft,
     --shadow-lift) whose blur radii were 3px, 2px and 3px. Three names, one
     shadow. Nothing on the page could be read as nearer or further than
     anything else, and the names actively misled: history's .scan-item sits at
     --shadow-card (3px blur) and its HOVER rule raised it to --shadow-soft
     (2px), so picking a row up made its shadow smaller.

     Three tiers now, and the distance between them is the whole point: 2px,
     16px and 64px of blur. A reader should be able to tell them apart without
     comparing two elements side by side.

       rest     a surface lying ON the page ground. Cards, tiles, the mascot
                tile, a pressed button. The default; most things are here.
       raised   a surface the reader has lifted or selected: a hovered history
                row, the chosen capture segment. Only ever a RESPONSE to input,
                which is why nothing is born at this tier.
       overlay  a surface floating over the page with a backdrop behind it:
                the auth modal, the image viewer, the ingredient sheet.

     --shadow-cta is deliberately NOT a fourth tier. It is brand-tinted (the
     action blue, not ink) and belongs to one component, the primary button; it
     says "this is the action", not "this is 4px above the page". Its tint is
     --color-primary's own rgb(70 105 134); it followed that token off the
     hairline triplet so the button's shadow stays the button's colour. */
  --shadow-rest: 0 1px 2px rgba(43, 51, 61, 0.06);
  --shadow-raised: 0 2px 4px rgba(43, 51, 61, 0.07), 0 8px 16px rgba(43, 51, 61, 0.08);
  --shadow-overlay: 0 4px 12px rgba(43, 51, 61, 0.10), 0 24px 64px rgba(20, 25, 31, 0.28);
  --shadow-cta: 0 1px 2px rgba(43, 51, 61, 0.10), 0 2px 8px rgba(70, 105, 134, 0.24);
}

/* --- ambient wash ---
   One soft slate tint from the top of the VIEWPORT, so the page has a little
   depth instead of being a flat beige field. Static: a plain background paint,
   no animation, no blur, no second layer.

   Two things about where this lives are load-bearing, and both were what broke
   the previous two attempts:

   1. It is on `html`, not on `.app-content`. On .app-content it was scoped to
      the 430px phone column and drawn as a 300px tile at the top of it, which
      is exactly the strip .hero-card and .action-card sit on. It was painting
      underneath opaque cards, so there was nothing left to see.
   2. `body` no longer carries the page ground. A background on `html` stops
      body's own background propagating to the canvas, and body is
      `min-height: 100vh`, so an opaque `background: var(--color-bg)` on body
      covered this gradient across the whole viewport. That is also why
      result-reference.css's html-level wash never showed: the shell's body rule
      was painting over it from the other stylesheet.

   `background-attachment: fixed` anchors it to the viewport rather than to the
   document, which keeps it one cheap paint on a page that scrolls several
   screens instead of a document-tall layer that repaints on scroll.

   The ellipse is measured, not eyeballed. result-reference.css's version was
   drawn against a wide harness page; sampled down the left gutter of the 430px
   phone column it was gone by y=200, which is a smudge under the topbar rather
   than ambient light. This one holds about 12/255 of slate just below the
   topbar and reaches flat cream around 55% of the viewport height. For scale,
   the app's own --color-surface sits 6-15/255 off --color-bg, so the wash is
   one step of the existing palette, not a new visual weight.

   result-reference.css no longer declares its own: it loads this file, so it
   picks the wash up here, against its own --color-bg. One gradient in the
   codebase, and bench/ui-copy.test.ts holds it to exactly one.

   THE SEAM. The origin used to sit at y:-6%, above the viewport, and .topbar
   paints an opaque ground over the first ~56px below that. The reader never saw
   the peak: what was on screen was the gradient's flank, a near-uniform field
   starting at a hard step under the topbar's hairline, which reads as a dimmed
   overlay rather than as light. Two changes, no second gradient:
   the origin moves to the top of the viewport so the part that shows is the
   falloff rather than a flat flank, and the alpha comes down now that the
   brighter part of the ramp is actually visible. The topbar still has to paint
   an opaque ground (content scrolls under it at both breakpoints), so it now
   paints the wash's own value at that height instead of bare cream, and the
   step at the hairline closes. */
html {
  background:
    radial-gradient(120% 62% at 50% 0%, rgb(78 96 118 / 0.09), transparent 72%),
    var(--color-bg);
  background-repeat: no-repeat;
  background-attachment: fixed;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font-body);
  /* Percentage, not px: the layout scales with the user's text-size setting. */
  font-size: 100%;
  line-height: 1.55;
  letter-spacing: var(--track-body);
  color: var(--color-ink);
  -webkit-font-smoothing: antialiased;
}

/* `hidden` means not rendered, full stop. Without this any class that sets its
   own `display` silently outranks the attribute, which is how the whale states
   below, each `display: flex`, showed up on a page that had marked them hidden.
   A `display` rule beating `hidden` is always the bug, never the intent. */
[hidden] { display: none !important; }

/* The phone column, and the mobile-first default. 430px is the iPhone Pro Max
   logical width: the widest layout the design is ever asked to hold. Full width
   below that, so nothing is letterboxed on a small phone.
   Two rules override this, both further down and both deliberate: the 768px
   block opens the column to 664px (the width .capture-note's 46ch measure and
   .product-confirmation's 32rem were tuned against), and index.html's own
   1200px block puts a second column beside that same 664px primary rather than
   stretching it. The primary column therefore never exceeds 664px at any
   viewport -- the shell's old 920px single column is not coming back. */
.app-card { width: 100%; max-width: 430px; margin: 0 auto; }
/* privacy.html and terms.html are documents, not app sections: they sit outside
   the tab shell and they are read, not operated. A phone-width column is right
   for a scan flow and wrong for pages of legal prose, and the bottom padding
   above clears a tab bar these two do not have. .policy keeps its own 70ch
   measure inside this, so the line length is unchanged; only the dead space
   either side of it goes. */
/* --doc-measure is the doc shell's ONE width. .policy reads it for the prose,
   and the full-bleed header and footer below compute their padding from the
   same value, which is what makes the wordmark sit on the same left edge as
   the sentence under it. They used to be two independent measures -- header
   padding derived from the 48rem card, prose from .policy's own 70ch centred
   inside it -- and the two edges stood 83px apart on every doc page.
   In rem, not ch: ch resolves against the font-size of the element reading it,
   and .topbar is 0.875rem, so one `70ch` computed 37px narrower in the header
   than in the prose and simply moved the drift to the other side. 37.5rem is
   the 70ch measure these pages were tuned at, in a unit that means the same
   thing in both places. */
.app-card.doc { max-width: 680px; --doc-measure: 37.5rem; }
.doc .app-content { padding-bottom: var(--space-8); }
/* The fixed tab bar's height, declared once and read by both the bar and the
   content padding that has to clear it. Two independent numbers is how the
   legal footer ends up parked under the tabs on a page that happens to end at
   the fold. 66px is the 49px tab plus the row's 4px padding, its border, and
   the label's descender. */
:root { --tabbar-h: 66px; }
/* The desktop rail's width, declared once for the same reason --tabbar-h is:
   the rail is fixed and the page clears it with padding on <body>, so two
   independent numbers is how the first column of every card ends up under the
   rail's border. 240px holds "History" beside an 18px glyph with the pill
   padding either side and still leaves a 1280px window 1040px of content. */
:root { --sidebar-w: 240px; }
.app-content { padding: 0 var(--space-4) calc(var(--tabbar-h) + var(--space-5) + env(safe-area-inset-bottom, 0px)); }

/* --- chrome --- */

.topbar {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  row-gap: var(--space-2);
  column-gap: var(--space-2);
  font-size: 0.875rem;
  color: var(--color-ink-muted);
  /* The bottom hairline sits outside the padding box, so equal padding leaves
     the nav pill 1px closer to the top edge than the bottom one. The row is
     short enough for that to read as a pill sitting high in its own header. */
  padding: var(--space-3) var(--space-5) calc(var(--space-3) - 1px);
  /* Opaque, because content scrolls under this row at both breakpoints, but
     mixed to the wash's own value at this height rather than bare cream, so
     the page ground runs continuously through the header instead of stepping
     at the hairline. Same slate as the gradient above, at its strength near
     the top of the viewport. */
  background: color-mix(in srgb, rgb(78 96 118) 8%, var(--color-bg));
  border-bottom: 1px solid var(--color-hairline);
}

.brand { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; color: inherit; text-decoration: none; border-radius: var(--r-sm); transition: color var(--dur-fast) var(--ease-out); }
/* The wordmark is a headline, not chrome text. It gets the display face. */
.brand strong { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; letter-spacing: var(--track-heading); color: var(--color-ink); }
.logo { height: 30px; width: 30px; border-radius: var(--r-sm); object-fit: cover; flex-shrink: 0; }

/* Everything but the brand lives in one right-hand group so it wraps as a unit
   on narrow widths instead of leaving a single control (e.g. Sign out)
   stranded on its own row under the logo. */
.topbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin-left: auto; justify-content: flex-end; min-width: 0; }

/* --- the rail's account indicator ---
   An INDICATOR, not the account editor. The address and Sign out are the
   subject of Profile and stay there (bench/tab-bar.test.ts holds index and
   history to that); this says which account the app is currently answering as,
   which is the one thing a persistent nav should be able to tell you without a
   trip to another page. Not a link: the Profile tab three rows above it is
   already the way there, and a second control to the same place is a second
   thing to keep in step.
   display:none is the default and the 1024px block is the only thing that
   switches it on. On a phone the topbar is one line tall and the account has no
   slot in the bottom tab bar, so there is nowhere for this to go there. */
.sidebar-account { display: none; align-items: center; gap: var(--space-2); margin: 0; padding: 0 var(--space-2); min-width: 0; font-size: 0.8125rem; line-height: 1.35; color: var(--color-ink-muted); }
/* Blobatar. Fixed box for the same reason profile's .account-avatar gets one:
   the inlined svg has no intrinsic size to constrain itself by. */
.sidebar-account-avatar { display: block; flex-shrink: 0; width: 28px; height: 28px; }
.sidebar-account-avatar svg { display: block; }
/* The ring tells the two states apart before the words do (fifth pass E3):
   dashed and muted for a guest, a placeholder identity; solid brand for an
   account. Guest is the default because it is the true state until
   watchSession answers (renderSidebarAccount sets data-state). An outline, so
   it follows the round avatar without changing its 28px box. */
.sidebar-account-avatar { border-radius: 50%; outline: 1.5px dashed var(--color-ink-muted); outline-offset: 2px; }
.sidebar-account[data-state="signed-in"] .sidebar-account-avatar { outline-style: solid; outline-color: var(--color-primary); }
.sidebar-account-note { display: block; margin-top: 2px; font-size: 0.75rem; color: var(--color-ink-muted); }
.sidebar-account-text { min-width: 0; }
.sidebar-account-label { display: block; font-size: 0.6875rem; font-weight: 700; letter-spacing: var(--track-caps-small); text-transform: uppercase; }
/* An address is one unbroken token and the rail is 240px wide: without this it
   pushes its own overflow rather than wrapping, and the rail scrolls sideways. */
.sidebar-account-email { display: block; overflow-wrap: anywhere; color: var(--color-ink); }
.topbar button {
  width: auto;
  padding: var(--space-2) var(--space-3);
  font-size: 0.85rem;
  font-family: inherit;
  margin: 0;
  background: transparent;
  color: var(--color-ink-muted);
  border: 1px solid var(--color-hairline);
  border-radius: var(--r-sm);
  cursor: pointer;
  flex-shrink: 0;
  /* 44px, not 40. This is "Sign in" -- the control a first-time guest is most
     likely to reach for -- and the topbar is sticky at the top edge, the
     hardest strip of a one-handed phone grip to reach. It was the one place in
     the app under the 44px floor the tab bar (49px) and the capture button
     (46px) both clear. A <button> is border-box under the UA sheet, so this
     min-height IS the hit box; measured at 44.0 after the change. */
  min-height: 44px;
  transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
.topbar button:active:not(:disabled) { transform: scale(0.97); }
/* #account's truncation rule lived here while the address sat in every page's
   topbar and had to be kept to one line. It is on Profile now, in a card of its
   own with room to wrap, and profile.html styles it there. Keeping this would
   have quietly won on the properties profile.html does not redeclare --
   white-space: nowrap in particular, which cancels the overflow-wrap that lets
   a long address break. */

/* Still used by privacy.html and terms.html for their single "Return to scan"
   link. Those two sit outside the tab shell (they are policy documents, not
   app sections), so they keep a pill rather than gaining a fourth tab. The
   in-app pill row that used to live here is now .tabbar below. */
.nav-pill {
  display: inline-flex;
  align-items: center;
  /* Right edge of the bar, opposite the brand -- the standard header
     left/right split .topbar-actions gives the in-app pages. There is
     nothing else in this row to push right instead, so the margin sits on
     the pill itself rather than a wrapper. */
  margin-left: auto;
  padding: var(--space-2) var(--space-3);
  font-size: 0.85rem;
  font-weight: 500;
  border-radius: var(--r-sm);
  color: var(--color-ink-muted);
  text-decoration: none;
  white-space: nowrap;
  /* box-sizing because this is an <a>, not a <button>: the UA gives buttons
     border-box and anchors content-box, so without it min-height would be a
     CONTENT height and the real target would be 44 + 16px padding. Measured,
     not assumed -- the pair below is what makes the hit box exactly 44. */
  box-sizing: border-box;
  min-height: 44px;
  transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
.nav-pill:active { transform: scale(0.97); }
/* A back mark (Lucide chevron-left) so the pill reads as the way out, not a
   label. Drawn as a mask in currentColor because the header markup is pasted
   across five pages and pinned byte-for-byte (bench/global-footer.test.ts). */
.nav-pill::before {
  content: "";
  width: 16px; height: 16px;
  margin: 0 var(--space-1) 0 calc(var(--space-1) * -1);
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
}

/* --- section navigation ---
   The app's three sections. ONE nav element, in the topbar markup on every
   page, wearing two shapes: a fixed bottom tab bar on a phone (below), a row
   of pills in the header on a desktop window (the min-width block further
   down). Not two navs behind display:none -- that is two sets of links, two
   landmarks and two things to keep in step.

   Either way the tabs are always present. They replace a pill row that was
   revealed by watchSession for a signed-in reader only, so a guest had no way
   to learn that History and Profile existed at all. Both destinations gate
   themselves with a sign-in panel that says what is behind them and offers a
   way back, which is a legible door rather than the dead end that hiding them
   was meant to avoid.

   PHONE: fixed rather than sticky, because a tab bar is chrome and does not
   belong to the scroll. The inner row re-applies the shell's own 430px cap so
   the tabs line up with the cards above them rather than stretching out.
   Being a fixed child of the sticky .topbar is fine: sticky is not a
   containing block for fixed, so this still positions against the viewport.
   It does cap the bar's stacking at .topbar's own z-index of 5 -- checked
   against every other layer in the app, and the only things above it are the
   modal overlays (25) and the skip link (30), both of which SHOULD cover it. */
/* Moving between Scan, History and Profile is a cross-fade of the page with the
   tab bar held still, not a white flash and a repaint, so the three pages read
   as one app. Browsers without cross-document view transitions navigate as
   before. Reduced motion opts out entirely. */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
.tabbar {
  view-transition-name: tabbar;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 20;
  display: flex;
  justify-content: center;
  /* Opaque, not a frosted material. iOS would blur this; bench/ui-copy's chrome
     rule forbids that app-wide and is right to. Frosting costs a composite
     every frame on a page that already spends its budget on a camera stream,
     and the app's ground is a flat cream that gains nothing from being seen
     through. The page's own colour plus a hairline separates chrome from
     content on its own. */
  background: var(--color-bg);
  border-top: 1px solid var(--color-hairline);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* box-sizing so the row's own padding is inside --tabbar-h rather than added
   to it, which is what keeps the clearance calc above honest. */
.tabbar-inner { box-sizing: border-box; display: flex; width: 100%; max-width: 430px; height: var(--tabbar-h); padding: var(--space-1) var(--space-2); }
.tab {
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  /* 49px is the iOS tab bar's own height and it clears the 44px minimum on its
     own, before the 4px of padding on the row. */
  min-height: 49px;
  padding: var(--space-1) 6px;
  border-radius: var(--r-sm);
  color: var(--color-ink-muted);
  text-decoration: none;
  /* Tab labels are the one place a platform reads smaller than body size; iOS
     sets 10pt. 11px keeps the label legible next to a 24px glyph without the
     row growing tall enough to eat the fold. */
  font-size: 0.6875rem;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: var(--track-body);
  transition: color var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
/* Tab glyphs are interface icons, so they take the md tier at every width. They
   used to sit at 24px on a phone and 18px in the desktop header: one glyph, two
   line weights (2.0px and 1.5px) depending on the window. One size now, so the
   desktop block no longer redeclares it. Still declared HERE, before the
   desktop block, so any later width rule there would win by source order. */
.tab-icon { width: var(--icon-md); height: var(--icon-md); }
/* aria-current is the selected state for a nav link, and it is what paints it:
   colour and weight come off the same attribute a screen reader announces, so
   the two cannot drift. */
.tab[aria-current="page"] { color: var(--color-primary-dark); font-weight: 600; }
/* Phone only: the selected glyph sits in a capsule, the platform's own
   selected-tab mark, so the current section reads without comparing greys.
   The glyph presses in on tap instead of the whole tab fading. */
@media (max-width: 767px) {
  .tab .tab-icon { box-sizing: content-box; padding: 2px 16px; border-radius: 999px; transition: background-color var(--dur-fast) var(--ease-out), transform var(--dur-press) var(--ease-out); }
  .tab[aria-current="page"] .tab-icon { background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
  .tab:active .tab-icon { transform: scale(0.92); }
}
@media (max-width: 767px) and (prefers-reduced-motion: reduce) { .tab:active .tab-icon { transform: none; } }
@media (hover: hover) and (pointer: fine) {
  .tab:hover { color: var(--color-ink); }
}

/* --- desktop shell ---
   A phone-width column with a bar pinned to the bottom of a 1440px window is a
   phone emulator, not an app: the bottom tab bar is the single strongest "this
   is a mobile screen" signal there is, and 430px of content in the middle of a
   desktop display leaves the eye travelling across dead space to reach one
   column of controls.

   So past 768px the same markup becomes a desktop header: the nav moves up into
   the topbar row as a pill row, and the column widens to the 664px it was built
   at (.capture-note's 46ch measure and .product-confirmation's 32rem were both
   tuned against that width). Nothing below this breakpoint changes, so a phone
   gets exactly the tab-bar layout it had.

   These pills look like the row that was removed, and that is fine: the problem
   with the old row was never its shape, it was that a guest never saw it. */
@media (min-width: 768px) {
  .app-card { max-width: 664px; }
  /* No tab bar down there to clear any more, and the roomier inline padding
     that a wider column can afford. */
  .app-content { padding: 0 var(--space-5) var(--space-8); }
  .tabbar {
    position: static;
    /* Pushed to the right of the brand. .topbar-actions gives up its own auto
       margin below so Sign in sits beside the nav rather than being shoved to
       the far edge with a hole between them. */
    margin-left: auto;
    z-index: auto;
    background: none;
    border-top: 0;
    padding-bottom: 0;
  }
  .topbar-actions { margin-left: 0; }
  .tabbar-inner { width: auto; max-width: none; height: auto; padding: 0; gap: 2px; }
  /* Icon beside the label, not above it: a stacked icon-over-caption IS the
     phone tab bar, and it reads as one wherever it is put. */
  .tab {
    flex: 0 0 auto;
    flex-direction: row;
    gap: var(--space-2);
    /* border-box, or min-height is a CONTENT height and the 0.4rem padding is
       added on top of it: the pill rendered 52.8px against a 30px brand block,
       which is what made the logo read as floating out of line with the nav. */
    box-sizing: border-box;
    min-height: 40px;
    padding: var(--space-2) var(--space-3);
    font-size: 0.85rem;
  }
  /* A pill needs a filled selected state: colour and weight alone are too
     quiet in a header row that also holds the wordmark and a button. */
  .tab[aria-current="page"] { background: rgba(100, 119, 139, 0.12); }
}

/* --- doc shell, desktop: full-bleed header/footer ---
   privacy.html, terms.html, changelog.html, how-it-works.html and 404.html are
   all .app-card.doc: reference pages, not app sections, so they keep .topbar's
   "Return to scan" pill instead of gaining the rail below. Below 768px that
   .topbar already reads as a full-width bar because .app-card itself is
   edge-to-edge on a phone; past that breakpoint the bar just went on being a
   680px column floating in the page background, which on a wide window looks
   like a phone screenshot pasted into a browser rather than a page header.
   The header and footer break out to the viewport edge here -- width: 100vw
   plus a negative margin, the one way to escape .app-card's own centred
   max-width without a wrapper element -- and their content is re-centred on
   the reading column with padding computed from --doc-measure, the same value
   .policy sets its own width from, so nothing drifts out of line with the
   prose between them. max() keeps a floor of
   --space-5 so the padding never collapses as the viewport narrows toward
   768px. The content column itself widens from 680px (42.5rem) to 48rem
   (768px): still inside .policy's own 70ch measure so line length does not
   grow, just less of the column reading as dead margin around it. */
@media (min-width: 768px) {
  .app-card.doc { max-width: 48rem; }
  .app-card.doc > .topbar,
  .app-card.doc .legal-footer {
    box-sizing: border-box;
    width: 100vw;
    margin-left: calc(50% - 50vw);
    /* The prose column's own edge, not the card's: .policy is 70ch centred
       inside .app-content's 16px inline padding, so (100vw - 70ch) / 2 is
       exactly where its first character lands. */
    padding-left: max(var(--space-5), calc((100vw - var(--doc-measure)) / 2));
    padding-right: max(var(--space-5), calc((100vw - var(--doc-measure)) / 2));
  }
}

/* --- desktop shell, past 1024px: the left rail ---
   The header row above is the tablet shape and stays exactly that. Past 1024px
   the SAME nav becomes a persistent left sidebar, which is what a window that
   wide actually affords: a horizontal pill row puts the app's three sections in
   the one strip the eye leaves first, and everything below it is then a column
   floating in a field of cream with no edge to sit against.

   No new markup and no second copy of the links. .topbar is already
   brand -> nav -> account slot in source order, which is top -> middle -> bottom
   of a rail; all this block does is turn that row on its side. Every rule here
   is scoped to .app-shell, the class the three in-app pages put on <body>.
   privacy.html, terms.html and contributor-badges.html share .topbar but carry
   no tab bar, and unscoped these rules would hand each of them a 240px empty
   rail.

   HOW IT IS OFFSET, and why it is not a grid: the rail is `position: fixed` and
   the content clears it with padding on <body>. .app-card, .app-content and
   #main are not touched, so the page below stays the single centred column that
   bench/single-column-lock.test.ts is there to defend -- a `display: grid` on
   the shell would be the exact rule that guard was written to catch, and a
   sidebar is not a reason to start arguing with it. Fixed also means the rail
   does not scroll away on a result page several screens tall, which is the
   whole point of a persistent nav. It escapes .app-card cleanly: nothing in
   either stylesheet puts a transform, filter or containment on body or
   .app-card, so the containing block really is the viewport. */
@media (min-width: 1024px) {
  .app-shell { padding-left: var(--sidebar-w); }
  .app-shell .topbar {
    position: fixed;
    inset: 0 auto 0 0;
    width: var(--sidebar-w);
    /* The rail's padding is inside its 240px, or the content clearance above
       is short by it and the column sits under the border. */
    box-sizing: border-box;
    flex-direction: column;
    align-items: stretch;
    /* The row wrapped its children onto extra lines; a column must not, or a
       short window drops the account slot beside the nav instead of below it.
       Scrolls instead -- three tabs and one line of address will not need to,
       but a 500px-tall window with the text size turned up will. */
    flex-wrap: nowrap;
    overflow-y: auto;
    row-gap: 0;
    padding: var(--space-4) var(--space-3) calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
    border-bottom: 0;
    border-right: 1px solid var(--color-hairline);
  }
  /* ONE inset for every row in the rail. Four rows sat at four different left
     edges -- the wordmark at 19.2px, a tab label at 23.2, the account line at
     19.2, the Sign in button at 24.8 -- because each one kept whatever inline
     padding it had as a child of the phone header row. Nothing here changes a
     row's structure; they just all start in the same place now. */
  .app-shell .topbar { --rail-inset: 0.7rem; }
  /* The wordmark is the rail's masthead, so it gets air under it rather than
     the 8px gap it had as the first item in a row. */
  .app-shell .brand { padding: 0 var(--rail-inset); margin-bottom: var(--space-5); }
  /* Inside the account box now, which carries the inset as its own padding. */
  .app-shell .sidebar-account { padding: 0; }
  .app-shell .topbar-actions button { padding-left: var(--rail-inset); padding-right: var(--rail-inset); background: var(--color-surface-alt); }
  /* margin-left: auto pushed the nav to the right of the brand in the header
     row. In a column it is the cross axis and means nothing; the tabs are a
     full-width stack. */
  .tabbar { margin-left: 0; }
  .tabbar-inner { flex-direction: column; width: 100%; }
  /* Left-aligned, full width: a rail item is a target the whole width of the
     rail, not a pill centred in it. Slightly roomier than the header pill
     because there is width to spend and nothing to compete with. */
  .tab { justify-content: flex-start; gap: var(--space-2); min-height: 44px; padding: var(--space-2) var(--rail-inset); font-size: 0.9rem; }
  /* The bottom slot: pinned down by the auto margin, not by the flow, so it
     stays at the foot of the rail on a short page and a tall one alike. */
  /* Contained, so the account reads as the rail's foot rather than a stray
     line under the tabs (fifth pass E3). Both states share the box; Sign in, on
     index.html only, sits inside it as the last row. */
  .app-shell .topbar-actions {
    margin-top: auto;
    margin-left: 0;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-3);
    padding: var(--space-3);
    background: var(--color-surface);
    border: 1px solid var(--color-hairline-warm);
    border-radius: var(--r-md);
  }
  /* The account indicator is a rail fixture. It has no place in the phone
     topbar -- that row is one line tall and the bottom tab bar has no slot for
     it -- so it ships display:none and is switched on only here. */
  .sidebar-account { display: flex; }
}

/* Touch fires hover on tap, so every hover state is gated. */
@media (hover: hover) and (pointer: fine) {
  .topbar button:hover:not(:disabled) { background: var(--color-surface); color: var(--color-ink); opacity: 0.9; }
  .nav-pill:hover { background: var(--color-surface); color: var(--color-ink); opacity: 0.9; }
  .brand:hover { color: var(--color-primary); }
}

/* On a phone the signed-in email is long enough to push nav and Sign out onto
   two extra rows, so the chrome eats a third of the screen before any content.
   Truncate the address instead. Sign out is what makes the row actionable. */
/* The signed-in address is no longer in this row on any page (it moved into
   Profile, where it labels the account being edited), so there is nothing left
   here to truncate or hide. The topbar just tightens its own padding. */
@media (max-width: 480px) {
  .topbar { padding: var(--space-3) var(--space-4); column-gap: 6px; }
}

/* --- typography --- */

/* One rule, so a heading can't pick up the display face on one page and miss it
   on another. h4 is here for the wizard step titles, which are headings even
   though they live inside a modal. Tracking tokens are unchanged. Bricolage
   takes --track-display as-is. */
h1, h2, h3, h4 { font-family: var(--font-display); }

/* Leading tightens as size grows; tracking goes negative. */
h3, .page-title {
  margin: var(--space-6) 0 var(--space-3);
  font-size: clamp(1.75rem, 7vw, 2.125rem);
  font-weight: 700;
  line-height: 1.08;
  letter-spacing: var(--track-display);
  color: var(--color-ink);
}

/* .section-label is the same treatment for a caps heading that is NOT labelling a
   form control, a <p> above the guest session strip, say. Same rule so the two
   can't drift apart visually. */
label.section, legend.section, .section-label {
  display: block;
  margin-top: var(--space-5);
  /* An <h2> carrying this class takes the label treatment whole: the display
     face from `h1, h2, h3, h4` above and the UA's own heading margins would
     otherwise make the same component look like two different things
     depending on which element carried it. The entry screen's section labels
     are headings now, because a page whose only heading is its <h1> has an
     outline that describes nothing below it. */
  margin-bottom: 0;
  font-family: var(--font-body);
  /* Sentence case (sixth pass F13): a tracked caps label over every group read
     as template chrome, and the wizard's category labels already dropped it. */
  font-size: 0.875rem;
  font-weight: 600;
  letter-spacing: var(--track-body);
  color: var(--color-ink-muted);
}
fieldset.preference-group { min-width: 0; margin: 0; padding: 0; border: 0; }
fieldset.preference-group + fieldset.preference-group { margin-top: var(--space-5); }
fieldset.preference-group legend.section { width: 100%; margin-top: 0; padding: 0; }

.state-panel { margin-top: var(--space-4); color: var(--color-ink); }
.state-panel h2 { margin: 0; font-size: 1rem; line-height: 1.35; }
.state-panel p { margin: var(--space-1) 0 0; color: var(--color-ink-muted); font-size: 0.875rem; line-height: 1.5; }
.state-panel .btn-primary, .state-panel .btn-quiet { display: inline-flex; align-items: center; justify-content: center; width: auto; min-height: 44px; margin-top: var(--space-3); padding: var(--space-3) var(--space-4); font-size: 0.875rem; text-decoration: none; }
.state-panel--mascot { padding: clamp(var(--space-5), 5vw, 2.5rem); text-align: center; background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface-alt)); }
.state-panel--mascot .whale { display: block; margin: 0 auto var(--space-4); --whale-size: 72px; }
.state-panel--mascot p { max-width: 25rem; margin: var(--space-2) auto 0; }
.state-panel--mascot .btn-primary { margin-top: var(--space-5); }

/* --- the signed-out empty state ---
   A screen with nothing on it yet is still a screen, and this one was a small
   white card pinned to the top of a page with two thirds of nothing under it.
   The card was the problem, not the copy: .state-panel draws card chrome, which
   says "here is a piece of content" on a page whose whole message is that there
   is no content, and a card that does not fill the page has to be at the top of
   it, which is what put the void underneath.

   So the chrome comes off and the content is centred in the space instead. The
   height is min(), not a bare vh: past a laptop the reader would be scrolling
   to reach a headline and a button, which is the same mistake one axis over.

   The two actions are RANKED, and the ranking is the reason there are two: the
   thing a signed-out reader most likely wants is to sign in, and a text action
   is the way to offer the other one without it competing. Both were one button
   reading "Return to scan", which offered only the exit.

   Written as .state-panel.state-panel--empty, not as the modifier alone: the
   `.action-card, .state-panel, .scan-item` group that draws the card chrome
   sits further down this file at equal specificity, so a single-class modifier
   loses to it on source order and the card survives every property below. */
.state-panel.state-panel--empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: min(62vh, 30rem);
  margin-top: 0;
  padding: var(--space-6) var(--space-4);
  border: 0;
  background: none;
  box-shadow: none;
  text-align: center;
}
.state-panel.state-panel--empty .empty-icon {
  width: var(--icon-lg);
  height: var(--icon-lg);
  margin-bottom: var(--space-4);
  color: var(--color-primary);
  fill: none;
  stroke: currentColor;
  stroke-width: var(--icon-lg-stroke);
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* 404's icon is two whole Lucide glyphs (search, circle-help) rather than one,
   since no single glyph in the library reads as "search + question mark". The
   group carries the spacing the lone .empty-icon carries everywhere else, and
   the badge is sized and inherits stroke from it the same way a notification
   dot sits on an avatar: a smaller circle pinned to the corner, cut off from
   the icon underneath by a ring of the page's own ground so the two glyphs
   read as one mark instead of two overlapping outlines. */
.state-panel.state-panel--empty .empty-icon-group {
  position: relative;
  display: inline-flex;
  margin-bottom: var(--space-4);
}
.state-panel.state-panel--empty .empty-icon-group .empty-icon { margin-bottom: 0; }
.state-panel.state-panel--empty .empty-icon-badge {
  position: absolute;
  /* Top-right, not bottom-right: search's own handle exits toward the bottom-right
     corner, and the badge's punch-out ring erased that stroke when it sat there,
     leaving what read as a broken circle with no handle at all.
     -10px, not the -4px it had on the old 40px glyph: on the 32px lg tier the
     badge's ring cut into the lens' top-right arc and the mark read as a "C".
     At -10px the ring clears the lens (about 1.5px of ground between them). */
  right: -10px;
  top: -10px;
  width: var(--icon-sm);
  height: var(--icon-sm);
  border-radius: 50%;
  background: var(--color-bg);
  box-shadow: 0 0 0 3px var(--color-bg);
  color: var(--color-primary);
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* One component, one headline scale, whichever element the page needs. 404
   heads its panel with an <h1> because the panel IS the page; History and
   Profile head theirs with an <h2> because both pages already carry an <h1>
   above #content. The 404's h1 used to take .page-title as well, which
   resolved to clamp(1.75rem, 7vw, 2.125rem): one component rendering at two
   sizes, the larger one on the page with the least to say. */
.state-panel.state-panel--empty h1,
.state-panel.state-panel--empty h2 { margin: 0; font-family: var(--font-display); font-size: 1.375rem; line-height: 1.35; letter-spacing: var(--track-heading); }
.state-panel.state-panel--empty p { max-width: 34ch; margin-top: var(--space-2); }
/* Full-width up to a comfortable measure rather than shrink-wrapped: a centred
   button sized to its own three characters reads as an afterthought. */
.state-panel.state-panel--empty .btn-primary { width: 100%; max-width: 17rem; margin-top: var(--space-5); padding: var(--space-3) var(--space-5); font-size: 1rem; }
/* The secondary action is a link, not a second button. It carries the standard
   44px target anyway, because it is the only way back for anyone who landed
   here by mistake. */
.state-panel.state-panel--empty .empty-secondary {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-top: var(--space-2);
  padding: 0 var(--space-3);
  color: var(--color-ink-muted);
  font-size: 0.9375rem;
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out);
}
.state-panel.state-panel--empty .empty-secondary:focus-visible { color: var(--color-ink); text-decoration: underline; text-underline-offset: 3px; }
@media (hover: hover) and (pointer: fine) {
  .state-panel.state-panel--empty .empty-secondary:hover { color: var(--color-ink); text-decoration: underline; text-underline-offset: 3px; }
}

/* The small print under a chip row: "terms you add are keyword checks", "your
   own terms are not spell-checked". Same note doing the same job in the
   onboarding wizard (.wiz-caveat) and on Edit profile (.caveat), and it was
   0.8rem/1.45 in one and 0.8125rem/1.5 in the other. One rule, one size. */
.caveat, .wiz-caveat { margin: var(--space-3) 0 0; font-size: 0.8125rem; line-height: 1.5; color: var(--color-ink-muted); }

#status { margin-top: var(--space-3); color: var(--color-ink-muted); min-height: 1.2em; font-size: 0.9rem; }

/* --- controls --- */

/* Press feedback is instant and lives on the press, not the release. */
.btn-primary {
  width: 100%;
  padding: var(--space-4) var(--space-5);
  font-size: 1.0625rem;
  font-weight: 600;
  font-family: inherit;
  letter-spacing: var(--track-heading);
  margin-top: var(--space-3);
  border: 0;
  border-radius: var(--r-md);
  background: var(--color-primary);
  color: #fff;
  cursor: pointer;
  box-shadow: var(--shadow-cta);
  transition: background-color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out), box-shadow var(--dur-fast) ease, opacity var(--dur-fast) var(--ease-out);
}
/* Pressed drops to the resting tier: the button is being held against the page,
   so it loses the brand lift rather than gaining more of it. */
.btn-primary:active:not(:disabled) { transform: scale(0.97); box-shadow: var(--shadow-rest); }
/* One disabled treatment for every button on the page, matching .link-btn and
   .image-viewer-tool below: dim the control, don't repaint it. The old rule
   swapped the primary's background for --color-accent, and a tan block reads as
   a broken button rather than a waiting one. .btn-quiet had NO disabled rule at
   all, so "Scan product" -- disabled until consent is ticked -- looked fully
   live and simply did nothing when pressed. */
.btn-primary:disabled, .btn-quiet:disabled { opacity: 0.5; box-shadow: none; cursor: not-allowed; }
/* The quiet button is a white block on a cream ground, so opacity alone thins its
   hairline border to nothing and the control stops reading as a control at all.
   It keeps its edge and goes grey instead. */
.btn-quiet:disabled { border-color: var(--color-ink-muted); color: var(--color-ink-muted); }

.btn-quiet {
  width: 100%;
  padding: var(--space-3) var(--space-5);
  font-size: 0.9375rem;
  font-weight: 500;
  font-family: inherit;
  letter-spacing: var(--track-body);
  margin-top: var(--space-2);
  border-radius: var(--r-md);
  background: var(--color-surface-alt);
  color: var(--color-ink);
  border: 1px solid var(--color-hairline);
  cursor: pointer;
  transition: background-color var(--dur-fast) ease, border-color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
.btn-quiet:active:not(:disabled) { transform: scale(0.97); }

/* The one first-class action on the scan screen. Taller, larger, and carrying an
   icon. Previously all three capture controls were the same full-width block
   and hierarchy rested on fill colour alone, which reads as three peers. */
.btn-hero { padding: var(--space-4) var(--space-5); font-size: 1.15rem; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); }
/* One icon class, sized per context, rather than a parallel style per button —
   same Lucide 24x24-viewBox / 2-stroke system as the chip icons in shared.js. */
/* .feature-icon was NOT in this group, and a Lucide icon with no fill:none is
   drawn as a filled blob: the three marks in "What Sift helps you check" on the
   scan screen rendered as solid black squares. An icon is its stroke, so the
   presentation attributes belong to every class that carries one, not to the
   ones that happened to be written at the same time. Pre-existing, and found by
   rendering the idle screen rather than by any of the icon tests, which read
   path data and cannot see a paint. */
.btn-icon, .control-icon, .tab-icon, .feature-icon { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
.control-icon { width: var(--icon-md); height: var(--icon-md); }
/* .tab-icon's size is NOT here -- see the chrome section above for why it has
   to be declared before the desktop block. */
.btn-hero .btn-icon { width: var(--icon-md); height: var(--icon-md); }
.link-btn .btn-icon { width: var(--icon-md); height: var(--icon-md); opacity: 0.85; }

/* The two alternatives, demoted from full-width buttons to one row of text
   actions. Removes ~110px of look-alike button from the entry screen and makes
   the hierarchy read 3:1:1. Padding keeps the touch target at 44px. */
.capture-alt { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.15rem var(--space-4); margin-top: var(--space-3); }
.capture-alt .sep { color: var(--color-accent); user-select: none; }
/* The dot separates two text actions on ONE line. The row wraps well before
   380px -- at 390 the second action is already on its own line with the dot
   left trailing at the end of the first -- so it goes at the width the wrap
   actually starts, not at the width the row finally runs out of room. */
@media (max-width: 480px) {
  .capture-alt .sep { display: none; }
  /* Stacked, the dot gone, the two alternatives were two centred lines of the
     same size and weight under one primary button: on the phone the capture
     card read as a list of three actions again, which is the shape the 3:1:1
     hierarchy replaced. Position can no longer carry the order once they wrap,
     so weight does. The live-camera path is the further one from "take a
     photo" and takes the lighter treatment. */
  .capture-alt > .link-btn:last-of-type { font-size: 0.875rem; font-weight: 400; }
}
.link-btn {
  width: auto;
  margin: 0;
  padding: var(--space-2) var(--space-1);
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  border: 0;
  background: none;
  font-family: inherit;
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--color-ink-muted);
  cursor: pointer;
  border-radius: var(--r-sm);
  text-decoration: underline;
  text-decoration-color: var(--color-hairline);
  text-underline-offset: 3px;
  transition: color var(--dur-fast) ease, text-decoration-color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
.link-btn:active { transform: scale(0.97); }
.link-btn:disabled { color: var(--color-ink-muted); cursor: not-allowed; opacity: 0.55; text-decoration-color: transparent; }

@media (hover: hover) and (pointer: fine) {
  .btn-primary:hover:not(:disabled) { background: var(--color-primary-dark); opacity: 0.9; }
  .btn-quiet:hover:not(:disabled) { background: var(--color-surface); opacity: 0.9; }
  .link-btn:hover:not(:disabled) { color: var(--color-primary); text-decoration-color: var(--color-primary); opacity: 0.9; }
}

/* --- chips --- */
/* --chip-accent is set per chip from shared.js (one home for the palette).
   Unselected chips hold the accent in the icon only; selecting tints the whole
   chip with the same hue so colour and icon read as one state, not two. */
.chip-list { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0.375rem var(--space-3);
  border-radius: var(--r-sm);
  border: 1px solid var(--color-hairline);
  background: var(--color-surface-alt);
  color: var(--color-ink);
  font-size: 0.8125rem;
  font-family: inherit;
  cursor: pointer;
  width: auto;
  margin: 0;
  transition: background-color var(--dur-fast) ease, color var(--dur-fast) ease, border-color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out);
}
.chip:active { transform: scale(0.97); }
.chip-icon { width: var(--icon-sm); height: var(--icon-sm); flex-shrink: 0; fill: none; stroke: var(--chip-accent, currentColor); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; opacity: 0.9; transition: opacity var(--dur-fast) ease; }
.chip.selected { background: color-mix(in srgb, var(--chip-accent, var(--color-primary)) 14%, var(--color-surface-alt)); border-color: var(--chip-accent, var(--color-primary)); color: var(--color-ink); font-weight: 500; }
.chip.selected .chip-icon { opacity: 1; }
/* A preference Sift stores but cannot check: Kosher and Halal are certification
   regimes, and a certificate is a property of a facility rather than of an
   ingredient list. The chip is an ordinary toggle, so this only changes how a
   TICKED one looks: it borrows .verdict-pill.diet-uncertain's shape from the
   result card, a dashed edge with no fill, instead of the solid accent wash that
   would claim Sift had confirmed something. Unticked there is nothing yet to
   qualify, so it is left alone. Both selectors carry .chip so they outrank
   .chip.selected and .chip.selected .chip-icon above rather than tying with them. */
.chip.chip-unverified.selected { background: var(--color-surface-alt); border-style: dashed; }
.chip.chip-unverified .chip-icon { opacity: 0.55; }

.chip-custom { border-style: dashed; padding-right: var(--space-1); cursor: default; }
/* The typed term IS the edit control (shared.js drawOne), so it has to look like
   the chip's own label and behave like a button. Two sibling buttons inside the
   chip rather than one wrapping button: a button cannot contain a button, and
   Remove has to stay independently reachable. */
.chip-edit { border: 0; background: none; padding: 0; margin: 0; width: auto; min-height: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; border-radius: 4px; }
/* A refused term says why, in the row it was refused in. .chip-list is a wrapping
   flex row, so 100% basis is what puts this on its own line under the chips
   instead of squeezing in beside them. */
.chip-error { flex-basis: 100%; margin: var(--space-2) 0 0; font-size: 0.8125rem; line-height: 1.45; color: var(--color-contains); }
/* 28px square tap box around an 18px X (the chip-icon size, so both marks in
   the chip draw the same 1.5px line). The negative block margin lets the box
   overhang the chip's padding instead of making the chip taller. */
.chip-remove { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; padding: 0; margin: -4px 0; width: 28px; height: 28px; min-height: 0; color: var(--color-ink-muted); cursor: pointer; border-radius: 5px; transition: color var(--dur-fast) ease, transform var(--dur-press) var(--ease-out); }
.chip-remove .control-icon { width: var(--icon-sm); height: var(--icon-sm); }
.chip-remove:active { transform: scale(0.9); }
/* .chip-add also carries the base .chip class (see shared.js), so it already
   gets .chip:active's press feedback below; no separate rule needed here. */
.chip-add { border-style: dashed; color: var(--color-ink-muted); }
@media (hover: hover) and (pointer: fine) {
  /* :not(.selected): a selected chip's border already carries its own accent
     colour (the fill IS the selected state), and border-color has the same
     specificity as .chip.selected -- without the guard, source order alone
     would decide whether hovering a selected chip quietly recoloured its
     border away from its own accent. Unselected only, border colour only:
     never background, or hovering a chip would read as ticking it. */
  .chip:hover:not(.selected) { border-color: var(--color-primary); }
  .chip-remove:hover { color: var(--color-contains); }
  .chip-edit:hover { text-decoration: underline; }
  .chip-add:hover { color: var(--color-ink); border-color: var(--color-primary); }
}
/* Specificity has to beat `.modal input[type=text]`, which is width:100%. */
.chip-list input.chip-input { padding: 0.375rem var(--space-2); margin: 0; border-radius: var(--r-sm); border: 1px solid var(--color-primary); background: var(--color-surface-alt); color: var(--color-ink); font-size: 0.8125rem; font-family: inherit; width: 11rem; box-sizing: border-box; }

/* --- allergen disclosure ---
   Native <details>: the open/closed state, the keyboard handling and the
   accessible name all come free, and it costs no JS. flex-basis 100% is what
   makes it break the chip row rather than sitting inline as a 17th chip. */
.chip-more { flex: 0 0 100%; margin-top: 0.15rem; }
.chip-more > summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0.375rem var(--space-3);
  border-radius: var(--r-sm);
  border: 1px dashed var(--color-hairline);
  color: var(--color-ink-muted);
  font-size: 0.8125rem;
  cursor: pointer;
  list-style: none;
  width: auto;
  transition: color var(--dur-fast) ease, border-color var(--dur-fast) ease;
}
/* Safari still needs the -webkit- pseudo to drop the disclosure triangle. */
.chip-more > summary::-webkit-details-marker { display: none; }
/* Own marker, so it can rotate. It was a filled CSS-border triangle, the one
   solid disclosure mark in an app whose every other disclosure is the outline
   Lucide chevron. Now it is that chevron (shared.js renderChips), at the chip
   tier like the "Add your own" mark beside it. A chevron that turns is the
   cheapest possible "this expands" signal and it survives reduced-motion as a
   static glyph. */
.chip-more > summary .chip-icon { transition: transform var(--dur-fast) var(--ease-out); }
.chip-more[open] > summary .chip-icon { transform: rotate(90deg); }
.chip-more[open] > summary { margin-bottom: 0.15rem; }
.chip-more .chip-list { margin-top: var(--space-2); }
@media (hover: hover) and (pointer: fine) {
  .chip-more > summary:hover { color: var(--color-ink); border-color: var(--color-primary); }
}
@media (prefers-reduced-motion: reduce) {
  .chip-more > summary .chip-icon { transition: none; }
}

/* --- verdict badge ---
   Was duplicated in history.html's page styles; index.html needs the same badge
   for the guest session strip now, so it lives here instead of being pasted a
   second time. The verdict class is qualified with .badge because index.html
   also uses the bare verdict names as TEXT colours on .verdict. An unqualified
   `.contains { background }` would bleed into that. */
/* Translucent tinted pill, same logic as .verdict.safe's muted card: a light
   wash of the verdict colour behind solid-colour text and border, rather than
   a solid fill with white text. */
.badge {
  display: inline-block;
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: var(--track-caps);
  padding: var(--space-1) var(--space-2);
  border-radius: var(--r-sm);
  white-space: nowrap;
  background: color-mix(in srgb, var(--badge-color) 9%, var(--color-surface-alt));
  color: var(--badge-color);
  border: 1px solid color-mix(in srgb, var(--badge-color) 40%, transparent);
}
.badge.contains { --badge-color: var(--color-contains); }
.badge.may_contain { --badge-color: var(--color-may-contain); }
.badge.caution { --badge-color: var(--color-caution); }
.badge.no_allergens_detected { --badge-color: var(--color-primary-dark); }
.badge.safe { --badge-color: var(--color-primary-dark); }

/* --- card sectioning ---
   Introduced on the scan screen (index.html): a tinted "here's the situation"
   card plus a lifted-white "here's the thing to do" card, instead of headings
   and controls sitting bare on the page background. Shared here once a second
   page (profile.html) needed the same two shapes. */
.hero-card {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  margin: 0 0 var(--space-4);
  padding: var(--space-4);
  border-radius: var(--r-card);
  background: var(--color-wash);
  /* The edge every other surface in the app carries. Without it this card is a
     10% slate tint sitting in the strongest part of the page's own slate wash,
     with no border and no shadow: it had no visible edge at all, while the
     white .action-card 24px below it read crisply. That inverted the intended
     order, since this is the card that sets the scene. */
  border: 1px solid var(--color-hairline);
  --whale-size: 40px;
}
/* A flex item's default min-width is auto (its content's intrinsic minimum),
   not 0 — index.html's prose wraps on spaces long before that matters, but an
   unbroken token (an email address, on profile.html) has no wrap point at all
   and pushed the card wider than its own border on a narrow phone instead of
   wrapping. min-width: 0 lets the item actually shrink to the card's width;
   overflow-wrap covers a token still too long even then. */
/* .whale's own tile is --color-surface-alt (opaque white), painted for the two
   other places the mascot appears, both on white/near-white surfaces. Here it
   sits on .hero-card's translucent wash instead, so the white tile read as a
   hard-edged square cut into the tint. Flattened to the same ground the
   topbar's own wash-matched strip already uses (this file's opaque-over-wash
   idiom, not a new literal): --color-primary is the wash's own rgb, mixed at
   the wash's own 10% into the page ground the card sits on. */
.hero-card .whale { background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg)); }
.hero-card > *:last-child { min-width: 0; overflow-wrap: break-word; }
.hero-card p { margin: 0; font-size: 0.9375rem; line-height: 1.5; color: var(--color-ink); }
.hero-card p + p { margin-top: var(--space-1); font-size: 0.85rem; color: var(--color-ink-muted); }
.hero-card strong { font-weight: 600; }

.action-card, .state-panel, .scan-item {
  /* The card padding of the entire app, and it used to be 14px: 2px off the
     scale, and 2px off .hero-card directly above it, which is var(--space-4).
     The two cards sit one under the other on the scan screen. */
  padding: var(--space-4);
  border-radius: var(--r-card);
  border: 0.5px solid var(--color-hairline-warm);
  background: var(--color-surface-alt);
  box-shadow: var(--shadow-rest);
}

/* --- mascot --- */

/* One class for both appearances of the whale, so it reads as the same
   character each time rather than two differently-cropped images. It shows up
   three times: greeting you in the scan screen's hero card, at the top of the
   sign-in modal, and beside a Safe verdict. It ran to four appearances once, at
   which point it had stopped being a character and become wallpaper, so three is
   already close to the line and the next one has to displace one of these.
   The rounded tile IS the treatment, held over from the opaque logo.jpg this
   used to point at. The flat PNGs that replaced it carry a transparent
   background, and with object-fit: cover a 600x600 whale in a 40px tile was
   cropped to a fragment of beak and eye: at every in-app size it did not read
   as a whale, or as anything. It is now object-fit: contain, and the tile is
   painted rather than implied, so the mark sits INSIDE the treatment instead of
   being cut by it. Tile, radius, shadow and size are all unchanged; only the
   fit and the fill are new.
   Sized in a token so neither appearance can creep back to hero size: the
   personality comes from recurring, not from being big.
   Always decorative: alt="" and aria-hidden, because both of these sit beside
   text that already carries the meaning. */
.whale {
  width: var(--whale-size, 44px);
  height: var(--whale-size, 44px);
  /* The tile the shadow was always implying. Without it, a transparent PNG at
     object-fit: contain leaves the rounded shadow sitting under nothing. */
  padding: calc(var(--whale-size, 44px) * 0.06);
  box-sizing: border-box;
  border-radius: var(--r-md);
  background: var(--color-surface-alt);
  object-fit: contain;
  flex-shrink: 0;
  box-shadow: var(--shadow-rest);
}

/* outline, not box-shadow: it follows border-radius the same way, doesn't
   fight a button's own box-shadow (--shadow-cta etc.), and survives
   forced-colors mode, where a box-shadow ring is suppressed outright.
   outline-offset is animatable, so the ring expands 0->3px on focus-in. */
:focus-visible {
  outline: 3px solid var(--color-primary-dark);
  outline-offset: 3px;
  transition: outline-offset var(--dur-fast) var(--ease-out);
}

/* --- accessibility preferences --- */
/* Gentler, not zero: opacity and colour stay, movement goes. */
@media (prefers-reduced-motion: reduce) {
  .btn-primary:active:not(:disabled),
  .btn-quiet:active:not(:disabled),
  .topbar button:active:not(:disabled),
  .chip:active,
  .nav-pill:active,
  .link-btn:active,
  .chip-remove:active,
  .wiz-link:active { transform: none; }
}

@media (prefers-contrast: more) {
  .topbar { border-bottom-color: var(--color-ink-muted); }
  .btn-quiet, .chip { border-color: var(--color-ink-muted); }
}

/* Privacy notices and policies share the app's existing reading column. */
.privacy-note, .consent { font-size: 0.875rem; line-height: 1.6; color: var(--color-ink); }
.privacy-note { margin: var(--space-4) 0; }
.privacy-note p { margin: var(--space-3) 0; }
.privacy-note a, .policy a, .wiz-lede a { color: var(--color-primary-dark); text-underline-offset: 3px; }
.consent { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) 0; cursor: pointer; }
/* The gate on the primary flow was the one control in the app still drawing
   the platform default: a heavy square with a system border, next to chips,
   pills, segments and buttons that are all on the token system. appearance:
   none hands the box over, accent-color no longer applies once it is gone, so
   the checked state is painted here in the same brand blue. The tick is drawn
   with a border rather than a glyph so it scales with the box and needs no
   font. :focus-visible is untouched: the app's one focus rule already covers
   this element. */
.consent input[type=checkbox] {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem;
  margin: 0.2rem 0 0;
  border: 1.5px solid var(--color-primary);
  border-radius: var(--r-sm);
  background: var(--color-surface-alt);
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.consent input[type=checkbox]:checked {
  border-color: var(--color-primary-dark);
  background: var(--color-primary-dark);
}
.consent input[type=checkbox]:checked::after {
  content: '';
  position: absolute;
  left: 32%; top: 14%;
  width: 26%; height: 52%;
  border: solid var(--color-surface-alt);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
@media (prefers-reduced-motion: reduce) {
  .consent input[type=checkbox] { transition: none; }
}
.consent span { min-width: 0; }
/* The one-time consent gate (#consent-dialog). Native <dialog> already centres
   itself and supplies the backdrop under showModal(), so this sets the reading
   width and the surface and nothing else. */
/* border-box, and it is load-bearing: a native <dialog> is content-box by
   default, so the declared width had 24px of padding either side plus the
   border added OUTSIDE it. On a 390px phone that rendered 402.4px wide at
   left: 0 and the right edge was clipped off-screen, on the first screen a new
   reader ever sees. */
.consent-dialog { box-sizing: border-box; width: min(92vw, 30rem); padding: var(--space-5); border: 1px solid var(--color-hairline); border-radius: var(--r-md); background: var(--color-surface); color: var(--color-ink); }
.consent-dialog::backdrop { background: rgb(20 25 31 / 0.62); }
.consent-dialog h2 { margin: 0 0 var(--space-3); font-family: var(--font-display); font-size: 1.2rem; line-height: 1.3; }
/* The login modal's wash band, pulled out to the dialog's padding edge. The
   radius sits 1px inside the dialog's own so it meets the border cleanly. */
.consent-head { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); margin: calc(var(--space-5) * -1) calc(var(--space-5) * -1) var(--space-4); padding: var(--space-5); background: var(--color-wash); border-radius: calc(var(--r-md) - 1px) calc(var(--r-md) - 1px) 0 0; }
.consent-head h2 { margin: 0; }
.consent-head-icon { width: var(--icon-lg); height: var(--icon-lg); color: var(--color-primary-dark); fill: none; stroke: currentColor; stroke-width: var(--icon-lg-stroke); stroke-linecap: round; stroke-linejoin: round; }
.consent-dialog p { margin: 0; font-size: 0.875rem; line-height: 1.55; }
.consent-dialog .consent { margin-top: var(--space-3); padding: var(--space-1) 0; }
.consent-dialog .btn-primary { width: 100%; margin-top: var(--space-5); }
/* Why the capture buttons are dead, for the reader who dismissed the dialog
   without answering. Muted and one line: this is a state note, not the
   disclosure, which stays in the dialog and on /privacy. */
.capture-consent-note { margin: 0 0 var(--space-4); font-size: 0.875rem; line-height: 1.55; color: var(--color-ink-muted); }
.capture-consent-note .link-btn { font-size: inherit; }
/* Against the buttons they qualify, not in the intro paragraph three elements
   up. Muted, because they are a standing limit rather than this scan's news.
   Two icon rows, each sentence in full (design audit, fifth pass E2). Up from
   0.75rem: the privacy disclosure has to stay fully legible, and the nutrition
   hint above no longer needs the size gap to stand apart, since it now sits in
   its own filled row. #5A6470 on white is about 5.9:1. */
.capture-limits { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-4) 0 0; font-size: 0.8125rem; line-height: 1.5; color: var(--color-ink-muted); }
.capture-limit { display: flex; align-items: flex-start; gap: var(--space-2); }
.capture-limit .btn-icon { width: var(--icon-md); height: var(--icon-md); margin-top: 0; }
/* The one footer treatment, used by every page that carries the policy links:
   index, history, profile, privacy, terms. They were page-accent links at body
   size flush to the left margin, which made them read as three more actions in
   the flow rather than as the page's standing legal row. Muted, centred and
   small instead, with the underline kept on hover and focus so losing the link
   colour does not also lose the affordance. Padding stays: these are the
   smallest tap targets on the page and 0.5rem is what carries them to 44px.
   (result-reference.html is deliberately not in this set. It is an internal
   design reference, not a page a reader is ever served.) */
.legal-footer { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 var(--space-5); margin-top: 2.5rem; padding-top: var(--space-5); border-top: 1px solid var(--color-hairline); font-size: 0.8125rem; line-height: 1.5; }
.legal-footer a { padding: var(--space-2) 0; color: var(--color-ink-muted); text-decoration: none; transition: color var(--dur-fast) var(--ease-out); }
.legal-footer a:focus-visible { color: var(--color-ink); text-decoration: underline; text-underline-offset: 3px; }
@media (hover: hover) and (pointer: fine) {
  .legal-footer a:hover { color: var(--color-ink); text-decoration: underline; text-underline-offset: 3px; }
}
.panel-legal-footer { margin-top: var(--space-5); padding-top: var(--space-4); }
/* The consent dialog's own policy row takes the same treatment, so the two
   policies read as a footer there too. Only the top margin differs: 2.5rem is
   the gap that separates a footer from a whole page of content, and inside a
   panel it would just be a hole. */
.consent-links { margin-top: var(--space-5); padding-top: var(--space-4); }
.policy { max-width: var(--doc-measure, 70ch); margin: 0 auto; overflow-wrap: anywhere; }
.policy h2 { margin-top: var(--space-6); font-size: 1.25rem; line-height: 1.3; }
.policy h3 { font-size: 1.05rem; line-height: 1.4; }
.policy li + li { margin-top: var(--space-2); }
/* How it works: the two halves of a scan as two rows, each led by its own
   glyph (Lucide scan-text, list-checks), so "two steps" is seen, not counted. */
.how-steps { display: grid; gap: var(--space-3); margin: var(--space-4) 0; padding: 0; list-style: none; }
.policy .how-steps li { display: grid; grid-template-columns: calc(var(--icon-md) + 2 * var(--space-2)) minmax(0, 1fr); gap: var(--space-3); align-items: start; margin: 0; padding: var(--space-4); border-radius: var(--r-md); background: var(--color-surface); border: 1px solid var(--color-hairline); }
.how-steps p { margin: 0; }
/* The same tile as the scan screen's .feature-icon: an md glyph in a wash
   square with --space-2 around it. It was a 24px glyph in a 40px box, one step
   heavier than every other interface icon. */
.how-step-icon { box-sizing: content-box; width: var(--icon-md); height: var(--icon-md); padding: var(--space-2); border-radius: var(--r-sm); background: var(--color-wash); color: var(--color-primary-dark); fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.policy-review { padding: var(--space-4); border: 1px solid var(--color-primary-dark); border-radius: var(--r-sm); background: var(--color-surface); }
.policy section, #main, .policy h2[id], .policy h3[id] { scroll-margin-top: 8rem; }
/* "On this page": the long policies' own contents, so a reader looking for
   deletion or contact goes there instead of scrolling the whole notice. */
.doc-toc { margin: var(--space-5) 0; padding: var(--space-4); border: 1px solid var(--color-hairline); border-radius: var(--r-md); background: var(--color-surface); }
.doc-toc-title { margin: 0 0 var(--space-2); font-size: 0.875rem; font-weight: 600; color: var(--color-ink-muted); }
.doc-toc ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0; }
.policy .doc-toc li + li { margin-top: 0; }
.doc-toc a { display: flex; align-items: center; min-height: 44px; color: var(--color-primary-dark); text-underline-offset: 3px; }
.skip-link { position: fixed; top: 0.5rem; left: 0.5rem; z-index: 30; padding: var(--space-3); color: var(--color-ink); background: var(--color-surface-alt); transform: translateY(-200%); }
.skip-link:focus { transform: none; }
.chip-list input.chip-input { max-width: 100%; }
#editing-email { overflow-wrap: anywhere; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
