/* ff-theme.css — the shared stylesheet.

   Before this file there were fourteen copies of the token block across seventeen
   pages, in four mutually different variants, and .card / .btn / .topbar / .rail
   redefined in ten to thirteen places. Only forty-eight CSS lines were byte-identical
   across all ten app screens: the copies had already drifted, which is the proof that
   they drift. Everything below is declared once.

   Load order matters and is the whole contract: this file is linked ABOVE each page's
   <style> block, so a page-local rule still wins on order alone. What is left in a
   page block after extraction is genuinely page-specific and stays there.

   The visual language is design-language.html v2 — measured off foreflux.ai for
   palette, type and voice, with frost surfaces, ambient canvas wash, far-throw
   elevation, mono numerals and visible key caps ported as posture from
   lineaprompt.com. No new hex enters the system. Every difference from v1 is alpha,
   blur, shadow throw, texture, radius laddering and typeface role. */

/* ── IBM Plex Mono, self-hosted ───────────────────────────────────────────────
   Latin subset only (U+0000–00FF), three weights, ~45 KB total. The mono face
   only ever sets numerals, units, keys and caps labels, so the rest of the Google
   subsets are weight this app already paid for once in 4ee6378 perf(boot) and is
   not paying again. Self-hosted rather than CDN for the same reason: a cold start
   here does not get to spend a DNS lookup and a TLS handshake on a third party.

   font-display:optional, not swap. This read `swap` so the numerals would render in the
   fallback stack immediately — right about not blocking, wrong about what happens next.
   `swap` guarantees a second layout: this face sets every numeral in every table, and its
   advance is 0.600em against Consolas' 0.550em, so on Windows each column re-measures ~9%
   narrower the moment the woff2 lands. `optional` keeps the immediate paint and drops the
   swap-in, so a cold load never reflows; the browser fetches the file in the background and
   every load after this one has it. The trade is one uncached, slow first pageview rendering
   in the fallback mono, which for a 15 KB same-origin subset in a tool people open daily is
   the cheaper half. The exact answer is a size-adjust fallback @font-face per platform
   default (Menlo / Consolas / DejaVu Sans Mono), which needs those metrics measured on each
   platform rather than guessed. docs/layout-stability-audit.md L6. */
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: optional;
  src: url("fonts/plex-mono-400.woff2?v=08949f72") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: optional;
  src: url("fonts/plex-mono-500.woff2?v=01d28544") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: optional;
  src: url("fonts/plex-mono-600.woff2?v=0d1f0b8d") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ─── tokens — the one canonical block ────────────────────────────────────────
   Reconciled from the four drifted variants by taking the widest of each: --fs-3xl
   and --fs-4xl were on some screens and not others, --accent-soft only on the
   dashboard, --well only on the app shell. Nothing was dropped.

   Three things this block corrects outright:

   1. --meta was rgba(28,28,28,0.4). On the old cream #f7f4ed canvas that composites to
      rgb(111,110,107) → 2.46:1, and the AA floor for body and UI text is 4.5:1.
      It is what every section label and every piece of metadata on the app is set
      in, 53 times over. At 0.62 it composites to 4.65:1 and passes. This is not a
      style change, it was a failing contrast ratio on thirteen pages.

   2. Elevation throws far and lands soft. The old --elev-panel ended at 28px of
      blur with -20px spread; the measured v2 value runs to 70px at -46px. The
      negative spread and the blue-slate ink are unchanged — only the falloff
      carries further, and that distance is what makes a panel read as hovering
      over paper rather than glued to it.

   3. --font-mono leads with a real face. It was a fallback stack with no webfont
      behind it, so the single loudest signal in the language — every quantity,
      delta, SKU and timestamp set in mono — was rendering in whatever the OS had.

   --well and --inset-control stay. The v2 specimen has no recessed-control tier;
   the third plane is a ForeFlux invention, documented on the dashboard, and it is
   not a v1 leftover to delete. */
:root {
  /* palette — the seven roles, unchanged */
  --bg: #ffffff;
  --surface: #ffffff;
  --fg: #1c1c1c;
  --fg-2: rgba(28, 28, 28, 0.83);
  --muted: #5f5f5d;
  --meta: rgba(28, 28, 28, 0.62);
  --border: rgba(37, 50, 72, 0.055);
  --border-weak: rgba(61, 77, 99, 0.13);
  /* The logo blue measured off the official ver-2 marks. #2563eb was a near-miss
     carried by the app shell alone — index.html and the design system both
     reconciled to #156bff on 2026-08-08 and label the old value explicitly so an
     audit can find it. --accent-legacy is kept for exactly that reason. */
  --accent: #156bff;
  --accent-legacy: #2563eb;
  /* #156bff is 4.18:1 on the old cream canvas — below the 4.5:1 text floor. Same
     fill-vs-type split the status hues already carry below: fills, strokes and
     rings keep --accent, anything setting `color` takes --accent-ink. */
  --accent-ink: #1260e5;   /* 4.99:1 on --bg, 5.30:1 on --surface */
  --accent-dark: color-mix(in oklab, var(--accent), black 30%);
  /* The second brand hue. Violet carries the mark, the gradient and the brand
     surface, and it has taken over every DECORATIVE wash and splash from amber
     so each surface reads blue in one corner and violet in the other. It is
     still never a UI accent, a chart series or a status colour — --warn keeps
     its semantic status job unchanged. */
  --violet: #b100ff;
  --spark: #19f2f2;
  --violet-ink: #9f00e5;   /* 5.30:1 on the old cream --bg; higher on white */
  /* Brand names, unused in the app today (§8) — kept for parity with the
     design system so the next audit is a grep, not a re-measure. */
  --bg-brand: #ffffff;
  --grad-brand: linear-gradient(135deg, #b100ff 0%, #156bff 100%);
  --grad-brand-mesh:
    radial-gradient(120% 120% at 0% 0%, #7e23ff 0%, transparent 62%),
    radial-gradient(120% 120% at 100% 0%, #9b0fff 0%, transparent 62%),
    radial-gradient(120% 120% at 0% 100%, #2263ff 0%, transparent 62%),
    radial-gradient(120% 120% at 100% 100%, #7529ff 0%, transparent 62%),
    #5b1fff;
  --success: #16a34a;
  --warn: #eab308;
  --danger: #dc2626;
  --btn-fg: #fcfbf8;
  --on-accent: #fff;   /* text on solid --accent: 4.59:1. --btn-fg (#fcfbf8) drops under 4.5 there. */
  --tint-4: rgba(28, 28, 28, 0.04);
  /* One step up, for a row hover that has to be seen (29b-2): --tint-4 on white is about
     1.08:1 from rest and vanishes down a long list; Grafana steps a row ~1.3:1. Ink at 8%,
     a color-mix of --fg, so the No-New-Hex Rule holds. */
  --tint-8: color-mix(in oklab, var(--fg) 8%, transparent);
  /* The focus ring was rgba(59,130,246,0.5) — 1.78:1 on the canvas, against a 3:1
     floor for non-text UI. WCAG 1.4.11 and 2.4.7 both fail on that value, and 2.4.13
     needs the focused and unfocused states to differ by 3:1 as well. Solid accent
     measures 4.71:1. index.html shipped this fix in its own --focus-ring; the app
     shell never got it. */
  --ring: var(--accent);
  /* Status hues are correct as FILLS at the 3:1 non-text floor and wrong as TYPE at
     4.5:1: --warn is 1.75:1 on the canvas, --success 3.00:1, --danger 4.40:1. The
     ink variants are the same hue carried far enough to be read. Fill keeps the raw
     token, type takes the ink. Ratios measured on the old cream --bg #f7f4ed, sRGB
     relative luminance, WCAG 2.2 AA; the canvas is now #ffffff, so these are the
     floor — the same three values index.html already ships. */
  --warn-ink:    color-mix(in oklab, var(--warn), black 38%);     /* 1.75:1 → 5.7:1 */
  --success-ink: color-mix(in oklab, var(--success), black 22%);  /* 3.00:1 → 5.4:1 */
  --danger-ink:  color-mix(in oklab, var(--danger), black 12%);   /* 4.40:1 → 5.9:1 */
  --scrim: rgba(28, 28, 28, 0.28);
  --accent-soft: color-mix(in oklab, var(--accent) 10%, transparent);
  /* The picked segment (V2b). Opaque, so --accent-ink on it is 4.79:1 on a grey track too;
     --accent-soft composited over --tint-4 measured 4.45:1, under AA. */
  --accent-pick: color-mix(in srgb, var(--accent) 10%, var(--surface));

  /* frost, not paint — three weights, so stacked surfaces read as depth
     instead of as the same fill repeated */
  --frost-strong: rgba(255, 255, 255, 0.72);
  --frost: rgba(255, 255, 255, 0.62);
  --frost-soft: rgba(255, 255, 255, 0.52);
  --frost-promoted: rgba(255, 255, 255, 0.94);  /* the promoted row only */
  /* blur and saturation rise together: blur without saturate turns the warm
     white grey and dead */
  --blur-chrome: blur(8px) saturate(1.06);
  --blur: blur(20px) saturate(1.1);
  --blur-over: blur(30px) saturate(1.2);

  /* texture. The page grain is tinted to the SHADOW INK rgb(37,50,72) and
     multiplied — grey noise on a warm off-white canvas turns it muddy, blue-slate
     noise reads as paper fibre lit from the same direction as the shadows. The
     panel tooth is white, finer (higher baseFrequency) and lighter; that
     inversion is what stops frost from reading as flat plastic. */
  --grain-page: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.145 0 0 0 0 0.196 0 0 0 0 0.282 0 0 0 .12 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  --grain-panel: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)' opacity='.075'/%3E%3C/svg%3E");

  /* the ambient canvas wash, named so body's background is a one-line lookup */
  --wash-page:
    radial-gradient(820px 520px at 14% -6%, rgba(255, 255, 255, 0.85), transparent 62%),
    radial-gradient(680px 460px at 88% 8%, rgba(177, 0, 255, 0.05), transparent 64%),
    radial-gradient(760px 560px at 46% 108%, rgba(21, 107, 255, 0.09), transparent 60%);

  /* splash grammar — three tiers, sized and placed by what carries them.
     Never a mid-size splash floating in the middle of a surface. */
  --sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.86), transparent 38%);
  --splash-card:
    radial-gradient(520px 260px at 82% 14%, rgba(21, 107, 255, 0.11), transparent 64%),
    radial-gradient(440px 280px at 12% 100%, rgba(177, 0, 255, 0.07), transparent 66%);
  --splash-ctl: radial-gradient(260px 160px at 90% 20%, rgba(21, 107, 255, 0.16), transparent 70%);

  /* depth — negative spread throughout, so the shadow tucks under the panel and
     contacts the page at its base instead of haloing all four sides */
  --inset:
    rgba(255, 255, 255, 0.2) 0 0.5px 0 0 inset,
    rgba(0, 0, 0, 0.2) 0 0 0 0.5px inset,
    rgba(0, 0, 0, 0.05) 0 1px 2px 0;
  --float:
    0 1px 2px -1px rgba(37, 50, 72, 0.20),
    0 6px 14px -10px rgba(37, 50, 72, 0.34);
  --elev-ring: 0 0 0 1px var(--border);
  --elev-panel:
    0 1px 2px -1px rgba(37, 50, 72, 0.16),
    0 26px 70px -46px rgba(37, 50, 72, 0.46);
  --elev-raised:
    0 2px 5px -2px rgba(37, 50, 72, 0.20),
    0 34px 96px -48px rgba(37, 50, 72, 0.55);
  --elev-promoted: 0 22px 58px -46px rgba(37, 50, 72, 0.62);
  --elev-active: 0 18px 44px -30px rgba(21, 107, 255, 0.74);
  --elev-overlay: 0 18px 40px -28px rgba(28, 28, 28, 0.45);
  --bevel-top: inset 0 1px 0 rgba(255, 255, 255, 0.9);
  /* The third plane. --bg is the canvas, --surface is a panel raised off it, and
     --well is a control cut INTO whichever of those it sits in. */
  --well: #ffffff;
  --inset-control: inset 0 1px 2px rgba(37, 50, 72, 0.055);

  --font-display: "Camera Plain Variable", "Camera Plain", ui-sans-serif, system-ui, sans-serif;
  --font-body: "Camera Plain Variable", "Camera Plain", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace;   /* Cascadia before Consolas: lighter strokes if Plex misses (round 10) */

  --fw-body: 400;
  --fw-display: 480;   /* hero headlines only — at wordmark size it goes soft */
  --fw-heading: 600;   /* the ceiling. 700 exists nowhere in the system */

  --fs-xs: 12px; --fs-sm: 14px; --fs-body: 16px; --fs-lg: 18px;
  --fs-xl: 20px; --fs-2xl: 36px; --fs-3xl: 48px; --fs-4xl: 60px;
  --fs-display: clamp(44px, 9vw, 104px);
  --fs-h2: clamp(32px, 5vw, 72px);
  --fs-figure: clamp(56px, 7vw, 88px);
  --lead-tight: 1.1; --lead-body: 1.5; --lead-figure: 0.95;
  --track-display: -0.025em; --track-figure: -0.03em; --track-caps: 0.08em;

  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 20px; --s6: 24px; --s8: 32px; --s12: 48px;
  --tap-min: 44px;
  /* radius steps down concentrically: shell 16 → card 12 → row 10 → control 6.
     A child that shares its parent's radius reads as a misaligned overlay. */
  --r-sm: 6px; --r-md: 12px; --r-lg: 16px; --r-row: 10px; --r-pill: 9999px;
  --rail: 232px;
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);
  /* --ease-move is the travel curve already load-bearing everywhere something
     slides/crossfades in place (the rail-pill view-transition morph and
     qfIndicator's travelling tab/chip underline, both in ff-data.js). Every one
     of those sites already reads var(--ease-move, cubic-bezier(0.77,0,0.175,1))
     — this names the value instead of leaving it an orphaned fallback default. */
  --ease-move: cubic-bezier(0.77, 0, 0.175, 1);
  /* Named durations — kit.css already ships against these, and every transition
     in this file now reads one of them instead of an inline .15s/.2s/.24s. Same
     numbers, so nothing moved on screen; what changed is that the next audit is
     a grep. The one-off timings that have no name (.16s, .18s, .3s, .5s and the
     stagger delays) are left as literals rather than invented into tokens. */
  --dur-fast: 150ms; --dur-base: 200ms; --dur-panel: 240ms; --dur-crossfade: 280ms;
  /* --dur-enter/--dur-exit name the asymmetric reveal/collapse pair that already
     recurs, unnamed, as ff-data.js's own var(--motion-base,.22s)/
     var(--motion-exit,.13s) fallback vocabulary (qfText, qfSwap, .ff-reveal) and
     as hardcoded .22s/.13s literals in support.html, auth.html and
     onboarding.html. A disclosure/toast/message opens slower than it closes on
     purpose; these are for that pair specifically — not a substitute for
     --dur-panel (dialog/drawer open) or --dur-crossfade (value crossfade). */
  --dur-enter: 220ms; --dur-exit: 130ms;
  /* --delay-stagger is the 60ms unit the section-rise stagger below already
     uses (as does kit.css's own copy, and index.html's independent
     `calc(var(--d,0)*60ms)` reveal stagger) without ever naming it. */
  --delay-stagger: 60ms;

  /* ── accordion (disclosure) — component mapping ─────────────────────────
     The accordion owns NAMES, not numbers. Every value below resolves to a
     token already declared above, so a change to the brand surface reaches the
     component for free and an audit of "what colour is an accordion" is a grep
     of this block rather than a read of the component's rules.

     Source of the values, in the order they were read:
       new-design-system-foreflux/brand-surface.html   the brand surface itself
       new-design-system-foreflux/colors_and_type.css  palette, ink, type scale
       new-design-system-foreflux/tokens.css           spacing, radius, elevation, motion
     Those three files are the extracted statement of this :root block (they say
     so in their own headers), so mapping onto the tokens above IS mapping onto
     the brand surface — there is no second set of numbers to reconcile.

     index.html carries the same --accordion-* names mapped onto the landing's
     own --space-*/--radius-*/--motion-* vocabulary, next to its FAQ rules. Two
     vocabularies, one contract, which is the split this repo already runs (see
     the @font-face note at the top of index.html) rather than a new one.

     Three choices worth stating, because they are not arbitrary:
       radius   follows the concentric ladder — the group is a card (12), an
                item inside it is a row (10). Equal radii read as a misaligned
                overlay, which is the note on --r-* above.
       motion   is the asymmetric disclosure pair --dur-enter/--dur-exit: a
                panel opens slower than it closes. Because both are duration
                tokens, the reduced-motion block below collapses them without
                the accordion needing its own media query.
       leading  --accordion-title-leading is the one number here with no token
                behind it. A wrapping question wants more air than --lead-tight
                (1.1, sized for display type) and less than --lead-body (1.5,
                sized for prose); 1.35 is that gap, and it is named here so no
                accordion rule ever carries a bare line-height. */
  --accordion-bg: var(--surface);
  --accordion-bg-hover: var(--tint-4);
  --accordion-border: var(--border);
  --accordion-border-hover: var(--border-weak);
  --accordion-radius: var(--r-md);
  --accordion-item-radius: var(--r-row);
  --accordion-gap: var(--s2);
  --accordion-padding: var(--s4) var(--s5);
  --accordion-title-gap: var(--s4);
  --accordion-panel-padding: 0 var(--s5) var(--s5);
  --accordion-shadow: var(--float);
  --accordion-ring: var(--ring);
  --accordion-min-target: var(--tap-min);
  --accordion-title-font: var(--font-display);
  --accordion-title-size: var(--fs-body);
  --accordion-title-weight: var(--fw-heading);
  --accordion-title-leading: 1.35;
  --accordion-title-color: var(--fg);
  --accordion-title-color-muted: var(--fg-2);
  --accordion-panel-size: var(--fs-sm);
  --accordion-panel-leading: var(--lead-body);
  --accordion-panel-color: var(--muted);
  --accordion-link-color: var(--fg);   /* a link inside the panel, against --accordion-panel-color */
  --accordion-marker-color: var(--meta);
  --accordion-marker-font: var(--font-mono);
  --accordion-transition-duration: var(--dur-enter);
  --accordion-transition-duration-exit: var(--dur-exit);
  --accordion-easing: var(--ease-standard);

  /* ── dropdown (single-select listbox) — component mapping ───────────────
     Names, not numbers, the same arrangement as the accordion block above.
     Every value here was read off one of the three overlay controls this repo
     already ships — .branchpick and .qfmenu (this file) and .ff-pop
     (ff-data.js) — so the dropdown is the fourth member of that family rather
     than a fourth look. Spec and the reason behind each choice:
     docs/dropdown-spec.md. Component: qfSelect() in ff-data.js.

     Two values carry no token behind them and are documented in place:
       --dropdown-trigger-border-hover is the accent-at-24% tint that
         .qfmenu-trigger, .branchpick-trigger, .ff-pop-trigger, .searchbox and
         input.ctl[type=search] all already write as a literal rgba;
       --dropdown-menu-max-height is calc()'d from --tap-min, so it is a token
         expression rather than a number: seven 44px rows fill and the eighth
         is cut mid-row, which is what tells a reader the list scrolls. */
  --dropdown-trigger-bg: var(--well);
  --dropdown-trigger-border: var(--border);
  --dropdown-trigger-border-hover: rgba(21, 107, 255, 0.24);
  --dropdown-trigger-shadow: var(--inset-control);
  --dropdown-trigger-radius: var(--r-sm);
  --dropdown-trigger-padding: 7px var(--s3);
  --dropdown-trigger-gap: var(--s2);
  --dropdown-trigger-size: var(--fs-sm);
  --dropdown-trigger-color: var(--fg);
  --dropdown-caret-color: var(--meta);
  --dropdown-caret-size: 16px;
  --dropdown-min-target: var(--tap-min);

  --dropdown-offset: 6px;               /* trigger → menu gap, as all three panels use */
  --dropdown-menu-bg: var(--surface);
  --dropdown-menu-border: var(--border-weak);
  --dropdown-menu-radius: var(--r-md);
  --dropdown-menu-shadow: var(--elev-overlay);
  --dropdown-menu-padding: var(--s2);
  --dropdown-menu-max-height: calc(var(--tap-min) * 7);
  --dropdown-menu-z: 20;

  --dropdown-option-radius: var(--r-row);
  --dropdown-option-padding: var(--s2) var(--s3);
  --dropdown-option-gap: var(--s3);
  --dropdown-option-size: var(--fs-sm);
  --dropdown-option-color: var(--fg);
  --dropdown-option-bg-hover: var(--tint-4);
  --dropdown-option-bg-selected: var(--accent-soft);
  --dropdown-option-tick-color: var(--accent-ink);
  --dropdown-option-note-font: var(--font-mono);   /* a code or a quantity beside the label */
  --dropdown-option-note-size: 11px;
  --dropdown-option-note-color: var(--meta);
  --dropdown-option-disabled-opacity: 0.4;

  --dropdown-ring: var(--ring);
  --dropdown-easing: var(--ease-standard);
  --dropdown-duration: var(--dur-fast);
}

/* Reduced motion as policy, not per-page opt-in: collapsing the shared duration
   tokens here means any surface built from var(--dur-*)/var(--delay-stagger) —
   present or future — inherits the reduction for free, without needing its own
   blanket `*{transition-duration:.01ms!important}` rule. This does not touch
   ff-data.js's own reduced-motion block (qfText, qfSwap, .ff-reveal, the
   rail-pill group, toasts, etc.) — that block makes its own considered
   per-component choice between a hard cut and a shortened-but-present crossfade
   (qfText keeps its opacity fade at a hardcoded 120ms and only drops the width
   interpolation) and applies via !important, so it always wins regardless of
   what these tokens resolve to. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 1ms; --dur-base: 1ms; --dur-panel: 1ms; --dur-crossfade: 1ms;
    --dur-enter: 1ms; --dur-exit: 1ms; --delay-stagger: 0ms;
  }
}

/* ─── base ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

/* The app is a full-page vertical shell; it should contain its own overscroll
   rather than handing a pull at the top to the browser's pull-to-refresh /
   rubber-band, which fights the sticky header. */
html, body { overscroll-behavior-y: none; }
/* The canvas carries an ambient wash, not a gradient — four wide radials pinned
   off-centre at 6–10% over flat --bg. Never a linear gradient, never a mesh,
   never enough to be nameable as a colour. background-attachment:fixed so it
   belongs to the viewport and does not scroll with a long inventory list. */
body {
  margin: 0; color: var(--fg);
  font-family: var(--font-body); font-size: var(--fs-body); font-weight: 400;
  line-height: var(--lead-body); -webkit-font-smoothing: antialiased;
  background: var(--wash-page), var(--bg);
  background-attachment: fixed;
}
/* Two composited layers, viewport-sized, pointer-events:none, and neither of them
   scrolls. The field fades out rather than ending at an edge and takes a 0.2px
   blur so it never reads as a crisp CSS shape; the grain multiplies, so it
   darkens into the canvas instead of hazing over it. */
body::before {
  content: ""; position: fixed; inset: 0; z-index: -2; pointer-events: none;
  background: radial-gradient(900px 620px at 50% 46%, rgba(21, 107, 255, 0.05), transparent 70%);
  -webkit-mask-image: radial-gradient(circle at 50% 52%, #000, #0000 73%);
  mask-image: radial-gradient(circle at 50% 52%, #000, #0000 73%);
  filter: blur(0.2px);
}
body::after {
  content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background-image: var(--grain-page);
  opacity: 0.16; mix-blend-mode: multiply;
}

svg { display: block; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
p { margin: 0; text-wrap: pretty; }
strong { font-weight: 600; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); font-weight: 600; text-wrap: balance; }
h1 { font-size: var(--fs-2xl); line-height: var(--lead-tight); letter-spacing: var(--track-display); }
h2 { font-size: var(--fs-xl); line-height: 1.25; }
h3 { font-size: var(--fs-body); }
/* outline is transparent, not none: forced-colors (Windows High Contrast) drops box-shadow and
   repaints outlines in the system colour, so this is the only focus mark left there. Every focus
   rule in the repo follows it; ff-focus-forced-colors-check.js holds the line. 29b #1. */
:where(button, select, input, a, summary):focus-visible { outline: 2px solid transparent; box-shadow: 0 0 0 2px var(--ring); border-radius: var(--r-sm); }
/* box-shadow is one property, so the ring rule above replaces the well rather than
   joining it — a focused field lost its inset and popped back to flat. Both layers,
   inset first so the ring sits outside it. */
:where(.ctl, .field input, .field select, .field textarea, .fld input, .fld select, .toggle):focus-visible { box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }

/* ─── shell ──────────────────────────────────────────────────────────────── */
/* The skip link (ff-nav.js builds it). position:fixed in BOTH states on purpose: body is a
   flex column when it holds the app shell (see below), so a statically-positioned link
   would be a flex item pushing the banner and the rail down by its own height while it is
   supposed to be invisible. No transition — a control you only ever see for one keypress
   should be there on the keypress. */
a.skip { position: fixed; top: 0; left: 0; z-index: 300; transform: translateY(-120%);
  padding: 10px 16px; background: var(--fg); color: var(--btn-fg);
  border-radius: 0 0 var(--r-sm) 0; font-size: var(--fs-sm); text-decoration: none; }
a.skip:focus { transform: none; outline: 2px solid transparent; box-shadow: 0 0 0 2px var(--ring); }
.app { display: grid; grid-template-columns: var(--rail) 1fr; min-height: 100vh; min-height: 100dvh; }
/* The footer sits at the bottom of the viewport on a short page and after the content on a
   long one — `.appfoot{margin-top:auto}` inside the flex .mainpane does that, but only if
   .app is exactly as tall as the space it has. It is not: sb-guard.js's demo / trial-lapsed
   banners are body children in normal flow, so `.app{min-height:100dvh}`
   overshoots by the banner's ~38px and a short screen scrolls that far with the footer
   hanging just under the fold. Hang the 100dvh off the body instead and let .app take
   whatever the banner leaves. :has scopes it to the app shell — the marketing pages share
   this stylesheet and lay their own body out — and a browser without :has keeps the
   .app min-height above, i.e. today's behaviour, not a broken one. */
body:has(> .app) { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
body:has(> .app) > .app { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr); }
/* And the rail's own `height:100dvh` is the other half of the same overshoot: it is a grid
   item, so a viewport-tall rail sizes the row — and therefore .app — to a full viewport no
   matter what is above it. Filling the row instead (capped at a viewport, so a long screen's
   rail stays the sticky viewport-tall column it is today) lets the row be the leftover. */
body:has(> .app) > .app > .rail { height: 100%; max-height: 100vh; max-height: 100dvh; }
/* Chrome is flat with the panels: opaque --surface and a hairline. In the opt-in frost
   look it takes the strongest frost and the lightest blur — one composited layer each,
   and both are sticky already, so nothing new is being promoted. */
.rail {
  border-right: 1px solid var(--border-weak); padding: var(--s6) var(--s4);
  display: flex; flex-direction: column; gap: var(--s6);
  position: sticky; top: 0; height: 100vh; height: 100dvh;
  background: var(--surface);
}
.brand { display: flex; align-items: center; gap: var(--s2); padding-inline: var(--s3); }
.brand img { width: 24px; height: 24px; }
.brand b { font-family: var(--font-display); font-weight: 600; font-size: var(--fs-lg); letter-spacing: -0.01em; }
/* brand separator — pipe, never an em dash or middot. DESIGN.md §11.4 */
.brand .sep { color: var(--muted); opacity: .5; }
.railnav { display: flex; flex-direction: column; gap: 2px; }
.railnav a {
  display: flex; align-items: center; gap: var(--s2);
  min-height: var(--tap-min);
  padding: 9px var(--s3); border-radius: var(--r-sm); font-size: var(--fs-sm); color: var(--muted);
}
.railnav a svg { width: 18px; height: 18px; flex-shrink: 0; }
.railnav a .txt { flex: 1; }
.railnav a:hover { background: var(--tint-4); color: var(--fg); }
/* V3 (2026-10-19): where you are is a grey wash, not an ink slab that outshouts the page. */
.railnav a[aria-current="page"] { background: var(--tint-4); color: var(--fg); font-weight: 600; }
/* FF_CSS's pill rule zeroes the current item's box-shadow, which is where the shared ring
   lives; doubled :focus-visible outranks it (round 5). */
.railnav a[aria-current="page"]:focus-visible:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.railfoot { margin-top: auto; padding-inline: var(--s3); }
/* In a demo the rail foot said three things the demo banner above the page already says or
   does: a "Sample data" tag (the banner opens "Demo data."), and a "Sign out" link with no
   session behind it — sb-guard.js returns before it wires the rail's sign-out in a demo, so
   the link was a plain second route to auth, under the wrong name, beside the banner's own
   "Sign in to keep your data". Reset demo data is the one thing only the rail foot offers,
   so it stays alone. qfDemoReset (ff-data.js) sets the attribute, under the same IS_DEMO
   gate as the button. A real account never gets it: its tag is already hidden by
   sb-guard.js's ff-live-rail rule and its Sign out stays. */
/* !important: the link carries an inline display:inline-flex (every page's markup), which beats
   a plain rule — without it the link stayed visible and ran into the Reset button. */
.railfoot[data-demo] .tag, .railfoot[data-demo] > a[href="auth"] { display: none !important; }

.mainpane { min-width: 0; display: flex; flex-direction: column; }
.topbar {
  position: sticky; top: 0; z-index: 210;
  background: var(--surface);
  border-bottom: 1px solid var(--border-weak);
  padding: var(--s4) var(--s8);
  display: flex; align-items: center; gap: var(--s5); flex-wrap: wrap;
}
/* After .topbar on purpose: same specificity as .rail and .topbar (:where), so order decides. */
:where([data-surface="frost"]) .rail, :where([data-surface="frost"]) .topbar {
  border-color: var(--border);
  background: var(--frost-strong);
  -webkit-backdrop-filter: var(--blur-chrome); backdrop-filter: var(--blur-chrome);
}
.topbar-id { min-width: 0; }
.topbar-id .eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--meta); margin-bottom: 2px; }
.topbar-id h1 { font-size: var(--fs-xl); letter-spacing: -0.01em; }
/* The topbar subtext is a breadcrumb trail: the business name is the root crumb
   and links back to the dashboard; the current page is the non-link tail
   (aria-current). Same trail grammar as the marketing pages' .eyebrow.crumbs,
   at the h1's weight. Shared across the app screens since the trail spread. */
.topbar-id h1.crumbs { display: flex; align-items: baseline; min-width: 0; }
.topbar-id h1.crumbs a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); text-decoration: none; cursor: pointer; transition: color var(--dur-fast) var(--ease-standard); }
.topbar-id h1.crumbs a:hover, .topbar-id h1.crumbs a:focus-visible { color: var(--accent-ink); text-decoration: underline; }
.topbar-id h1.crumbs .sep { color: var(--muted); opacity: .45; padding-inline: 8px; flex: none; }
/* A three-deep trail ({business} > Inventory > Full Summary) has a middle crumb that is
   not a link and not the current page. Without this it inherits the h1's colour and reads
   exactly as loud as the tail, so the trail has two "you are here"s. Subdue it with the
   links: everything before the tail is context, the tail is the page. */
.topbar-id h1.crumbs > span:not([aria-current="page"]) { color: var(--muted); }
.topbar-id h1.crumbs [aria-current="page"] { color: var(--fg); }
/* And it is the crumb that ellipsises when the trail still does not fit. The h1 carries
   text-overflow:ellipsis under 768px, but .crumbs is display:flex and a flex container
   clips its items rather than ellipsising them — so the declaration up there does nothing
   and the tail used to cut mid-word ("Stock levels" → "Stocl"). Put it on the item that
   runs out of room. The root crumb already has its own copy of this a few lines above. */
.topbar-id h1.crumbs [aria-current="page"] { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* I8 (Daniel, Cycle 15): a quiet trail on every screen, not only on record pages. At the
   h1's 20px the leaf named the record ("Beef Burger") at the same size as the page title
   70px under it, so the name was said twice, loudly. Small and muted now, the way a trail
   reads on every app that has one; the page title is the loud line. The current crumb keeps
   the ink colour, a step heavier, so "where am I" is still answered. It stays an <h1>: the
   heading level is for a screen reader, the size is for the eye. */
.topbar-id h1.crumbs { font-size: var(--fs-sm); font-weight: 400; letter-spacing: 0; }
.topbar-id h1.crumbs [aria-current="page"] { font-weight: 500; }
/* One-line page footer: the "data as of" line that used to sit in the topbar, plus
   the legal/help sublinks. Sits under the content column (right of the rail). */
.appfoot {
  margin-top: auto;
  padding: var(--s4) var(--s8);
  border-top: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4); flex-wrap: wrap;
  font-size: var(--fs-sm); color: var(--meta);
}
/* Not shown until the screen's first data paint (ff-arrived, ff-data.js qfRepaint; a load
   event for a screen without one), nor under the boot skeleton. On a short pane the footer
   sits at the viewport's foot, and the first paint shoved it off: CLS 0.19 on a phone, every
   warm load (pill debug run 2026-10-22, D §2; ff-boot-shift-check.js). Visibility, not
   display: it keeps its box, so nothing else moves when it appears. */
html.ff-booting .appfoot, html:not(.ff-arrived) .appfoot { visibility: hidden; }
.appfoot .footlinks { display: inline-flex; gap: var(--s4); }
.appfoot a { color: var(--muted); text-decoration: none; }
.appfoot a:hover, .appfoot a:focus-visible { color: var(--fg); }
/* The as-of line reads as the footer's right-hand stamp, but it is written first in the
   markup so it precedes the legal links for a screen reader (and so the four screens that
   have no as-of still start their footer with the links). `order` moves it visually without
   touching eight files' markup or the reading order. `margin-left:auto` rather than leaning
   on the row's space-between: auto margins resolve per flex LINE, so once the footer wraps
   on a narrow screen the as-of is still hard right on its own line, where space-between —
   which has nothing to space out on a one-item line — would drop it back to the left. */
.appfoot #asOf { order: 1; margin-left: auto; }
/* The links' own spacing measured fine — 16px between all three at every width — so the
   only part of it that was wrong rather than a matter of taste is the target: a 21px line
   box, under WCAG 2.2 (2.5.8)'s 24px minimum. Grown with an overlay rather than block
   padding because padding is 8px of extra footer height, and 8px is exactly what makes a
   just-fits screen (locations at 1024x768 measured a 768px document) start to scroll —
   i.e. it would spend the sibling task's whole sticky-footer margin on a hit area. The
   pseudo-element is inside the <a>, so a press on it is a press on the link. */
.appfoot .footlinks a { position: relative; }
/* -12px vertical takes the 21px line box past --tap-min (45px); -8px horizontal gives `Help`
   (29px wide) a 45px target without moving the 16px gap between the three links. Still an
   overlay, still zero added footer height — that is the whole reason this is not padding. */
.appfoot .footlinks a::after { content: ""; position: absolute; inset: -12px -8px; }
/* One right-alignment mechanism per topbar, and it is `.ff-sync{margin-left:auto}`
   (ff-data.js). The `<span class="topbar-spacer">{flex:1}` that used to sit in all twelve
   headers was inert: an auto margin on a flex item absorbs the free space BEFORE any flex
   grow factor is applied, so the spacer resolved to 0px on every screen carrying a sync
   pill — which is every screen. Removing it is pixel-identical and stops the next control
   added to a topbar from landing on the wrong side of a rule that never fired. */

/* ─── the branch picker ──────────────────────────────────────────────────────
   Phase 6b. One control in the topbar, replacing the per-screen <select>. It is an
   overlay, not an in-flow reveal, so opening it must not push the page down — but it
   still UNFOLDS rather than appearing: grid-template-rows 0fr→1fr animates the panel's
   own height, which is the same mechanism a disclosure uses to slide its neighbours,
   applied to a box that has no neighbours to slide.

   Selection is colour and a tick. No padding, no weight, no border-width — the row you
   are pointing at must not resize under your finger, and there is no transition that can
   hide a reflow. */
.branchpick { position: relative; display: inline-flex; }
.branchpick-trigger {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 44px; padding: 7px var(--s3);
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
}
.branchpick-trigger:hover { border-color: rgba(21, 107, 255, 0.24); }
.branchpick-trigger:focus-visible { outline: 2px solid transparent; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }
.branchpick-label { white-space: nowrap; }
.branchpick-arrow { display: inline-flex; width: 14px; height: 14px; color: var(--meta); transition: transform var(--dur-fast) var(--ease-standard); }
.branchpick-arrow svg { width: 100%; height: 100%; }
.branchpick.is-open .branchpick-arrow { transform: rotate(180deg); }

.branchpick-panel {
  position: absolute; top: calc(100% + 6px); left: 0; z-index: 20;
  display: grid; grid-template-rows: 0fr; min-width: 280px;
  opacity: 0; visibility: hidden;
  transition: grid-template-rows var(--dur-panel) var(--ease-standard),
              opacity var(--dur-fast) var(--ease-standard),
              visibility 0s linear var(--dur-panel);
}
/* Hung from the trigger's right edge when the left-hung panel would run past the window
   (qfBranchPicker's setOpen measures it on each open — ff-nav.js). */
.branchpick.is-flip .branchpick-panel { left: auto; right: 0; }
.branchpick.is-open .branchpick-panel {
  grid-template-rows: 1fr; opacity: 1; visibility: visible;
  transition: grid-template-rows var(--dur-panel) var(--ease-standard),
              opacity var(--dur-fast) var(--ease-standard),
              visibility 0s;
}
.branchpick-in { min-height: 0; overflow: hidden; }
.branchpick-card {
  background: var(--surface); border: 1px solid var(--border-weak); border-radius: var(--r-md);
  box-shadow: var(--elev-overlay); padding: var(--s2);
}
.branchpick-acts { display: flex; gap: var(--s1); padding: var(--s1) var(--s1) var(--s2); }
.branchpick-act {
  border: 1px solid var(--border-weak); border-radius: var(--r-pill);
  background: transparent; color: var(--fg-2);
  font: inherit; font-size: 12px; padding: 4px var(--s3); cursor: pointer;
  transition: background var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard);
}
.branchpick-act:hover:not(:disabled) { background: var(--tint-4); }
.branchpick-act:disabled { opacity: .45; cursor: not-allowed; }
.branchpick-act:focus-visible { outline: 2px solid transparent; box-shadow: 0 0 0 2px var(--ring); }

.branchpick-list { display: flex; flex-direction: column; }
.branchpick-row {
  position: relative; display: flex; align-items: center; gap: var(--s3);
  min-height: 44px; padding: var(--s2) var(--s3); border-radius: var(--r-row);
  cursor: pointer; background: transparent;
  transition: background var(--dur-fast) var(--ease-standard);
}
.branchpick-row:hover { background: var(--tint-4); }
.branchpick-row.is-on { background: var(--accent-soft); }
.branchpick-row.is-locked { cursor: default; }
/* The native box carries the state and the keyboard; the drawn tick carries the look.
   Kept in the layout (opacity, not display) so ticking crossfades in place — a tick that
   is inserted has nothing to fade from. */
.branchpick-box { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }
.branchpick-tick {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; flex: none;
  border: 1px solid var(--border-weak); border-radius: var(--r-sm); background: var(--surface);
  color: var(--btn-fg);
  transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.branchpick-tick svg { width: 12px; height: 12px; opacity: 0; transition: opacity var(--dur-fast) var(--ease-standard); }
.branchpick-row.is-on .branchpick-tick { background: var(--accent); border-color: var(--accent); }
.branchpick-row.is-on .branchpick-tick svg { opacity: 1; }
.branchpick-box:focus-visible + .branchpick-tick { box-shadow: 0 0 0 2px var(--ring); }
.branchpick-names { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; }
.branchpick-name { font-size: var(--fs-sm); color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.branchpick-short { font-family: var(--font-mono); font-size: 11px; color: var(--meta); flex: none; }
.branchpick-floor { margin: var(--s1) var(--s3) var(--s2); font-size: 11px; color: var(--meta); }

/* ─── the account menu ───────────────────────────────────────────────────────
   S02, qfMenu's first consumer. Same overlay shape as the branch picker just
   above — an absolutely-positioned panel so opening it never pushes the topbar
   — with none of the checkbox/tick machinery, since a menu item is just a row. */
.qfmenu { position: relative; display: inline-flex; }
/* The topbar copy is the account menu, and above 700px it is a second Profile button for a
   rail that is already showing Profile and Sign out — so it is gone there. Below 768px the
   rail is display:none and the tab bar has no Profile tab, which makes this the only route
   to Profile and the only way to sign out; that is why it is a media query and not a
   deletion. ponytail: one rule, no JS — restore Profile/Sign out to the mobile tab bar or
   to profile.html itself and this menu can go entirely. */
@media (min-width: 769px) { .topbar > .qfmenu { display: none; } }
.qfmenu-trigger {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 44px; padding: 7px var(--s3);
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
  max-width: 160px;
}
.qfmenu-trigger svg { width: 16px; height: 16px; flex: none; }
.qfmenu-trigger:hover { border-color: rgba(21, 107, 255, 0.24); }
.qfmenu-trigger:focus-visible { outline: 2px solid transparent; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }
.qfmenu-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.qfmenu-panel {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 20;
  min-width: 160px; padding: var(--s2);
  background: var(--surface); border: 1px solid var(--border-weak); border-radius: var(--r-md);
  box-shadow: var(--elev-overlay);
  opacity: 0; visibility: hidden; transform: translateY(-4px);
  transition: opacity var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard),
              visibility 0s linear var(--dur-fast);
}
.qfmenu.is-open .qfmenu-panel {
  opacity: 1; visibility: visible; transform: translateY(0);
  transition: opacity var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard), visibility 0s;
}
.qfmenu-item {
  display: flex; align-items: center; min-height: 44px; padding: var(--s2) var(--s3);
  border-radius: var(--r-row); color: var(--fg); text-decoration: none; font-size: var(--fs-sm);
}
.qfmenu-item:hover, .qfmenu-item:focus-visible { background: var(--tint-4); outline: 2px solid transparent; }
.qfmenu-item svg { width: 16px; height: 16px; flex: none; margin-right: var(--s2); color: var(--meta); }

/* ─── the dropdown (single-select listbox) ───────────────────────────────────
   The fourth member of this family — qfSelect() in ff-data.js. Every number below
   is a --dropdown-* token (see :root), and every token resolves to a value the three
   overlays above it already agreed on. Spec: docs/dropdown-spec.md. */
.ff-dd { position: relative; display: inline-flex; }
/* The native <select> stays in the DOM as the value/change carrier — kept 1px and
   opacity 0 rather than display:none, the .branchpick-box precedent, so .value,
   focus and form submission all keep working. */
/* select.ff-dd-native, not .ff-dd-native: the fallback also carries .ctl, and `.ctl` at :1104
   declares min-height/padding/border at the same (0,1,0) from further down the file — so every
   zeroing declaration here lost on source order, and the "1x1" box measured 26x44 in a browser
   (border-box, 12px of padding a side, the 44px tap floor). The tag takes it to (0,1,1), which
   is also what FF_CSS's select rule needed excluding to stop forcing page-level scroll.
   clip-path is the belt to those braces — paint-only, so it never mattered on its own. */
select.ff-dd-native { position: absolute; opacity: 0; width: 1px; height: 1px; min-height: 0; overflow: hidden; clip-path: inset(50%); margin: 0; padding: 0; border: 0; pointer-events: none; }
.ff-dd-trigger {
  display: inline-flex; align-items: center; gap: var(--dropdown-trigger-gap);
  min-height: var(--dropdown-min-target); padding: var(--dropdown-trigger-padding);
  border: 1px solid var(--dropdown-trigger-border); border-radius: var(--dropdown-trigger-radius);
  background: var(--dropdown-trigger-bg); box-shadow: var(--dropdown-trigger-shadow);
  font: inherit; font-size: var(--dropdown-trigger-size); color: var(--dropdown-trigger-color); cursor: pointer;
  transition: border-color var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-trigger svg {
  width: var(--dropdown-caret-size); height: var(--dropdown-caret-size); flex: none;
  color: var(--dropdown-caret-color); transition: transform var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ff-dd-trigger:hover { border-color: var(--dropdown-trigger-border-hover); }
.ff-dd.is-open .ff-dd-trigger, .ff-dd-trigger[aria-expanded="true"] { border-color: var(--dropdown-trigger-border-hover); }
.ff-dd.is-open .ff-dd-trigger svg, .ff-dd-trigger[aria-expanded="true"] svg { transform: rotate(180deg); }
.ff-dd-trigger:focus-visible { outline: 2px solid transparent; box-shadow: var(--dropdown-trigger-shadow), 0 0 0 2px var(--dropdown-ring); }
.ff-dd-trigger:disabled, .ff-dd-trigger[disabled] { opacity: .45; cursor: not-allowed; }

.ff-dd-panel {
  position: absolute; top: calc(100% + var(--dropdown-offset)); left: 0; z-index: var(--dropdown-menu-z);
  display: flex; flex-direction: column;
  min-width: 100%; max-height: var(--dropdown-menu-max-height); overflow-y: auto;
  background: var(--dropdown-menu-bg); border: 1px solid var(--dropdown-menu-border);
  border-radius: var(--dropdown-menu-radius); box-shadow: var(--dropdown-menu-shadow);
  padding: var(--dropdown-menu-padding);
  opacity: 0; visibility: hidden; transform: translateY(-4px);
  /* Closed, the panel is held to its trigger's width. visibility:hidden still lays a box out,
     and an absolute box still counts toward the page's scrollable width: a qfSelect near the
     right edge of a phone page left its option-wide panel 12px past the screen, and the whole
     page scrolled sideways with nothing visible out there. Wider rows are kept inside the
     panel's own overflow (overflow-y:auto makes x auto too), which never reaches the page.
     Released on open at once (the open rule names no max-width transition) and re-applied on
     close only after the fade, the same hold visibility gets, so the rows never squeeze while
     they are still on screen. allow-discrete is a LONGHAND below, not in the shorthand: a
     browser without transition-behavior (Chrome <117, Safari <17.4) drops the whole
     declaration it sits in, and in the shorthand that would take the fade with it. As its
     own line it costs only itself, and the cap snaps at the start of the fade: a 150ms
     squeeze, nothing worse. It must follow the shorthand, which resets it. */
  max-width: 100%;
  transition: opacity var(--dropdown-duration) var(--dropdown-easing),
              transform var(--dropdown-duration) var(--dropdown-easing),
              visibility 0s linear var(--dropdown-duration),
              max-width 0s linear var(--dropdown-duration);
  transition-behavior: normal, normal, normal, allow-discrete;
}
/* Beta found the same leak independently (the Suppliers "Order deadline" list, 23px past a
   phone drawer) and capped it with `.ff-dd:not(.is-open) .ff-dd-panel { max-width:100% }`.
   Dropped in the drawer-pages merge as a duplicate: the max-width above already caps every
   closed panel with the same value, and the open rule's max-width:none releases it. */
/* A dropdown in a label/value row sits flush right, so its list opens leftward, back over
   the row, instead of out past the edge of the drawer. */
.ff-kv .ff-dd-panel { left: auto; right: 0; }
.ff-dd.is-open .ff-dd-panel {
  max-width: none;
  opacity: 1; visibility: visible; transform: translateY(0);
  transition: opacity var(--dropdown-duration) var(--dropdown-easing),
              transform var(--dropdown-duration) var(--dropdown-easing), visibility 0s;
}


/* Filter-bar dropdowns (All products, All locations, All categories) sit in a trigger-wide
   .ff-dd, and an absolute box with left:0 shrinks to fit THAT, not its rows: "Cebu IT Park
   (opening soon)" came out four lines tall in a 126px panel (C18 visual audit F4). Open, the
   panel takes its longest row, capped at 22rem; qfSelect slides it back in from the right edge. Desktop only:
   below 769 these filters live in the bar's Filters popover, whose wider box already fits. */
@media (min-width: 769px) {
  /* width on the panel itself, max-width on the open state: closed, the shared cap (100%) comes
     back only after the fade, so the rows do not rewrap while still on screen. */
  .ff-fbar .ff-dd-panel { width: max-content; }
  .ff-fbar .ff-dd.is-open .ff-dd-panel { max-width: min(22rem, calc(100vw - 2rem)); }
}
.ff-dd-opt {
  display: flex; align-items: center; gap: var(--dropdown-option-gap); width: 100%;
  min-height: var(--dropdown-min-target); padding: var(--dropdown-option-padding);
  border: 0; border-radius: var(--dropdown-option-radius); text-align: left;
  font: inherit; font-size: var(--dropdown-option-size); color: var(--dropdown-option-color);
  background: transparent; cursor: pointer;
  transition: background var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-opt:hover, .ff-dd-opt:focus-visible { background: var(--dropdown-option-bg-hover); outline: 2px solid transparent; }
/* Selected wins over hover — one background, never stacked tints. */
.ff-dd-opt[aria-selected="true"] { background: var(--dropdown-option-bg-selected); }
.ff-dd-opt:disabled { opacity: var(--dropdown-option-disabled-opacity); cursor: default; }
.ff-dd-opt-note { font-family: var(--dropdown-option-note-font); font-size: var(--dropdown-option-note-size); color: var(--dropdown-option-note-color); flex: none; }
/* Kept in the layout at opacity 0 rather than inserted on select — the .branchpick-tick
   precedent — so the tick crossfades in place instead of appearing from nothing. */
.ff-dd-check { flex: none; width: 16px; height: 16px; margin-left: auto; color: var(--dropdown-option-tick-color); opacity: 0; transition: opacity var(--dropdown-duration) var(--dropdown-easing); }
.ff-dd-opt[aria-selected="true"] .ff-dd-check { opacity: 1; }

/* ─── the combobox: qfCombo ──────────────────────────────────────────────────
   Same wrapper, same panel, same option rows as qfSelect — one skin, so a searchable field
   and a closed enum on the same screen are indistinguishable. What differs is the trigger:
   the INPUT is the trigger, and its affordance is the text caret, not a chevron.
   Spec: docs/superpowers/specs/2026-09-03-combobox-picker-design.md § Visual. */
/* display:block beats .ff-dd's inline-flex by source order, deliberately — both land on the
   same element (class="ff-dd ff-combo") with one class each, so the later rule wins and the
   field fills its column instead of shrink-wrapping. Moving this block above .ff-dd would
   silently undo it. */
.ff-combo { width: 100%; display: block; position: relative; }
.ff-combo .ff-combo-input { width: 100%; }
.ff-combo .ff-dd-panel { right: 0; }

/* The clear affordance on an optional pick. Absolutely positioned rather than in flow so
   showing and hiding it cannot reflow the field under the pointer — the same rule the option
   rows keep. Right-padding goes on the input so the caret never runs under it.
   --tap-min on coarse pointers because it is a real target on a phone; on a mouse it is the
   16px icon box the caret family already uses. */
.ff-combo .ff-combo-clear {
  position: absolute; top: 50%; right: var(--s2); transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0;
  border: 0; background: transparent; cursor: pointer;
  color: var(--meta); border-radius: var(--r-sm);
  transition: color var(--dur-fast) var(--ease-standard),
              background var(--dur-fast) var(--ease-standard);
}
.ff-combo .ff-combo-clear svg { width: 14px; height: 14px; }
.ff-combo .ff-combo-clear:hover { color: var(--fg); background: var(--tint-4); }
.ff-combo .ff-combo-clear:focus-visible { outline: 2px solid transparent; box-shadow: 0 0 0 2px var(--ring); }
/* Room for the button, so a long supplier name does not run under it. */
.ff-combo:has(.ff-combo-clear:not([hidden])) .ff-combo-input { padding-right: 32px; }
@media (pointer: coarse) {
  .ff-combo .ff-combo-clear { width: var(--tap-min); height: var(--tap-min); right: 0; }
  .ff-combo:has(.ff-combo-clear:not([hidden])) .ff-combo-input { padding-right: var(--tap-min); }
}
/* The matched span. Type, not highlighter: a background would fight the row's own
   hover/selected tint and stack two tints, which the option rules above forbid. */
.ff-dd-opt mark { background: none; color: var(--accent-ink); font-weight: 600; }
/* Keyboard-active row (aria-activedescendant) shares the hover tint — one tint, never two.
   Selected still wins, as it does for hover. */
.ff-dd-opt.is-active { background: var(--dropdown-option-bg-hover); }
.ff-dd-opt[aria-selected="true"].is-active { background: var(--dropdown-option-bg-selected); }
/* Unit beside the label, code right-aligned in mono — the slot .ff-dd-opt-note already is. */
.ff-dd-meta { color: var(--meta); font-size: var(--fs-sm); }
.ff-dd-opt .ff-dd-opt-note { margin-left: auto; }
.ff-dd-empty { padding: var(--dropdown-option-padding); color: var(--meta); font-size: var(--dropdown-option-size); }
.ff-dd-create {
  display: flex; align-items: center; gap: var(--s2); width: 100%;
  min-height: var(--dropdown-min-target); padding: var(--dropdown-option-padding);
  border: 0; border-top: 1px solid var(--border-weak);
  border-radius: 0 0 var(--dropdown-option-radius) var(--dropdown-option-radius);
  background: #fbfcff; color: var(--accent-ink); font: inherit; font-size: var(--dropdown-option-size); font-weight: 600;
  text-align: left; cursor: pointer;
  /* The same fade .ff-dd-opt carries. Without it the create row's hover snaps while every
     row above it eases, which is exactly the tell that gives away "one skin" as two. */
  transition: background var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-create:hover, .ff-dd-create.is-active { background: var(--dropdown-option-bg-hover); }
.ff-dd-create .ff-dd-plus {
  flex: none; width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center;
  background: var(--accent); color: var(--on-accent); font-size: 14px; line-height: 1;
}
.ff-dd-kbd {
  display: flex; gap: var(--s3); padding: var(--s1) var(--s3);
  border-top: 1px solid var(--border); background: var(--well);
  font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--meta);
}
.ff-dd-kbd b { font-weight: 600; color: var(--muted); }
@media (pointer: coarse) { .ff-dd-kbd { display: none; } }
/* The create mini-form, inside the panel. .fld markup, the editors' own, so validation and
   spacing are theirs; only the frame is new. */
.ff-dd-form { display: flex; flex-direction: column; gap: var(--s2); padding: var(--s3); }
.ff-dd-form .fld { display: flex; flex-direction: column; gap: 4px; }
.ff-dd-form .fld > label { font-family: var(--font-mono); font-size: var(--fs-xs); letter-spacing: .08em; text-transform: uppercase; color: var(--meta); }
.ff-dd-form-acts { display: flex; gap: var(--s2); justify-content: flex-end; margin-top: var(--s1); }

/* ─── the table actions bar ──────────────────────────────────────────────────
   qfTableActions: a kebab beside the table for the table-level actions (CSV
   download, import, export), or — when there is only one — that action as a
   single square icon button. Both are 44px so the touch target never shrinks
   below the same floor every other control here keeps; only the label goes. */
.ff-tableactions { display: inline-flex; align-items: center; }
.qfmenu-trigger.is-icon {
  justify-content: center; gap: 0; width: 44px; min-width: 44px; max-width: 44px; padding: 0;
}
.ff-tableactions-one {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; min-width: 44px; height: 44px; padding: 0;
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
}
.ff-tableactions-one svg { width: 16px; height: 16px; flex: none; }
.ff-tableactions-one:hover { border-color: rgba(21, 107, 255, 0.24); }
.ff-tableactions-one:focus-visible { outline: 2px solid transparent; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }

/* The write-surface chip. Accent-tinted and pinned at the top of the drawer, because it
   answers the question you must not have to ask: where is this about to land. Two moods —
   a statement when one branch is in play, a required picker when several are — and the
   picker's untouched state is deliberately styled as unfinished business rather than as a
   neutral control, since the confirm below it is disabled until it is answered. */
.ff-branch-chip {
  display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
  margin-bottom: var(--s4); padding: var(--s2) var(--s3);
  border: 1px solid color-mix(in oklab, var(--accent) 22%, transparent);
  border-radius: var(--r-row); background: var(--accent-soft);
  font-size: var(--fs-sm);
  transition: border-color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
}
.ff-branch-chip.is-inline { margin-bottom: 0; padding: 4px var(--s3); }
.ff-branch-chip-label {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps);
  text-transform: uppercase; color: var(--meta);
}
.ff-branch-chip strong { color: var(--fg); }
.ff-branch-chip select { min-height: 36px; }
.ff-branch-chip-add { margin-left: auto; color: var(--accent-ink, var(--accent)); }
/* Unanswered reads as an empty field, not a warning (Hans, 21 Oct, C9): it was an amber band,
   and amber says something is already wrong before anyone has done anything — while every
   other required field on the form sits plain. The red outline (.is-needed, below) comes only
   once a Save is refused, the same as any other field. */
.ff-branch-chip[data-mode="pick"]:not([data-chosen]) {
  border-color: var(--border-weak);
  background: var(--surface);
}

main { padding: var(--s6) var(--s8) var(--s12); display: flex; flex-direction: column; gap: var(--s8); min-width: 0; }
section { min-width: 0; animation: rise 0.5s var(--ease-standard) both; }
section:nth-child(1) { animation-delay: 0ms; }
section:nth-child(2) { animation-delay: calc(var(--delay-stagger) * 1); }
section:nth-child(3) { animation-delay: calc(var(--delay-stagger) * 2); }
section:nth-child(4) { animation-delay: calc(var(--delay-stagger) * 3); }
section:nth-child(5) { animation-delay: calc(var(--delay-stagger) * 4); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }

/* ─── primitives ─────────────────────────────────────────────────────────── */
/* The panel is flat (round 4 29b-4, Daniel, 2026-10-16: frost as the identity "makes the
   app feel like 2017"). An opaque --surface and one weak-tier hairline; depth is the
   border, not a 70px throw under a blur. --border-weak, not --border: at 0.055 the default
   hairline was only ever legible because the shadow underneath drew the edge for it.
   Frost survives as a look the owner can pick on Profile (html[data-surface="frost"], set
   before first paint by sb-guard.js); its rules follow, each under that attribute. */
.card {
  background: var(--surface);
  border: 1px solid var(--border-weak); border-radius: var(--r-md); padding: var(--s5);
  box-shadow: none;
  transition: border-color var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
}
/* The frost panel, opt-in. Layer order is load-bearing and reads bottom-up: the surface
   token underneath so the panel keeps its plane, the two card-tier splashes over
   it in opposite corners, the top-down glass sheen above those, and the white
   tooth on top. Drop the base fill and the card becomes a gradient swatch.
   :where() keeps this at .card's own specificity, so every page override written against
   .card (a nested card, the dashboard's overview strip, a linked plan card) still wins on
   order exactly as it did when this was the default. */
:where([data-surface="frost"]) .card {
  background:
    var(--grain-panel) repeat 0 0 / 160px 160px,
    var(--sheen),
    var(--splash-card),
    var(--frost);
  -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur);
  border-color: var(--border);
  box-shadow: var(--bevel-top), var(--elev-panel);
}
/* Mandatory, not optional: without blur support the frost is a 62% white film over
   whatever moves behind it, and the text on it stops being legible. The fallback
   is an opaque --surface, which is exactly the v1 look. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :where([data-surface="frost"]) .card { background: var(--grain-panel) repeat 0 0 / 160px 160px, var(--surface); }
  :where([data-surface="frost"]) .rail, :where([data-surface="frost"]) .topbar { background: var(--surface); }
}
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); margin-bottom: var(--s4); flex-wrap: wrap; }
/* One card-header pattern (design audit F4). The title block leads and takes the slack; every
   other child, the action group, sits inline to its right and flows as ONE unit — it wraps
   under the title only when the two do not fit, never button by button, so a lone download
   never gets a row of its own. The title's 12rem basis is what lets a short title share the
   row on a phone while a long blurb still drops the actions beneath it. A group is
   .card-actions (built by qfFilterBar's cfg.head) or a .row a screen already puts there. */
.card-head > :first-child { flex: 1 1 12rem; min-width: 0; }
.card-head > :not(:first-child) { flex: none; }
.card-head > .card-actions, .card-head > .row { display: flex; align-items: center; gap: var(--s2); margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }   /* wrap: five actions on a 360px phone don't fit one row */
.card-head:has(> .card-actions, > .row, > .btn, > .ff-tableactions) { align-items: center; }
.card-actions .ff-tableactions { flex: none; }
.import-actions { flex-wrap: wrap; }
.card-head .import-actions { flex-wrap: nowrap; }
/* The list toolbar (drawer-pages design §7): search, filter pills, a space, Import, Export,
   Add. The space is Import's auto margin, so search and pills sit left and the actions right
   whenever the row has room to spare. A search box leading the head itself (Inventory levels)
   keeps its own width instead of the first child's flex-grow above. */
.card-head > .row > .import-actions { margin-left: auto; }
.card-head > .ff-fbar-search { flex: 0 1 240px; }
/* Round 3 #2.1: search sits in ONE slot on every list card, the left end of the toolbar line.
   Products, Suppliers, Locations and Inventory already had it there (the toolbar is the
   head's first child). A card with a title block in front (Stock items, Order plan, the
   supplier Deliveries card) put the box wherever the title left room: x=773 on Stock items,
   533 on Order plan, 883 on Deliveries at 1440. There the title now takes its own line and
   the toolbar starts under it at the card's left edge, search first, the actions pushed
   right by Import's auto margin. The group may shrink (min-width:0): held at flex:none it
   was as wide as its widest wrap line, which on Stock items at 390 put the search box and
   Add 18px past the card and scrolled the page sideways; at 1440 it pushed Add onto a
   line of its own. */
.card-head:has(> :not(:first-child) .ff-fbar-search, > .ff-fbar-search:not(:first-child)) > :first-child { flex-basis: 100%; }
.card-head > :not(:first-child):has(> .ff-fbar-search) { flex: 1 1 100%; min-width: 0; margin-left: 0; justify-content: flex-start; }
.card-head > :not(:first-child):has(> .ff-fbar-search) > :not(.ff-fbar-search):first-of-type,
.card-head > .card-actions:has(> .ff-fbar-search) > .ff-tableactions { margin-left: auto; }
.card-head > .ff-fbar-search:not(:first-child) ~ .card-actions { margin-left: auto; }
/* I12 (19b): a box that is a direct child of the head kept its 240px on a phone, so on
   Inventory Levels it took half the row and the actions hung right-aligned under it. The
   toolbars built as a .row already give the box the whole line there; these match. */
@media (max-width: 576px) { .card-head > .ff-fbar-search { flex: 1 1 100%; } }
/* Icon + word at every width (I11, Daniel's Cycle 15 pick; it was icon-only, copy deck #31).
   An up arrow for Import and a bare + for Add failed the mom test. The word is short ("Import",
   "Export", "Add") and starts the accessible name, which keeps the longer "Import CSV" or
   "Add supplier". Still 44px tall; the width is the word's. nowrap so a toolbar wraps between
   buttons, never inside one. */
.btn:has(> .btn-lbl) { gap: 6px; padding-inline: var(--s3); min-height: var(--tap-min); white-space: nowrap; }
.btn-lbl { white-space: nowrap; }
/* A card inside a card keeps the fill family, drops to the weaker border, and
   carries NO shadow of its own — a drop shadow inside an already-floating panel
   double-counts depth and the whole stack goes to mush. */
.card .card { background: var(--frost-soft); border-color: var(--border-weak); box-shadow: none; border-radius: var(--r-row); backdrop-filter: none; -webkit-backdrop-filter: none; }
.label { font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--meta); display: flex; align-items: center; gap: 6px; }
.label svg { width: 14px; height: 14px; flex-shrink: 0; }
.meta { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--meta); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.dim { color: var(--muted); }
.small { font-size: var(--fs-sm); }
.grid { display: grid; gap: var(--s5); }
.row { display: flex; align-items: center; gap: var(--s3); }
.between { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.rule { border: 0; border-top: 1px solid var(--border); margin: 0; }

/* Every quantity, percentage, delta, SKU, unit and timestamp is mono with tabular
   figures, so columns align down a table and a figure does not jitter as it
   updates. This is the single loudest signal in the language: a number in the body
   face reads as marketing, a number in mono reads as a reading. */
.num, .meta, .ff-id, .ff-code, .ff-val, .ag-by, .ag-what, .cal-date,
table.ff-table td.r, .ff-kv > span:last-child,
.lines td.n, .lines th.n, .big, .figure, .unit, kbd {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}
/* The figure tier — one number per card, at the size where the reading is the
   content rather than a label on it. */
.figure { font-family: var(--font-mono); font-size: var(--fs-figure); line-height: 0.95; letter-spacing: -0.03em; font-weight: 600; }
.unit { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--meta); }
/* Keyboard-first affordances are visible, not hidden. The product is operated,
   not browsed. */
kbd {
  font-family: var(--font-mono); font-size: 11px; line-height: 1;
  padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px;
  background: var(--frost-strong); color: var(--fg-2); box-shadow: var(--bevel-top);
}

/* S04/S15: the UA stylesheet's [hidden]{display:none} loses to any later same-specificity
   display rule — .btn's inline-flex among them — so a hidden .btn stayed visible/clickable
   at rest (ledger Clear, profile Save). !important restores `hidden` as the one thing that
   always wins; .ff-reveal panels that are also `hidden` (S01 import cluster, items.html's
   newStockFld) collapse the same way until the attribute is removed. */
[hidden] { display: none !important; }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  min-height: var(--tap-min);
  padding: var(--s2) var(--s4); border-radius: var(--r-sm); border: 1px solid transparent;
  background: transparent; font-size: var(--fs-body); line-height: var(--lead-body);
  /* A button in a right-aligned number cell (td.r) would otherwise inherit the mono digits. */
  font-family: var(--font-body);
  transition: opacity var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
  transition-property: opacity, background, transform;
}
/* Press is a confirmation, not a decoration — 3% scale, same as the landing page. */
.btn:active { opacity: .8; transform: scale(.97); }
/* V6 (2026-10-19): one hover, a --tint-4 wash, on every button, chip, tab and segment.
   The solid primary cannot take a wash, so it deepens instead. ff-states-check.js guards
   the selected/primary half of this. */
@media (hover: hover) {
  .btn:not(:disabled):hover { background: var(--tint-4); }
  .btn.btn-primary:not(:disabled):hover { background: var(--accent-ink); }
  /* The page-local pickers. Pages load after this file but none sets a hover background,
     so these hold; the picked one keeps its tint. */
  :is(.cats, .pays, .adj-dir, .auth-tabs) button:not([aria-pressed="true"]):not(.active):not(:disabled):hover,
  .tile:not(:disabled):hover, :is(.day-chip.tog, button.day-chip):not(.active):not(:disabled):hover, .ob-tab:not(.on):hover,
  .sched-tab:not(.on):hover, .cal-chip:not(.arrive):hover { background: var(--tint-4); }
}
/* Focus on a picked segment: the inset is box-shadow too, so the picked rule replaced the
   shared ring. Both layers, stated once. :focus-visible is doubled on purpose: FF_CSS loads
   after this file and its [data-all] picked rules tie a single one at (0,3,1), which left the
   resting "All" with no ring (round 4 N2). */
:is(.cats, .pays, .adj-dir, .ff-spills, .ff-stiles) button[aria-pressed="true"]:focus-visible:focus-visible,
.ff-doorstrip > button.btn[aria-pressed="true"]:focus-visible:focus-visible,
:is(.ob-tab.on, .sched-tab.on, .day-chip.active, .ob-step-dot.active):focus-visible:focus-visible {
  box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 2px var(--ring);
}
/* A ring on a solid blue button needs a gap, or it is blue on blue. */
.btn.btn-primary:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent); }
/* V5: one disabled look. Same button, 45%, reason written beside it. .is-blocked below is a
   different thing (aria-disabled "not yet", hollow) and keeps its own rule. */
/* Not the step dots (progress, not an action), the range arrows or the location chips: each
   keeps its own "not here" look, and a second .45 on top made them unreadable. Nor a row name
   (.ph-more): off a phone it is disabled so it reads as text, and it must look like text too. */
:is(button:disabled, .btn[disabled], [aria-disabled="true"]):not(.ob-step-dot, .ff-range-step, .branch-chip, .ph-more) {
  opacity: .45; cursor: not-allowed; transform: none;
}
/* The reason beside a disabled button when it is "not yet", not an error. */
.ff-blocked-why.is-quiet { color: var(--muted); }

/* Every variant below is written .btn.btn-x, not .btn-x, and the doubled class is
   load-bearing rather than stylistic.

   Twelve pages still carry their own `.btn { … background: transparent }` base rule,
   because each differs from this one by its transition and so survived extraction.
   Those blocks are inline <style>, which loads AFTER this file, and a bare .btn-primary
   is specificity (0,1,0) — exactly the same as .btn. Later wins, so the page's
   transparent background overrode the ink fill while --btn-fg stayed near-white, and
   every primary button on those screens rendered as a white ghost box on a card.
   The first-run checklist's "Start" was the visible symptom.

   .btn.btn-primary is (0,2,0) and cannot be reached by a later single-class rule, so
   the variants win wherever they are declared. This costs one class in the selector and
   removes the whole category of failure — including padding on .btn-sm, which the same
   page-local .btn was quietly resetting. */
.btn.btn-primary {
  /* V1 (2026-10-19): solid brand blue, no gradient. The ink fill read as dated. */
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
/* V1b, round 3 A: the suggested action ("Use suggested qty", "Back to our forecast"). Main =
   solid blue, suggested = blue text on a blue border with no fill (a fill read as picked),
   everything else = grey outline. */
.btn.btn-suggest { background: transparent; color: var(--accent-ink); border-color: var(--accent); }
@media (hover: hover) { .btn.btn-suggest:not(:disabled):hover { background: var(--tint-4); } }
.btn.btn-ghost { border-color: var(--meta); }
.btn.btn-ghost:not(:disabled):hover { background: var(--tint-4); }
.btn.btn-quiet { color: var(--muted); padding-inline: var(--s2); }
.btn.btn-quiet:not(:disabled):hover { color: var(--fg); background: var(--tint-4); }
/* Row delete icons (qfRowActions danger:true): neutral at rest, danger only when the pointer or
   keyboard is actually on it. A red trash can on every row used the danger colour as decoration
   and sat beside edit as a misclick target. The confirmation dialog is unchanged. */
.btn.btn-quiet.ra-danger:not(:disabled):hover, .btn.btn-quiet.ra-danger:focus-visible, .btn.btn-quiet.ra-danger:active {
  color: var(--danger-ink); background: color-mix(in oklab, var(--danger) 8%, transparent);
}
/* Touch has no hover to reveal it, so there the icon keeps its danger colour at rest. */
@media (hover: none) { .btn.btn-quiet.ra-danger { color: var(--danger-ink); } }
.btn.btn-sm { font-size: var(--fs-sm); padding: 6px var(--s3); min-height: 36px; }
/* The floor held for chrome and broke at the write action: .btn.btn-sm is (0,2,0), so the
   `.btn { min-height: 44px }` that dashboard.html and sales.html declare at ≤768px never reached
   it — Create order ×10 on supply, the forecast row pencil and every control on Profile all
   measured 36px. Media queries add no specificity, so this must stay AFTER the line above.
   Coarse pointer rather than a width query: a 36px button is wrong for a thumb on a tablet too. */
/* 19b M4: min-width too. A one-glyph small button (the recipe line's Remove ✕) was 36px wide.
   On a page, a disclosure's summary line and a link inside a help sentence were the other
   sub-44 targets: the summary gets the height as padding, the link gets it as inline
   padding, which grows its hit box without moving the line it sits in. */
@media (pointer: coarse) { .btn.btn-sm { min-height: var(--tap-min); } }
@media (pointer: coarse) {
  .btn.btn-sm { min-width: var(--tap-min); }
  .ff-drawer--page summary { padding-block: 10px; }
  .ff-drawer--page p.meta a { padding-block: 15px; }
}
.btn.btn-pill { border-radius: var(--r-pill); }
/* Icon buttons: actionIcon()'s <svg> carries a viewBox and no width/height, which as a flex
   item of .btn resolves to 0×0 — measured in Chromium, not assumed — so an icon-only button
   paints an empty box. Sized here rather than at each call site: .railnav a svg (:329) and
   .qfmenu-trigger svg (:455) already do the same for their own glyphs, and both are more
   specific than this, so neither moves. 18px matches the rail. */
.btn > svg { width: 18px; height: 18px; flex: none; }
/* Nothing translates on hover except the arrow. */
.btn .arrow { display: inline-block; transition: transform var(--dur-fast) var(--ease-standard); }
.btn:hover .arrow { transform: translateX(3px); }

/* ff-validate.js: shared invalid-field and blocked-control styling. See docs/error-copy-
   validation-audit.md and ff-validate.js's own header for why this exists — three screens
   were about to grow three copies of "what does wrong look like". */

/* A field the user got wrong (.ff-invalid, toggled by qfInvalid). Red alone is not the
   signal — qfInvalid also sets aria-invalid — but the outline is what a sighted user scans
   the form for, so it has to beat whatever border-color the base input/select/textarea rule
   already carries. */
.ff-invalid, input.ff-invalid, select.ff-invalid, textarea.ff-invalid {
  border-color: var(--danger);
  background: color-mix(in oklab, var(--danger), transparent 96%);
}
/* The inline message under a field, placed by the caller — qfWhy() supplies the text,
   qfInvalid() does not render it. font-size matches .ctl's own so the message reads as part
   of the same control rather than a caption on the form. */
.ff-fielderr {
  display: block; margin-top: var(--s1); font-size: var(--fs-sm); color: var(--danger-ink);
}
/* ── Lane 2 (number guard, 2026-10-19) — kept as one block so Lane 1's edits to this file
   merge around it. ───────────────────────────────────────────────────────────────────
   1. A guarded field reads red. .ff-invalid alone (0,1,0 / 0,1,1) lost to the later
      `input.ctl` border and background rules at the same specificity, so only the message
      turned red (visual audit P1-4). Two classes or the attribute beat it, and the 1px
      inset makes the state visible at a glance, the same weight the selected pills use.
   2. The message under a number box wraps in the body face. In the supply table it sat in
      a right-aligned mono cell and was clipped at 390 (P1-6); a long reason must break,
      not widen the cell or push the box onto its own line. */
/* Focus included, and input.cell at (0,2,1)+: suppliers.html's own `.cell:focus` border
   comes later than this file and was repainting a focused bad box grey. */
.ctl.ff-invalid, input.cell.ff-invalid, input.cell.ff-invalid:focus, input.ctl.ff-invalid:focus,
input[aria-invalid="true"], .stepper input[aria-invalid="true"] {
  border-color: var(--danger);
  background: color-mix(in oklab, var(--danger), transparent 96%);
  box-shadow: inset 0 0 0 1px var(--danger);
}
.ff-fielderr[data-qty-err] {
  font-family: var(--font-body); white-space: normal; overflow-wrap: anywhere;
  max-width: 100%; text-align: start; font-variant-numeric: normal;
}
/* 3. The plan table's Order cell sizes to its box. contain:inline-size gives the message no
      width of its own, so it wraps inside the cell instead of widening it, and the box does
      not jump left on a phone, where the row is a wrapping flex line (supply.html). */
table.plan-table td.ord > .ff-reveal:has(> [data-qty-err]) { contain: inline-size; white-space: normal; }
/* ── end Lane 2 block ── */
/* The touch/AT twin of the [data-tip] tooltip further down: ff-blocked.js renders a
   blocked control's own tip text into one of these on click, because a ::after on :hover
   reaches neither a phone nor a screen reader — docs/ui-ux-audit-2026-09-10.md B-03,
   B-13, C-26. flex-basis so it drops to its own line inside the wrapping flex rows these
   controls sit in (profile.html's .frow) instead of squeezing the row it follows. */
.ff-blocked-why {
  flex-basis: 100%; margin-top: var(--s1);
  font-size: var(--fs-sm); color: var(--danger-ink);
}

/* A control that cannot be used yet (.is-blocked, toggled by qfBlock). aria-disabled, not
   the disabled attribute — see ff-validate.js's header — so this has to read as "not yet"
   through style alone: hollow, not filled, because a filled-but-faded button (.btn[disabled])
   already means something else on these screens. Transparent background and an outline-only
   border are the whole trick; the hover rules below exist only to cancel .btn-primary's
   blue fill and .btn-ghost's tint hover so a blocked button never looks pressable. */
/* !important on the skin, not just on the cursor, and for the same reason the cursor
   already had it: this rule has to beat whatever skin the control is already wearing, and
   some of those skins live in a page's own <style>, which is always later in source order
   than this file. support.html's .auth-cta (support.html:57) ties this at (0,1,0) and wins
   on order, so a blocked Send message rendered pixel-identical to a live ink primary —
   docs/ui-ux-audit-2026-09-10.md B-03. opacity is pinned too because
   .auth-cta:hover:not([disabled]) (support.html:59) is (0,3,0); a hollow button that fades
   on hover is still claiming to be pressable.
   ponytail: a state class carrying !important, rather than a per-page-skin rule here
   (.auth-cta.is-blocked, .some-next-cta.is-blocked, …) that would need a new line every
   time a screen grows a CTA class and would still lose the :hover tie. */
/* [data-ff-idle] is the same "not yet" for a Save on a form nothing has changed on (T3,
   qfSaveIdle in ff-data.js): one look for every control that is waiting on the owner. */
.is-blocked, [data-ff-idle], [data-needs-branch] {
  background: transparent !important; color: var(--meta) !important;
  border-color: var(--meta) !important; opacity: 1 !important;
  cursor: not-allowed !important;
}
.btn.is-blocked, .btn.is-blocked:hover, .btn.is-blocked:active,
.btn[data-needs-branch], .btn[data-needs-branch]:hover, .btn[data-needs-branch]:active,
.btn[data-ff-idle], .btn[data-ff-idle]:hover, .btn[data-ff-idle]:active {
  background: transparent; color: var(--meta); border-color: var(--meta);
  opacity: 1; transform: none; box-shadow: none;
}

/* [data-tip] fallback for pages that carry no ff-data.js — auth.html, support.html — so
   qfHelp()'s tooltip still renders with no JS beyond ff-validate.js itself. Copied from
   ff-data.js's injected style block (its own [data-tip]:hover::after, ~line 3283) rather
   than shared, because that block ships only to screens which load ff-data.js and this file
   is the one both kinds of page load. Keep the two in sync by hand; ff-tip-check.js and
   ff-validate-check.js each assert their own copy is still there. */
[data-tip] { position: relative; }
[data-tip]:hover::after, [data-tip]:focus-visible::after {
  content: attr(data-tip);
  position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%);
  z-index: 6; white-space: pre-wrap; max-width: min(280px, 80vw); text-align: center;
  font-size: var(--fs-sm); line-height: 1.35;
  background: var(--fg); color: var(--btn-fg);
  padding: 6px 9px; border-radius: var(--r-sm);
  box-shadow: var(--float, 0 4px 12px -4px rgba(37, 50, 72, .28));
  pointer-events: none;
}

/* .ff-reveal fallback, same reason and same contract as the [data-tip] block above:
   copied from ff-data.js's injected style block (~line 3886) because that block ships only
   to screens which load ff-data.js, and auth.html / support.html — the two pages carrying
   the most appearing error text in the product — deliberately do not. Without this,
   ff-fieldwatch.js's inline message had nowhere shift-free to appear: it was unhidden on
   focusout, which browsers fire on the MOUSEDOWN of the click aimed at the submit button
   below it, so the button moved out from under the pointer between mousedown and mouseup and
   the click never fired. See docs/layout-stability-audit.md L1.

   One deliberate difference from the original, and the only one: --motion-base/--motion-exit
   are declared inside ff-data.js's own block (:3926) as aliases of --dur-enter/--dur-exit, so
   on a page without ff-data.js they resolve to nothing and the whole transition is dropped —
   a snap-cut, which is the thing this rule exists to prevent. The var() falls through to the
   --dur-* token this file already declares, and whose reduced-motion block already collapses
   it. Keep the rest in sync with ff-data.js by hand; ff-flowshift-check.js asserts both
   copies are still there. */
.ff-reveal { display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease); }
.ff-reveal[data-open] { grid-template-rows: 1fr;
  transition: grid-template-rows var(--motion-base, var(--dur-enter)) var(--ease-standard, ease); }
.ff-reveal > * { min-height: 0; overflow: hidden;
  opacity: 0; visibility: hidden;
  transition: opacity var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease),
              visibility 0s var(--motion-exit, var(--dur-exit)),
              margin-block var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease),
              padding-block var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease),
              border-block-width var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease); }
/* M10: a closed reveal is 0px. A grid track never sizes below its item's margin, padding and
   border, so a child that carried its own (a 16px top margin, a bordered card body) held 16-25px
   open at rest and snapped to full on the first frame of the open. Zeroed while closed, tweened
   with the track. Mirrors ff-data.js. */
.ff-reveal:not([data-open]) > * { margin-block: 0; padding-block: 0; border-block-width: 0; }
/* visibility as well as opacity, so a closed reveal's controls leave the tab order and the
   accessibility tree - opacity:0 alone left them focusable. Mirrors ff-data.js. */
/* The clip is released once the track has stopped growing, and the delay is the whole point.
   overflow:hidden is what keeps the content inside a box that is still opening, but it also
   makes that box a SCROLL CONTAINER with no scrollbar — and a panel opened inside it (any
   qfSelect, qfCombo or tooltip in a reveal) reaches below the box's edge, so the browser
   scrolls the box to bring the focused row into view and nothing can scroll it back. That is
   how items.html's recipe rows scrolled themselves out of sight behind an open item dropdown
   and stayed gone until reload. Settled open, the track is exactly content height: there is
   nothing left to clip, so not clipping costs nothing and buys back both the panel and the
   unreachable scroll. overflow is a discrete property, hence `0s <delay> allow-discrete` — it
   flips when the track finishes rather than on the first frame, which would put the spill
   straight back. Closing needs no delay: [data-open] stops matching and the rule above
   re-clips immediately, which is what the collapse wants. ff-reveal-clip-check.js gates it. */
.ff-reveal[data-open] > * { opacity: 1; visibility: visible; overflow: visible;
  transition: opacity var(--motion-base, var(--dur-enter)) var(--ease-standard, ease) var(--delay-stagger),
              visibility 0s,
              overflow 0s var(--motion-base, var(--dur-enter)) allow-discrete,
              margin-block var(--motion-base, var(--dur-enter)) var(--ease-standard, ease),
              padding-block var(--motion-base, var(--dur-enter)) var(--ease-standard, ease),
              border-block-width var(--motion-base, var(--dur-enter)) var(--ease-standard, ease); }
/* The track stops animating — height is movement — but the content still fades, so an error
   message does not blink into existence. Mirrors ff-data.js's own split at :4013. */
@media (prefers-reduced-motion: reduce) {
  .ff-reveal { transition-duration: .01ms; }
  /* M12: a close fades its content (120ms, below) before the height steps; an open steps at once. */
  .ff-reveal:not([data-open]) { transition-delay: 120ms; }
  .ff-reveal[data-open] { transition-delay: 0s; }
  .ff-reveal > * { transition-duration: 120ms; transition-delay: 0s;
    transition-property: opacity, visibility, overflow; }
}

.tag {
  display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
  border: 1px solid var(--border); border-radius: var(--r-pill);
  font-size: 11px; font-family: var(--font-mono); letter-spacing: .04em; color: var(--meta);
  background: var(--frost-strong);
}
.dot { width: 7px; height: 7px; border-radius: var(--r-pill); background: var(--muted); flex: none; transition: background 0.3s var(--ease-standard); }
.is-ok .dot, .dot.ok { background: var(--success); }
.is-low .dot, .dot.low { background: var(--warn); }
.is-bad .dot, .dot.bad { background: var(--danger); }
/* the dots above are fills and keep the raw hue; these are type and take the ink */
.is-ok { color: var(--success-ink); }
.is-low { color: var(--warn-ink); }
.is-bad { color: var(--danger-ink); }
.flag-accent { color: var(--accent-ink); }

/* The stock-position chips (round 4 #4): a stock item's record head, On hand · Needed ·
   Short by, always in that order. Pills because they are readings, not buttons. The colour
   rides the dot (a fill, raw hue) and, for Short by above zero, the type (the -ink twin) —
   the Fill-vs-Type Rule. Needed is accent because it is the forecast's number; a short item
   is the only one that goes red. Wraps rather than scrolls at phone width. */
.ff-pos-wrap { margin-bottom: var(--s4); }
.ff-pos-wrap > .meta { margin: var(--s2) 0 0; }
.ff-pos { display: flex; flex-wrap: wrap; gap: var(--s2); }
.ff-pos-chip {
  display: inline-flex; align-items: center; gap: var(--s2); padding: 6px 12px;
  border: 1px solid var(--border-weak); border-radius: var(--r-pill);
  background: var(--frost-strong); font-size: var(--fs-sm);
}
.ff-pos-l { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--meta); }
.ff-pos-v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); }
.ff-pos-chip[data-k="onhand"] .dot { background: var(--muted); }
.ff-pos-chip[data-k="needed"] .dot { background: var(--accent); }
.ff-pos-chip[data-k="short"] .dot { background: var(--border-weak); }
.ff-pos-chip[data-tone="bad"] { border-color: color-mix(in oklab, var(--danger) 40%, transparent); }
.ff-pos-chip[data-tone="bad"] .dot { background: var(--danger); }
.ff-pos-chip[data-tone="bad"] .ff-pos-v { color: var(--danger-ink); }
/* The product page's one ingredient warning (round 4 #5): same tone as a short chip, and it
   only exists when something is short, so it can afford the colour. */
.ff-short-line { margin: var(--s2) 0 0; color: var(--danger-ink); font-variant-numeric: tabular-nums; }

/* The control tier: one splash, top-right, plus a border tinted to the same accent
   at 24%. One splash only — two would make it a card. */
.ctl {
  min-height: var(--tap-min);
  padding: 7px var(--s3); border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control); font-size: var(--fs-sm); max-width: 100%;
}
input.ctl { font-family: var(--font-mono); }

/* A control that is not yours to type in. The stock drawer's Unit cost goes `disabled` the
   moment a case pack is filled — from then on it is the case price divided down — and until
   this rule existed it did that with no visual change at all: same white well, same black
   ink, opacity 1, pixel-identical to the two live fields beside it. cursor:not-allowed is
   the pair .btn[disabled] already uses, so "off" reads the same on a control as on a button;
   the flat well and the meta ink are what say the box itself has stopped being an input.
   Not opacity: these fields sit next to live ones and a faded-but-white box reads as a
   rendering artefact rather than a state.
   -webkit-text-fill-color because iOS Safari greys disabled text with its own value and
   ignores `color` — the same reason the autofill block below sets it.
   (0,2,0) deliberately: ff-data.js injects `select.ctl,input.ctl,textarea.ctl` (0,1,1) into
   <head> at runtime, which lands after this file, so specificity rather than source order is
   what keeps this skin. ff-stock-drawer-check.js asserts the rule is here. */
.ctl:disabled {
  background: var(--tint-4); box-shadow: none;
  color: var(--meta); -webkit-text-fill-color: var(--meta);
  cursor: not-allowed;
}

/* Autofilled fields. Chrome paints its own #E8F0FE over anything we set, and it paints it
   with `background-color`, which no cascade of ours outranks — the one property it does not
   own is the inset box-shadow, so a 1000px inset spread is the standard way to put our own
   surface back. -webkit-text-fill-color goes with it because `color` is overridden too.
   The transition is the second half of the same trick: Chrome re-applies its blue on blur
   and on re-fill, and a background-color transition delayed past any real session means that
   repaint never arrives.
   :autofill is the standard selector (Firefox, and Chrome from 108); :-webkit-autofill is
   what Safari and older Chrome answer to. They are SEPARATE rules on purpose — one unknown
   selector voids the whole list it sits in, and Safari does not know :autofill.
   No :where() here either, deliberately: auth.html styles `.field input` in its own page CSS
   at (0,1,1), and a :where()-wrapped rule scores 0 for its list and loses the box-shadow to
   it. The class has to be in the selector to outrank the page.
   Mobile: nothing here touches height or padding, so the field an autofill lands in is the
   same tap target it was empty — .ctl keeps its var(--tap-min). */
.ctl:autofill, .field input:autofill, .fld input:autofill,
.ctl:autofill:hover, .field input:autofill:hover, .fld input:autofill:hover,
.ctl:autofill:focus, .field input:autofill:focus, .fld input:autofill:focus {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset;
  -webkit-text-fill-color: var(--fg);
  caret-color: var(--fg);
  transition: background-color 600000s 0s, border-color .15s var(--ease-standard);
}
.ctl:-webkit-autofill, .field input:-webkit-autofill, .fld input:-webkit-autofill,
.ctl:-webkit-autofill:hover, .field input:-webkit-autofill:hover, .fld input:-webkit-autofill:hover,
.ctl:-webkit-autofill:focus, .field input:-webkit-autofill:focus, .fld input:-webkit-autofill:focus {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset;
  -webkit-text-fill-color: var(--fg);
  caret-color: var(--fg);
  transition: background-color 600000s 0s, border-color .15s var(--ease-standard);
}
/* The focus ring survives: the :focus-visible rule near the top of the control tier also sets
   box-shadow, and without these the autofill rules would outrank it and swallow the ring. */
.ctl:autofill:focus-visible, .field input:autofill:focus-visible, .fld input:autofill:focus-visible {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset, 0 0 0 2px var(--ring);
}
.ctl:-webkit-autofill:focus-visible, .field input:-webkit-autofill:focus-visible, .fld input:-webkit-autofill:focus-visible {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset, 0 0 0 2px var(--ring);
}
/* Round 3 D: flat. White, the standard border, blue only once focused. */
input.ctl[type="search"], .searchbox {
  border-color: var(--border);
  background: var(--surface);
  box-shadow: none;
}
input.ctl[type="search"]:focus-visible, .searchbox:focus-within { border-color: var(--accent); }
.searchbox {
  display: flex; align-items: center; gap: var(--s2);
  height: 36px; padding: 0 var(--s2) 0 var(--s3);
  border: 1px solid rgba(21, 107, 255, 0.24); border-radius: var(--r-sm);
  color: var(--meta); font-family: var(--font-mono); font-size: var(--fs-xs);
}
/* ↑↓ / ↵ / esc under any keyboard-navigable list */
.keyhints { display: flex; align-items: center; gap: var(--s2); font-family: var(--font-mono); font-size: 11px; color: var(--meta); margin-top: var(--s3); }

/* ─── row state ladder ───────────────────────────────────────────────────────
   Rest is translucent frost with a weak border and no shadow. Promoted goes to
   near-solid warm white, picks up an accent-tinted border, and only THEN earns a
   shadow plus a 1px lift — that combination is what makes a row read as chosen
   rather than merely different. Active inverts to a solid accent fill with a
   shadow tinted to the SAME accent: the one sanctioned exception to the
   blue-slate rule, because the light source is now the element itself.

   Frost is deliberately absent here. A backdrop-filter per row is a repaint per
   row, and inventory.html and items.html run to hundreds of them. */
.ff-agenda .ag-row, .listrow {
  background: var(--frost-soft);
  border: 1px solid var(--border-weak);
  border-radius: var(--r-row);
  transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard),
              box-shadow var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard);
  /* §0.3 mobile: the CSS half of ffTapGuard. The guard cancels the tap once a drag has
     started, but without pan-y the browser can still treat the row as the gesture target
     and eat the scroll. Both halves or neither. */
  touch-action: pan-y;
}
.ff-agenda .ag-row:hover, .listrow:hover {
  background: var(--frost-promoted);
  border-color: rgba(21, 107, 255, 0.24);
  box-shadow: var(--elev-promoted);
  transform: translateY(-1px);
}
.listrow.is-active {
  background: var(--accent); border-color: rgba(21, 107, 255, 0.28);
  box-shadow: var(--elev-active);
  transform: translateY(-1px);
  color: var(--bg);
}

/* ─── responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 992px) {
  :root { --rail: 68px; }
  .rail { padding: var(--s5) var(--s2); align-items: center; }
  .brand { padding-inline: 0; justify-content: center; }
  .brand b, .brand .sep, .railnav a span.txt, .railnav a .tag, .railfoot { display: none; }
  .railnav { width: 100%; }
  .railnav a { justify-content: center; padding: 11px 0; }
  main, .topbar { padding-inline: var(--s5); }
}
@media (max-width: 768px) {
  /* Sized for the account menu's 44px trigger from the first frame: it is injected after
     first paint, and a bar sized to one line of crumbs grew 29px under it and moved the whole
     page down on every phone load (pill debug run 2026-10-22, D §2; ff-boot-shift-check.js). */
  .topbar { flex-wrap: nowrap; min-height: calc(var(--tap-min) + 2 * var(--s4) + 1px); }
  /* flex-basis auto, not 0 (audit §2.2 asked for `flex:1`, which is basis 0): from 0 the
     trail only ever got what the branch picker left over, so between 577px — where S02's
     wrap stops applying — and ~660px it was handed 170px for 226px of crumbs. The root
     crumb ellipsised to 0px wide on Inventory at 500px, i.e. the way back to the dashboard
     vanished, and the tail cut mid-word. From auto the trail asks for its content first and
     the picker, which already ellipsises against its own min-width:0 below, is what yields.
     Still one row, which is what §2.2 is about. */
  .topbar-id { flex: 1 1 auto; overflow: hidden; }
  .topbar-id .eyebrow, .topbar-id h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* The footer's bottom clearance matches the fixed tabbar (the tabbar is the last
     thing a thumb reaches, so the legal line must not slide under it). */
  .appfoot { padding-bottom: calc(var(--s4) + 104px); }
  /* M03: the demo banner keeps its verdict and its exit, loses its essay. The full
     explainer wraps to three lines here (89px, measured); "Demo data." + the sign-in
     link says the same thing in one. sb-guard.js marks the cut. */
  [data-od-id="demo-mode-banner"] [data-demo-sub] { display: none; }
  /* M01: the account menu goes icon-only. The trigger's business-name label is the third
     copy of it in a 390px topbar (root crumb, branch picker, this) and it is what pushed
     the menu onto a wrap row of its own — a 157px topbar on dashboard, measured. The label
     is clipped, not display:none, so it stays the trigger's accessible name. */
  .topbar > .qfmenu .qfmenu-trigger { justify-content: center; gap: 0; width: var(--tap-min); min-width: var(--tap-min); padding: 0; }
  .topbar > .qfmenu .qfmenu-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  /* And the branch picker may shrink: a long branch name ellipsises inside the trigger
     instead of shoving the row into a wrap. */
  .topbar [data-od-id="branch-pick-mount"] { min-width: 0; }
  .topbar .branchpick { min-width: 0; max-width: 100%; }
  .topbar .branchpick-trigger { max-width: 100%; min-width: 0; }
  .topbar .branchpick-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
  /* M02: the topbar yields on the way down. Reading direction is downward and the phone
     viewport is already paying for Safari's own chrome, so once the reader is past the
     header the bar slides off (exit) and any upward scroll brings it back (enter) —
     ff-data.js's chromeElevation owns the class. `html .topbar` outranks ff-data.js's
     later-injected `.topbar,.tabbar` box-shadow transition, which this restates so the
     lift keeps animating too. Reduced motion: both durations are tokens, so the :root
     collapse makes the hide instant without a rule here. */
  html .topbar {
    transition: box-shadow var(--dur-panel) var(--ease-standard),
                transform var(--dur-enter) var(--ease-move);
  }
  html.ff-chrome-hide .topbar {
    transform: translateY(calc(-100% - 1px));   /* -1px: the border-bottom goes too */
    transition: box-shadow var(--dur-panel) var(--ease-standard),
                transform var(--dur-exit) var(--ease-move);
  }
}
/* S02: below 576px even the trimmed nowrap row (topbar-id + branch picker + alert
   button + sync pill + account menu, dashboard's worst case) overflows and pushes
   the account-menu trigger off-screen — Sign out becomes unreachable on a phone.
   Wrapping to a second row keeps every control in the viewport; the other eleven
   screens already fit in one row at this width, so wrap is a no-op for them.
   Stays at 480: the 481–700 band is fixed by the flex-basis above, in one row, which is
   what §2.2 wants. This rule is for the width where one row genuinely cannot hold it. */
@media (max-width: 576px) {
  .topbar { flex-wrap: wrap; }
  /* And the trail takes the whole first row. `.topbar-id{flex:1}` above is flex-basis:0,
     so the crumbs only got the space the branch picker left over — at 390px that ellipsised
     "Tita Belen's Burgers" down to "Tit…", which is not a business name and not a link
     anyone would aim at. A full-width basis pushes the picker onto the wrap row instead. */
  .topbar-id { flex-basis: 100%; }
  /* M01b: only the branch picker earns that second row. On the eight screens without one
     the wrap row held a lone 44px account icon, so the trail keeps flex:1 and shares the
     row with it — a one-row topbar. A browser without :has keeps the two-row layout,
     which is today's behaviour, not a broken one. .pos-till is Sales' one-branch picker
     (C10), which earns the row the same way (review 21 Oct, P3). */
  .topbar:not(:has(.branchpick, .pos-till)) .topbar-id { flex-basis: 0; }
}
/* M04: between S02's wrap and the rail, one row is the rule and the root crumb is the crumb
   to lose. Measured on Inventory at 500px: the trail wants 381px (business 156 + Inventory
   77 + tail 92 + two 28px separators) and the row can spare 248 beside the branch picker, so
   something has to give. Dropping the business name costs least — the picker next to it
   already says which branch, the account menu carries the business, and Dashboard, the only
   thing the root crumb ever linked to, is a tab away at this width. What is left,
   "Inventory > Stock levels", is 197px and fits whole, which is the half a reader needs.
   Below 577px S02 hands the trail its own row and the whole thing fits, so this stops. */
@media (min-width: 577px) and (max-width: 768px) {
  .topbar-id h1.crumbs > a:first-child,
  .topbar-id h1.crumbs > a:first-child + .sep { display: none; }
  /* What is left of the trail then holds its ground and the picker is what ellipsises —
     "Stock levels" beats a fuller branch name that the row already repeats in the location
     it filters. Capped at 70% so a long page name cannot walk the account menu off the
     right edge, which is the failure S02 exists to prevent. */
  .topbar-id { flex-shrink: 0; max-width: 70%; }
}
/* F3: under 576 the trail is ONE crumb: the page's own name. At 390 the three-deep trail
   ("Tita Belen's Bur… > Inventory > Stock le…") ellipsised at both ends and the reader could not
   tell where they were. A three-deep trail shows its middle crumb (Inventory, Demand, Products &
   stock: the screen), because the tail is a tab name ("Products" on Demand) and the tab row sits
   right under the bar; a two-deep trail (Activity) shows its tail, the current page. Everything
   else is clipped, not removed, so the heading still reads "{business} > Inventory > Stock
   levels" to a screen reader. The business is in the branch picker and the account menu beside
   it, and Dashboard is a tab away.
   A middle crumb that is a LINK is clipped like the root: items.html's Setup became one
   (2026-10-18), so its phone bar shows the tail ("Stock items"), the way Suppliers and
   Locations show theirs. Setup is the tab bar's own entry at this width. */
@media (max-width: 576px) {
  .topbar-id h1.crumbs > a,
  .topbar-id h1.crumbs > .sep,
  .topbar-id h1.crumbs:has(> span:not(.sep):not([aria-current="page"])) > [aria-current="page"] {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
  .topbar-id h1.crumbs { position: relative; }
  .topbar-id h1.crumbs > span:not(.sep) { flex: 0 1 auto; min-width: 0; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
/* F9: one KPI tile layout on phones. Inventory had a cramped 2x2 whose captions wrapped to
   three lines and Forecast stacked its tiles full width, a whole screen before the table.
   Now both are the same compact two-column grid; an odd last tile spans the row, so three
   tiles read as 2 + 1 and four as 2 x 2. `main .stats` outranks each page's own
   `.stats` repeat(auto-fit, minmax(...)) rule, which loads later at lower specificity. */
@media (max-width: 576px) {
  main .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s3); margin-bottom: var(--s4); }
  main .stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
  main .stats > .stat.card { padding: var(--s3) var(--s4); }
  main .stats .s-num { font-size: var(--fs-xl); line-height: 1.1; }
  main .stats .s-lab { font-size: var(--fs-xs); margin-top: 4px; }
  main .stats .stat > p { font-size: var(--fs-xs); line-height: 1.35; margin-top: 4px; }
}
/* The opt-in frost look goes opaque on phones (the Frost Budget Rule). A low-end Android
   pays for backdrop-filter in scroll frames, and the fallback is the same surface the
   @supports block already ships, so nothing else has to change. Harmless on the flat
   default, which has no blur to take off. */
@media (max-width: 768px) {
  .card, .rail, .topbar, .tabbar {
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
  :where([data-surface="frost"]) .card { background: var(--grain-panel) repeat 0 0 / 160px 160px, var(--sheen), var(--splash-card), var(--surface); }
  .rail, .topbar, .tabbar { background: var(--surface); }
  /* Scroll-frame cost trio (mobile audit §2.3): fixed background-attachment repaints the
     gradient on every scroll frame, and the two fixed filtered/blended layers recomposite
     every frame too — all decoration, none of it worth paying for on a phone. */
  body { background-attachment: scroll; }
  body::before, body::after { filter: none; mix-blend-mode: normal; opacity: 1; }
}
/* M1 (19b): a text field under 16px makes iOS Safari zoom the page on focus, so every edit
   on a phone started with an unwanted zoom and a sideways pan back. Pages, list searches and
   the import grid all set 14px (--fs-sm) at class specificity, in ff-theme.css, in each
   screen's <style> and in ff-data.js's injected sheet, which loads last. !important because
   this is a platform floor, not a skin choice: no later rule should be able to put a phone
   back under it. Boxes that toggle (checkbox, radio, range, colour) have no text and are
   left alone. */
@media (max-width: 768px) {
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]),
  select, textarea { font-size: var(--fs-body) !important; }
}

/* ─── reduced motion ─────────────────────────────────────────────────────────
   Strips the 1px row lift and the arrow translate; the colour, border and opacity
   halves of every state change survive, so state is still conveyed without motion. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  .ff-agenda .ag-row:hover, .listrow:hover, .listrow.is-active { transform: none; }
  .btn:hover .arrow { transform: none; }
  .btn:active { transform: none; }
}

/* ─── scroll-edge affordance ────────────────────────────────────────────────
   Six findings, one missing signal: at 390px the stock table is 898px in a 316px window with
   STATUS off-screen (A-04), items hides its whole action column the same way (B-16), supply's
   order plan is 1,171px with SUGGESTED ORDER off-screen (C-13), forecast's matrix shows 4 of 15
   columns (C-23) and onboarding's CSV preview cuts QTY SOLD (C-34). Every one of them measured
   `mask-image: none` and `box-shadow: none`. The mask rides on the scroll container, so it stays
   pinned to the right edge of the box while the content moves under it.
   ponytail: the veil is constant, not driven by scroll position — at the very end of a sideways
   scroll the last 24px is still at 55% alpha (legible, and this is one declaration). The
   conditional version is `animation-timeline: scroll(self inline)`, which needs a keyframe, an
   @supports fence and an answer for the inactive-timeline case on tables that do not overflow.
   Scoped to ≤768px for the same reason: every one of these wraps demonstrably overflows there,
   and on a desktop where they do not, a veil would be a lie. */
/* The table wraps and the forecast matrix have outgrown this: their fade is the conditional one
   now, at every width, shown only while there is more to that side and paired with an arrow
   (C1, FF_CSS .ff-tscroll in ff-data.js). A mask here would also fade that arrow. The
   onboarding grid keeps the constant veil. */
@media (max-width: 768px) {
  .ff-igrid-wrap {
    mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), rgba(0, 0, 0, .55) 100%);
    box-shadow: inset -14px 0 14px -14px rgba(0, 0, 0, .22);
  }
}

/* ─── public marketing chrome ────────────────────────────────────────────────
   The canonical header and footer (brand/chrome-*.html, pinned by
   public-chrome-check.js) are pasted into all twenty public pages. Eight of them
   already carry their own chrome CSS in their own <style>; four — contact, privacy,
   terms and support — carried a minimal bespoke bar, or in support's case nothing at
   all, and so have no rules for it. These are those rules.

   Everything in the first group is wrapped in :where(), which drops its specificity to
   the bare class. That is the whole point: this is a default for the four bare pages,
   and any page that already styles its own chrome keeps winning on source order
   (ff-theme.css is <link>ed before every page's <style>). Without it, adding
   `.topnav .container` here at (0,2,0) would silently override about/case-studies/guides'
   own `.container` and move their nav gutters — a shared stylesheet reaching into eight
   pages that never asked.
   ponytail: shared here rather than pasted a fourth time because these four pages all
   link ff-theme.css already. index.html does not, and keeps its own copy. */
/* Solid, like the app's .topbar since round 4 (2026-10-16): no glass on the public bar either. */
:where(.topnav) { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--border);
  background: var(--bg); }
:where(.topnav .container) { max-width: 1200px; margin-inline: auto; padding: 14px var(--s6);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4); }
:where(.topnav .logo) { display: inline-flex; align-items: center; min-height: var(--tap-min); text-decoration: none; }
:where(.topnav nav) { display: flex; align-items: center; gap: var(--s8); }
:where(.topnav nav a) { font-size: var(--fs-sm); color: var(--muted); text-decoration: none;
  transition: color var(--dur-fast) var(--ease-standard); }
:where(.topnav nav a:hover, .topnav nav a:focus-visible, .topnav nav a[aria-current="page"]) { color: var(--fg); }
/* The one auth door is the app's outline button (.btn.btn-ghost): 1px --meta, 6px, ink. */
:where(.topnav nav a.nav-login) { padding: 7px 15px; border-radius: var(--r-sm);
  background: transparent; border: 1px solid var(--meta); color: var(--fg);
  transition: background var(--dur-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { :where(.topnav nav a.nav-login:hover) { color: var(--fg); background: var(--tint-4); } }
/* Same breakpoint the other eight pages use: below 768px the six section links fold away
   and the one auth door stays. */
@media (max-width: 768px) { :where(.topnav) nav a:not(.nav-login) { display: none; } }

:where(.pagefoot) { border-top: 1px solid var(--border); text-align: left;
  font-size: var(--fs-sm); color: var(--muted); }
:where(.pagefoot .container) { max-width: 1200px; margin-inline: auto; padding: var(--s12) var(--s6); }
:where(.pagefoot .footlinks) { list-style: none; display: flex; flex-wrap: wrap;
  column-gap: var(--s5); margin: 0 0 var(--s6); padding: 0; }
:where(.pagefoot .footlinks a) { color: var(--muted); text-decoration: none;
  transition: color var(--dur-fast) var(--ease-standard); }
:where(.pagefoot .footlinks a:hover, .pagefoot .footlinks a:focus-visible,
       .pagefoot .footlinks a[aria-current="page"]) { color: var(--fg); }
:where(.pagefoot .row-between) { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s5); flex-wrap: wrap; margin-top: var(--s6); }
/* A status as the app writes one: a 7px dot and the words, no outlined pill. */
:where(.pagefoot .pill) { display: inline-flex; align-items: center; gap: 6px; color: var(--muted);
  font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
:where(.pagefoot .pill)::before { content: ""; width: 7px; height: 7px; border-radius: var(--r-pill);
  background: var(--meta); flex: none; }

/* DESIGN.md:252-253 — the 44px floor holds "including … footer links (grown by
   pseudo-element overlay, not padding)". It was never shipped on the public chrome: the
   audit measured header links at 21-22px and footer links at 18-19px, with no overlay at
   all on eight pages and a -6px one on the landing. These two rules are NOT in :where():
   they are meant to reach every page that links this file, overriding the undersized local
   copies, because a target below the floor is the finding.

   The arithmetic is the reason the row-gap moves with the inset. 14px type in a single nav
   row is a 21px line box → 11.5px each side → -12px lands at 45px. 14px type in a wrapping
   footer list is 18-19px → -13px. But 13px of bleed on both neighbours overlaps unless the
   row gap clears 26px, and a target that overlaps its neighbour is worse than a small one,
   because the press lands on the wrong link — so the gap goes to --s8 (32px) in the same
   breath. Horizontal bleed is smaller than the column gaps it sits in (--s8 = 32px in the
   nav, --s5 = 20px in the list), so nothing overlaps sideways either. */
.topnav nav a { position: relative; }
.topnav nav a::after { content: ""; position: absolute; inset: -12px -10px; }
.pagefoot .footlinks { row-gap: var(--s8); }
.pagefoot .footlinks a { position: relative; }
.pagefoot .footlinks a::after { content: ""; position: absolute; inset: -13px -9px; }

/* Phone tab bar. One copy, here: every app screen used to carry its own, and tickets.html's
   had drifted to 10px labels without min-width:0. Hidden above 700px, a fixed bar below.
   - Opaque, not frosted: the 720px block above already strips backdrop-filter from fixed
     chrome on phones (it costs scroll frames), and this bar only exists under 768px.
   - min-width: 0 is load-bearing. A flex item defaults to min-width:auto, its label word,
     so six tabs on a 320px screen refused to shrink and pushed the last tab off the bar.
   - Labels 11px, 0 tracking: read one-handed at closing time; at .03em Dashboard ran 2px
     past a 60px cell at 390 wide. Narrower phones drop a pixel so nine letters still fit.
   - renderNav() emits <svg> with no size; unsized it stretches to the tab's full width. */
.tabbar { display: none; }
@media (max-width: 768px) {
  .tabbar {
    display: flex; position: fixed; inset: auto 0 0 0; z-index: 20; background: var(--surface);
    border-top: 1px solid var(--border); padding: 6px var(--s2) calc(6px + env(safe-area-inset-bottom));
  }
  .tabbar a {
    flex: 1; min-width: 0; min-height: 48px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
    font-size: 11px; font-family: var(--font-mono); color: var(--muted); border-radius: var(--r-sm);
  }
  .tabbar a span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tabbar a[aria-current="page"] { color: var(--fg); background: var(--tint-4); }
  .tabbar svg { width: 20px; height: 20px; flex: none; }
}

/* A tab row alone in its wrapper, straight under a gap'd #content, is already spaced by the
   gap. Its own 20px bottom margin (ff-data.js .ff-tabs) stacked on the 32px gap into a 52px
   band between the tabs and the card they switch — the widest gap on the screen, around the
   thing it most needs to sit close to. */
#content > div > .ff-tabs:only-child { margin-bottom: 0; }

/* ── supplier item rows ──────────────────────────────────────────────────────────
   "What this supplier brings" and "Locations served", on the Suppliers drawer, and "Locations
   served" on the onboarding Deliveries step. Every pick-some list is these rows, never pills (Daniel, Cycle 14).
   One checkbox row per stock item, A to Z, in groups that are fixed when the screen opens
   (page-suppliers.js itemChipsHtml says why rows must not move under a tap). Here rather than
   in either page's <style> because both screens draw the same rows. */
.sup-items { display: flex; flex-direction: column; gap: var(--s3); }
.sup-grp { display: flex; flex-direction: column; gap: var(--s1); }
.sup-grp-h { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s2);
  font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--meta); }
.sup-rows, .branch-chips { display: flex; flex-direction: column; border: 1px solid var(--border-weak); border-radius: var(--r-sm); overflow: hidden; }
.sup-row, .branch-chip { display: flex; align-items: center; gap: var(--s3); width: 100%; min-height: 52px; padding: 6px var(--s3);
  border: 0; border-top: 1px solid var(--border-weak); background: var(--surface); color: var(--fg);
  font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer;
  transition: background var(--dur-fast) var(--ease-standard); }
/* 19b: one row height (I16; a sub-line made rows 48, 51 or 52px, so the list stepped), the
   radius from the scale (I17, was an off-scale 5px), and a long name wraps to a second line
   before it ellipsises (I13: at 390 only 248 of 403px of a name showed, with no way to read
   the rest). The rows are role=checkbox in the markup (I18): they look and act as one. */
.sup-row:first-child, .branch-chip:first-child { border-top: 0; }
@media (hover: hover) { .sup-row:hover, .branch-chip:not(:disabled):hover { background: var(--tint-4); } }
.sup-row:focus-visible, .branch-chip:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.sup-box { flex: none; width: 20px; height: 20px; box-sizing: border-box; border-radius: var(--r-sm);
  border: 1.5px solid var(--muted); display: grid; place-items: center; }
.sup-row.on .sup-box, .branch-chip.on .sup-box { background: var(--accent); border-color: var(--accent); }
.sup-row.on .sup-box::after, .branch-chip.on .sup-box::after { content: ''; width: 10px; height: 6px; border: 2px solid var(--btn-fg);
  border-top: 0; border-right: 0; transform: rotate(-45deg) translate(1px, -1px); }
.sup-row-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sup-row-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sup-row-nm { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow-wrap: anywhere; }
.sup-row-sub { font-size: var(--fs-xs); color: var(--muted); }
.sup-row-sub.mv { color: var(--accent-ink); }
/* A location another schedule already serves: visible but inert, unticked there first. */
.branch-chip:disabled { cursor: not-allowed; color: var(--muted); }
.branch-chip:disabled .sup-box { opacity: .45; }
/* The Stock ID, right-aligned in mono so a column of them lines up down the list. */
.sup-row-id { flex: none; max-width: 38%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--muted); }
/* The folded group (I14, Daniel's Cycle 15 pick A). Its header is the same plain label every
   other group has, drawn as a row: the whole line is the tap target (the 44px floor), and a
   chevron at its end turns when it opens. It was a grey bordered box with a text "+", which
   read as a disabled field and named its state in a glyph nobody reads as "show more". */
.sup-held > .sup-grp-h { width: 100%; text-align: left; cursor: pointer; min-height: var(--tap-min); padding: 0;
  align-items: center; justify-content: flex-start; }
.sup-held > .sup-grp-h > span:first-child { flex: 1; }
.sup-held > .sup-grp-h::after { content: ''; flex: none; width: 7px; height: 7px; margin: 0 var(--s2) 3px var(--s3);
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg);
  transition: transform var(--dur-fast) var(--ease-standard); }
.sup-held > .sup-grp-h[aria-expanded="true"]::after { transform: translateY(3px) rotate(-135deg); }
/* UT14 row 3: as a plain label it still went unnoticed — a tester never opened it. Drawn as a
   button now (border, surface, accent ink), still one line at the 44px floor with the chevron. */
.sup-held > .sup-grp-h { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
  color: var(--accent-ink); padding: 0 var(--s1) 0 var(--s3); transition: background var(--dur-fast) var(--ease-standard); }
.sup-held > .sup-grp-h:hover { background: var(--tint-4); }
.sup-held-body { padding-top: var(--s1); }
.sup-held { gap: 0; }
.sup-held-body > p { margin: var(--s2) 0 0; }
.sup-held > .sup-grp-h:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .sup-row { transition: none; } }

/* The branch chip a refused Save points at (qfNeedBranch): red, the invalid-field colour
   (UT12 U1, 20 Oct — it was the warn ring, and amber is already the chip's untouched look),
   cleared the moment a branch is picked. .pos-till is the sales till's own picker. */
.ff-branch-chip.is-needed, .pos-till.is-needed{outline:2px solid var(--danger);outline-offset:2px;border-radius:var(--r-sm)}

/* ─── Setup subnav (moved from ff-data.js's FF_CSS, round 4 J) ───────────────────── */
.ff-subnav{display:flex;gap:2px;margin-bottom:var(--s5);min-width:0;max-width:100%;
  overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;
  padding-bottom:1px;box-shadow:inset 0 -1px 0 var(--border)}
.ff-subnav::-webkit-scrollbar{display:none}
.ff-subnav a{flex:none;white-space:nowrap}
.ff-subnav a{padding:var(--s3) var(--s4);font-size:var(--fs-sm);color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--motion-fast,.13s) var(--ease-standard),border-bottom-color var(--motion-gentle,.22s) var(--ease-standard)}
.ff-subnav a:hover{color:var(--fg)}
/* Round 3 F: the underline is blue and, because each of these links loads another page, it is
   its own named element so the cross-document view transition slides it from the old tab to
   the new one instead of the line blinking out and back. */
.ff-subnav a{position:relative}
.ff-subnav a[aria-current="page"]{color:var(--fg)}
.ff-subnav a[aria-current="page"]::after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:2px;background:var(--accent);view-transition-name:subnav-ind}
.ff-subnav a:focus-visible{outline:2px solid transparent;box-shadow:inset 0 0 0 2px var(--ring);border-radius:var(--r-sm)}
/* Four Setup entries since Stock items got its own (UT 2026-10-17). At 16px a side they
   overran a 375px phone by ~30px and "Stock items" — the very entry the tester could not
   find — was the one cut off behind the scroll. 8px a side fits all four; the tap height
   is the vertical padding and is untouched. */
@media (max-width:576px){.ff-subnav a{padding-left:var(--s2);padding-right:var(--s2)}}

/* ─── reason line inside a drawer's save bar (round 5 N) ──────────────────────────── */
/* qfWhyPlace() (ff-data.js) and ff-blocked.js put a hollow Save's reason inside .sheet-acts,
   just before the button, so it stays on screen with it (the bar is sticky, fixed on a
   phone). Red here, by Daniel's note: in the bar it is the one thing standing between the
   owner and Save. --danger-ink is 5.9:1 on --surface. Reasons in normal page flow stay grey. */
.sheet-acts > .ff-blocked-why, .sheet-acts > .ff-blocked-why.is-quiet {
  flex: 1 1 14em; margin: 0; color: var(--danger-ink); font-size: var(--fs-sm); text-align: right;
}
/* One reason per bar, however many hollow controls it holds (the adjust sheet in "Set the
   count to" has Save & next and Save, each with its own line; visual r4 P1-1). The first
   line whose control is showing speaks for the bar. */
.sheet-acts > .ff-blocked-why:not(:has(+ [hidden])) ~ .ff-blocked-why { display: none; }
/* The line sits right before its control, so a hidden control (the adjust sheet's "Save &
   next" outside count mode) hides its line with it, however it was toggled (review3 K3). */
.sheet-acts > .ff-blocked-why:has(+ [hidden]) { display: none; }

/* ─── desktop: reasons in busy rows go into the bubble (round 6 P) ───────────────── */
/* Daniel, 2026-10-19: "To prevent moving UI interactives … move error messages that exist in
   between busy rows or columns into … a word bubble on top." From 768px up, a save bar's
   reason (every .ff-blocked-why: drawers, sheets, Profile, the till, Create order) and a red
   field's error inside a plan-table cell or a card stepper leave the flow. The control
   carries the sentence as data-tip and qfTip (ff-data.js) draws it as one bubble above it on
   hover, keyboard focus or a refused click; Esc or leaving closes it. The line stays in the
   DOM, visually hidden, as the control's aria-describedby (and as the role=alert a refused
   click announces). A standalone field's error under it, with room, stays inline.
   Round 7 (Daniel, 2026-10-19, the phone pass) takes the same two out of the flow on a phone:
   R7a, a bar's reason (a drawer or page save bar, the onboarding Continue bar; the till's is
   in sales.html), opens as the bubble when the faded button is tapped; R7c, a red field in a
   busy row (plan-table cell, card stepper, a .ff-busyrow such as a supplier's minimum) is a
   red box that shows its sentence when tapped or focused. The bar stays one row and never
   jumps (it grew 69 -> 150px and moved 29-81px with the line). Reasons in normal page flow,
   and a standalone field's error, stay inline on a phone (R7b).
   Except Profile's three field rows (align decisions d8 A, Daniel 2026-10-27): "Nothing to
   save yet." came and went under Save as the name was typed and moved every row below it
   37px. On a phone too it is the bubble a tap on the faded button opens (R7a's click path in
   qfTip), and nothing moves. .pf-standing is the demo's "Sign in to use this." under a
   disabled Redeem: a disabled button takes no tap, and that line never comes or goes.
   R7b stands everywhere else (support.html's Send, Demand's override Set, add forms). */
.pf-field > .ff-blocked-why:not(.pf-standing),
.sheet-acts > .ff-blocked-why, .sheet-acts > .ff-blocked-why.is-quiet,
.ob-nav + .ff-blocked-why, .ob-nav > .ff-blocked-why,
.ff-table td .ff-reveal:has(> [data-qty-err]), .stepper + .ff-reveal:has(> [data-qty-err]),
.ff-busyrow .ff-reveal:has(> [data-qty-err]), .ff-card-foot > .ff-blocked-why {
  position: absolute !important; width: 1px !important; height: 1px !important; margin: 0 !important;
  padding: 0 !important; overflow: hidden !important; clip-path: inset(50%) !important;
  white-space: nowrap !important; border: 0 !important; box-shadow: none !important;
}
@media (min-width: 768px) {
  .ff-blocked-why, .ff-blocked-why.is-quiet {
    position: absolute !important; width: 1px !important; height: 1px !important; margin: 0 !important;
    padding: 0 !important; overflow: hidden !important; clip-path: inset(50%) !important;
    white-space: nowrap !important; border: 0 !important; box-shadow: none !important;
  }
  /* The footer stays one line: "Unsaved changes", Discard and the Saves, no wrapped words. */
  .sheet-acts, .sheet-acts:has(> .ff-blocked-why) { flex-wrap: nowrap; }
  .sheet-acts > .btn, .sheet-acts::before { white-space: nowrap; }
}
/* Round 7: a field focused by a refused Save, or by Tab, lands clear of the sticky top bar and,
   on a record page, of the fixed save bar and tab bar (it scrolled under the demo banner or the
   bar; phone audit C-13, C-15). --ff-th-top is the top bar's live bottom (ff-data.js). */
@media (max-width: 768px) {
  html { scroll-padding-top: calc(var(--ff-th-top, 0px) + var(--s3)); }
  html:has(body.ff-paged) { scroll-padding-bottom: calc(61px + var(--tap-min) + 2 * var(--s3) + 1px + var(--s3) + env(safe-area-inset-bottom)); }
}
/* The bubble's caret. qfTip sets --caret-x (the anchor's centre, in the tip's own box) and
   .is-below when it had to flip under the anchor. */
.ff-tip::after {
  content: ""; position: absolute; left: var(--caret-x, 50%); top: 100%; translate: -50% 0;
  border: 6px solid transparent; border-top-color: var(--fg);
}
.ff-tip.is-below::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: var(--fg); }

/* Jev in flight on an import panel (ff-import.js jevBusy). Daniel, 2026-10-26: no latency cap,
   but the wait must show, and quietly: one 2px line in the accent, low contrast against its own
   tint, nothing that flashes. The height and margin are always there and only visibility
   toggles, so the panel never moves when it comes or goes. */
.ff-jevbar { height: 2px; margin: var(--s1) 0 0; border-radius: 1px; overflow: hidden; visibility: hidden; background: var(--accent-soft); }
.ff-jevbar.is-on { visibility: visible; }
.ff-jevbar::before { content: ""; display: block; width: 40%; height: 100%; background: var(--accent); animation: ff-jevbar 1.2s ease-in-out infinite; }
@keyframes ff-jevbar { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@media (prefers-reduced-motion: reduce) {
  .ff-jevbar::before { animation: none; width: 100%; opacity: .45; }
}
