/* ChefsLab Pro — app design system (ported from the prototype). Apple-style, CSS-variable themed,
   OLED-true-black dark mode. Drives the real app shell + all in-app screens. */
:root{
  color-scheme:light;   /* native controls (the <select> option popup, scrollbars) render light */
  --font:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Segoe UI",system-ui,sans-serif;
  --r-card:20px; --r-ctrl:12px;
  /* NO-JS FALLBACK for the sticky .top bar's height. Everything that pins under the bar should use
     var(--bar-h,var(--appbar-h)): --bar-h is the bar MEASURED by app.js at the current width, this
     token is what a scripting-off visitor gets.
     56px was 3px SHORT of the real bar at 1280 (59px), so .plan-sech — which used this token raw,
     with no measured fallback — stuck 3px too high and lost its top hairline under the bar. Corrected
     to 59px and rounded UP on purpose: the bar is 57px on a phone and 73px around 1024, and there is
     no one right number, but overshooting leaves a harmless sliver of gap while undershooting hides
     content behind the bar. Erring high is the safe direction. (2026-08-10 nav-under-bar fix) */
  --appbar-h:59px;
  --accent:#34C759; --accent-press:#2aa84a;
  /* Focus ring colour — deliberately NOT --accent: green #34C759 is only 1.99:1 on --bg and 2.22:1 on
     a card, so an accent ring fails WCAG 1.4.11 (3:1 for a focus indicator). #0b6b2c gives 5.97:1 on
     --bg / 6.66:1 on --bg-elev. Dark theme keeps the bright accent (10.39:1 on black). (a11y audit) */
  --focus:#0b6b2c;
  /* PRIMARY-BUTTON PAIR (chef 2026-07-31: "do the login page contrast"). .btn.primary was
     background:var(--accent) with color:#fff — white on #34C759 is 2.20:1, which fails WCAG AA for text
     (4.5:1) AND the 3:1 floor for a UI component. The comment three lines up already worked this out for
     the focus ring and fixed THAT; the buttons kept the failing pair, so the app has been shipping a
     documented contrast failure on every primary action, not only on the sign-in page where he noticed it.
     These are the values the landing page arrived at and has been shipping since 9c68a95 — promoted here
     rather than re-derived, so there is one definition instead of two that drift.
     Light: #0f7a33 + white = 5.4:1. Dark: the bright accent + near-black ink = 8.8:1, which keeps the
     brand green as the fill where the background can carry it. */
  --btn-fill:#0f7a33; --btn-fill-hover:#0b6b2c; --btn-ink:#ffffff;
  /* Accent GREEN AS TEXT. Same 2.20:1 problem: fine as a fill, unreadable as small type on a light card.
     In dark mode this is deliberately identical to --accent, so switching a rule from --accent to
     --accent-text changes light mode only and leaves dark untouched. */
  --accent-text:#0b6b2c;
  /* ── STATUS AND ACTION ROLES (2026-08-22, tablet import brief §3.2) ─────────────────────────────
     Warm = state, cool = action. Three status roles a chef reads, one interactive role a chef taps.
     Never let a status colour be tappable, and never let the action colour carry meaning about state.
     Light values alias the pairs already measured in this file, so there is one opinion about green,
     not two. Contrast on white card / #f2f2f7 page:
       --cleared   6.78 / 6.07     --held  5.79 / 5.19
       --stop      5.90 / 5.28     --induction 5.84 / 5.23
     Every status must ALSO carry a glyph and a word (✓ Cleared / ⚠ Held / ⛔ Stopped) — colour is
     never the only carrier, both for WCAG 1.4.1 and for the ~8% of men with a red-green deficiency. */
  --cleared:#17692f; --held:#965600; --stop:#c1261c;
  --induction:#0b5ed7;
  /* Cool graphite ground from the brief. DECLARED, NOT WIRED: swapping --bg to these repaints every
     screen, and the current OLED black has MORE contrast headroom (--stop scores 5.37 on #000 against
     4.64 on #12161A). Adopting them is a whole-app decision, so they wait here until it is taken. */
  --steel-900:#12161A; --steel-800:#191F25; --steel-700:#232B33;
  --chalk:#E8EDF2; --chalk-dim:#93A1AE;
  --red:#FF3B30; --orange:#FF9500; --green:#34C759; --blue:#007AFF;
  --bg:#f2f2f7; --bg-elev:#fff; --card:#fff;
  --nav-bg:rgba(245,245,247,.8); --bar-bg:rgba(255,255,255,.72);
  --text:#1c1c1e; --text2:rgba(60,60,67,.72); --text3:rgba(60,60,67,.3);  /* --text2 .6->.72: WCAG-AA contrast on light bg (a11y audit P1) */
  --sep:rgba(60,60,67,.16); --hair:rgba(60,60,67,.12);
  --fill:rgba(120,120,128,.12); --fill-2:rgba(120,120,128,.08);
  /* --fill-2 IS NOT A HOVER OF --fill, IT IS A WEAKER FILL (2026-08-17). It reads as one because most
     of its users hover from TRANSPARENT (nav.side a, .botnav a, tbody tr, .navtoggle, .botmore-panel
     a) where .08 is an increase. But .btn.ghost and .iconbtn REST on --fill, so swapping in --fill-2
     took them .12 -> .08 light and .24 -> .16 dark: the control got FAINTER as the cursor landed on
     it. This is the step ABOVE --fill, for the "hovering something already filled" case. The two
     existing tokens are untouched, so nothing that hovers from transparent moves. */
  --fill-strong:rgba(120,120,128,.20);
  --shadow:0 1px 2px rgba(0,0,0,.04),0 6px 20px rgba(0,0,0,.06);
  --shadow-lift:0 8px 30px rgba(0,0,0,.12);
  /* STATUS INKS ARE SMALL TEXT, so the floor is 4.5:1 — and all three were tuned against a white CARD
     while tinted badges also sit directly on --bg, where every one of them fell short (2026-08-21 design
     audit). --ok-fg was the worst and the reason is written at app.css:468-478: it was measured for
     .kpi .val, which is 34px/700 and therefore governed by the 3:1 LARGE-TEXT floor, then reused on
     10-13px badges where that floor does not apply. A token validated at one size is not validated at
     another. Measured on ok-bg over the page background: 3.62:1 -> 5.57:1, warn 4.43 -> 4.69, alert
     4.45 -> 4.66. Dark overrides are untouched. */
  --ok-bg:rgba(52,199,89,.12); --ok-fg:#17692f;
  --warn-bg:rgba(255,149,0,.14); --warn-fg:#965600;
  --al-bg:rgba(255,59,48,.10); --al-fg:#c1261c;
  --accent-tint:rgba(52,199,89,.14);
  --cost-tint:rgba(180,130,20,.09);   /* tint for the recipe costing columns — a muted ledger amber */

  /* ---- design-system scales (Superplan Phase 0, 2026-07-10). Theme-independent — the dark block
     below overrides only colours. Migrating the app's magic numbers onto these is ongoing Phase-0 work
     (see docs/SUPERPLAN-APPLE-UI-AND-SCALE.md). ---- */
  /* Type ramp (Apple text styles) — replaces 20+ ad-hoc sizes incl. the half-point 10.5/11.5/…/15.5px. */
  --fs-caption2:11px; --fs-caption:12px; --fs-footnote:13px; --fs-subhead:15px; --fs-callout:16px;
  --fs-body:17px; --fs-title3:20px; --fs-title2:22px; --fs-title1:28px; --fs-large:34px;
  /* Spacing — strict 4pt grid; replaces off-grid 9/11/13/17/22/26 values. */
  --sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px; --sp-6:24px; --sp-8:32px; --sp-10:40px;
  /* Radius scale (extends --r-card:20 / --r-ctrl:12). */
  --r-xs:6px; --r-sm:8px; --r-md:12px; --r-lg:16px; --r-pill:999px;
  /* Motion — one spring for direct manipulation, one standard curve, three durations. */
  --ease-spring:cubic-bezier(.2,.7,.3,1); --ease:cubic-bezier(.4,0,.2,1);
  --dur-fast:120ms; --dur:200ms; --dur-slow:320ms;
}
:root[data-theme="dark"]{
  color-scheme:dark;    /* so the native <select> dropdown list themes dark (was light bg + light text) */
  --bg:#000; --bg-elev:#1c1c1e; --card:#1c1c1e;
  --nav-bg:rgba(10,10,12,.7); --bar-bg:rgba(0,0,0,.6);
  --text:#fff; --text2:rgba(235,235,245,.6); --text3:rgba(235,235,245,.3);
  --sep:rgba(84,84,88,.55); --hair:rgba(84,84,88,.4);
  --fill:rgba(120,120,128,.24); --fill-2:rgba(120,120,128,.16); --fill-strong:rgba(120,120,128,.34);
  --shadow:none; --shadow-lift:0 10px 40px rgba(0,0,0,.6);
  --accent:#30D158; --accent-press:#28b94c; --green:#30D158; --red:#FF453A; --orange:#FF9F0A; --blue:#0A84FF;
  --focus:#30D158;   /* on OLED black this is 10.39:1 — the bright accent is a fine ring in dark mode */
  /* Dark keeps the BRIGHT green as the button fill and flips the ink to near-black: 8.8:1, and the brand
     colour stays the thing you see. --accent-text is the accent itself here, because green on black is
     already 10.39:1 — so a rule moved to --accent-text is unchanged in dark mode by construction. */
  --btn-fill:#30D158; --btn-fill-hover:#5ce07d; --btn-ink:#04240f;
  --accent-text:#30D158;
  /* Dark roles. --stop is #ea6c70, NOT the briefed #E5484D: measured on the raised chit ground
     (--steel-700) the briefed red is 3.66:1, which fails AA for the 17px blocker copy it carries.
     #ea6c70 gives 4.70 / 5.44 / 5.95 across steel-700/800/900 and 6.6:1 on the OLED black shipping
     today, so it is correct on either ground. --cleared and --held are the briefed values, both
     verified comfortable (8.19:1 and 8.91:1 on steel-900). */
  --cleared:#34C759; --held:#F5A524; --stop:#ea6c70;
  --induction:#4CC2FF;
  --ok-bg:rgba(48,209,88,.18); --ok-fg:#30D158;
  --warn-bg:rgba(255,159,10,.20); --warn-fg:#FFB340;
  --al-bg:rgba(255,69,58,.18); --al-fg:#FF6961;
  --accent-tint:rgba(48,209,88,.18);
  --cost-tint:rgba(255,196,90,.10);
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%}
/* normalize: the `hidden` attribute must always win. The UA `[hidden]{display:none}` is only (0,1,0) and
   loses to element display rules like `.respcards tr{display:block}` — so a `<tr … hidden>` (e.g. the
   data-empty / data-filter rows) would still show. This is the standard normalize.css hardening and lets
   us hide via the `hidden` attribute instead of an inline style="display:none" (audit 9.1 CSP). */
[hidden]{display:none!important}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:var(--fs-subhead);line-height:1.45;-webkit-font-smoothing:antialiased}
a{color:var(--accent-text);text-decoration:none} button{font-family:inherit;cursor:pointer;color:inherit}
::selection{background:var(--accent-tint)}
/* ---- accessibility: keyboard focus ring + skip-to-content ---- */
:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
main.content:focus{outline:none}
.skip{position:absolute;left:8px;top:-48px;z-index:200;background:var(--bg-elev);border:.5px solid var(--sep);
  border-radius:8px;padding:8px 14px;font-weight:600;box-shadow:var(--shadow-lift);transition:top .15s}
.skip:focus{top:8px}

/* ---- app shell ---- */
/* minmax(0,1fr), NOT 1fr, on the main column (2026-08-11). A `1fr` track carries an implicit
   `min-width:auto`, so it can never shrink below its CONTENT'S min-content width — and the recipe
   ingredients table's min-content is ~1300px. Between 768 and 820 that forced the whole grid wider
   than the viewport and the PAGE scrolled sideways: measured documentElement.scrollWidth 912 against
   a clientWidth of 820, with the furthest-right element the table itself at right=1302.
   It hid at every other width for two different reasons — above ~1024 the track fits anyway, and
   below 760 the grid collapses to a single column — which is why the 2026-08-11 fix for the SAME
   symptom at 1024 (.tablewrap gaining position:relative, a genuinely different cause) did not touch
   this one. Two bugs, one symptom.
   minmax(0,…) lets the track shrink to its share, and .tablewrap's overflow-x:auto then scrolls the
   table inside its own card — which is what it was always meant to do. */
.app{display:grid;grid-template-columns:248px minmax(0,1fr);grid-template-rows:auto 1fr;min-height:100vh;grid-template-areas:"top top" "nav main"}
/* THE APP BAR WRAPS, AND ITS NAME-BEARING ITEMS ELLIPSISE (2026-08-11). Third and last cause of the
   sideways page scroll, after .tablewrap's containing block and the minmax(0,1fr) main track — and
   the only one that was never about the page's content at all.

   WHAT WAS MEASURED, real headed Chrome over CDP, documentElement scrollWidth/clientWidth/scrollX
   after window.scrollTo(600,0), on `/` (which has no table on it) AND on a recipe page:

       1-site org   bar min-content  964   ->  768: sx=196   820: sx=144   900: sx=64   960: sx=4
       3-site org   bar min-content 1202   ->  768: sx=434   820: sx=382   900: sx=302  1024: sx=178

   Injecting `.top{display:none}` dropped documentElement.scrollWidth to exactly the viewport in
   every one of those cells and moved nothing else, and `.top`'s own scrollWidth equalled
   documentElement's to the pixel (964/964, 1202/1202). `.top` spans "top top", so the sidebar/main
   split had nothing to do with it. It looked page-independent and unattributable because it is
   ORG-dependent: the 3-site org is worse only because `chrome_sites|length > 1` renders the .sitesw
   <select>, and a <select> is sized to its LONGEST OPTION (214px for "Limassol Seafront SeaNatra").

   WHY IT SURVIVED THE PHONE BREAKPOINT. @media(max-width:760px) already hides .kitchen, .pill,
   .trialpill, .sitesw and .cs-switch for exactly this reason. Between 761px and the bar's own
   min-content every one of them is back, and this was a NON-WRAPPING flex row whose items all kept
   the default min-width:auto — so the row could not shrink below the sum of its items' min-content
   widths, and pushed the DOCUMENT wider instead.

   WHY WRAP RATHER THAN HIDE MORE. Adding widths: at 768 the fixed, must-not-truncate part alone —
   the 145px brand lockup, the trial pill, the stale-price pill, the Chef Space link, four 36px icon
   buttons, the logout button, gaps and padding — is 888px. There is no cap or ellipsis anywhere in
   the flexible remainder that makes that fit 768: shrinking is arithmetically insufficient, so the
   honest choices were to hide controls up to ~960px or to let the row take a second line. A control
   that disappears is worse than one that moves down 40px, and hiding to ~960 would take the site
   switcher off every ordinary laptop.
   The bar was ALREADY built for this: app.js measures .top and publishes --bar-h on <html> on load,
   on resize and through a ResizeObserver, and its comment says in so many words "the wrap point
   depends on which pills this org shows". Everything that pins under the bar (nav.side, .plan-sech,
   the event cap) reads var(--bar-h, var(--appbar-h)) -- spelled with its fallback here because
   test_css_utility_classes scans this file WITHOUT stripping comments, so a bare no-fallback var()
   in prose reads to it as an undeclared token.
   Two-line bar was the designed-for state; flex-wrap was simply
   never set. test_nav_sticky.py's docstring even describes "~73px around 1024 where its pills wrap
   onto a second line".

   WRAP ALONE IS NOT ENOUGH, which is the subtle half. Flex breaks a line between items; it cannot
   break a SINGLE item that is wider than the line, and every such item here holds user data an org
   controls — the trading name, the member's name-or-email, a site name inside the <select>. One
   400px email would reopen the exact same page scroll on a 375px phone with flex-wrap set. So the
   two halves below are one fix: the row may break, AND every user-data item carries min-width:0, a
   max-width cap and an ellipsis so it can never be the thing that does not fit.

   NOT overflow-x:hidden on html/body: that hides the symptom, kills legitimate horizontal scrolling,
   and there is a test forbidding it. */
.top{grid-area:top;display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;padding:11px 22px;background:var(--bar-bg);
  backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);
  border-bottom:.5px solid var(--sep);position:sticky;top:0;z-index:20}
/* WHAT MAY NEVER SHRINK. These are load-bearing and are pinned flex:0 0 auto so the row wraps around
   them instead of squeezing them: the brand lockup (identity, and the home affordance), and every
   icon button including logout — those carry a 44x44 floor under (pointer:coarse) that a flex shrink
   would silently eat. The short status/nav chips are here too: .trialpill, .pill.fresh, .cs-switch
   and .pill.role hold app-authored strings of at most ~150px, never user data, so they can never be
   the item that overflows a line — and a clipped "prices sta..." on the one control that reports a
   costing problem is a worse outcome than a second row. Their text stays whole.
   `form.m-0:not(.sitesw)` is the logout form — .sitesw is also .m-0 and is deliberately NOT pinned;
   it is capped instead, in the .sitesw rule further down. */
.top .brand,.top .iconbtn,.top form.m-0:not(.sitesw),
.top .trialpill,.top .pill.fresh,.top .cs-switch,.top .pill.role{flex:0 0 auto}
/* BRAND LOCKUP (kit 2026-07-27). The mark and the wordmark are two background images, so the phone
   breakpoint can drop the wordmark and leave the kit's "symbol alone" mark — exactly the fallback the
   brand guide prescribes. The file swaps on data-theme, NOT prefers-color-scheme: this app themes by an
   attribute on <html> and a user can pick a theme against their OS preference, so a media query would
   show the light lockup on a dark bar. The mark stays 28px — above the guide's 24px floor, so the
   bubbled mark is legible and mark-solid-* is not needed. */
/* flex:0 0 auto, NOT the min-width:0 this carried before (2026-08-11). min-width:0 let the .brand BOX
   shrink while .mark and .bword inside it stayed flex:0 0 auto — so the lockup did not shrink, it
   OVERFLOWED its own box and painted on top of the org name. Measured at HEAD, 1-site org: at 1024
   .brand laid out 38px wide around 145px of content; at 768, 0px wide. The lockup was being drawn
   over .kitchen at every width below ~1120, which is a live rendering defect nobody had named. It is
   pinned in the rule above; this rule only lays it out. */
.brand{display:flex;align-items:center;gap:9px}
.brand .mark{width:28px;height:28px;flex:0 0 auto;
  background:url("../brand/svg/mark-green.4b25220a83de.svg") no-repeat center/contain}
.brand .bword{width:108px;height:24px;flex:0 0 auto;
  background:url("../brand/svg/wordmark-on-light.eb61d5e8dd58.svg") no-repeat left center/contain}
:root[data-theme="dark"] .brand .bword{background-image:url("../brand/svg/wordmark-on-dark.ab7f55f44cfe.svg")}
.kitchen{font-size:var(--fs-footnote);color:var(--text2)} .kitchen b{color:var(--text);font-weight:600}
/* WHAT MAY TRUNCATE, and the reasoning (2026-08-11 — see the long note on .top).
   These three are the only items in the bar whose width is set by DATA AN ORG OWNS, so they are the
   only ones that can be individually wider than the line — which flex-wrap cannot rescue, because
   wrapping breaks BETWEEN items and never inside one. Each gets min-width:0 (a flex item's default
   min-width:auto is precisely what pins the row open), a cap well under the 375px phone line, and a
   visible ellipsis so nothing is silently cut.
   A NAME truncating is acceptable and the user reverses it by widening the window; losing the
   control is not. The full string stays available three ways: the text node itself is intact for a
   screen reader (text-overflow clips pixels, not content), a title= carries it for a pointer user,
   and for the <select> every <option> is read out whole when it opens.
   .kitchen is blockified by being a flex item, which is what makes text-overflow apply to it at all
   despite it being a <span> in the markup. */
.top .kitchen{min-width:0;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* the signed-in member's name or, with no name set, their email — which is the long one. display:block
   overrides .pill's inline-flex because text-overflow needs a block box, not a flex container whose
   text is an anonymous flex item.
   MIN-WIDTH:761px IS LOAD-BEARING, NOT TIDINESS. @media(max-width:760px) hides `.top .pill` — that is
   0-2-0 specificity, and `.top .pill.who` is 0-3-0, so an unconditional rule here OUTRANKS the phone
   hide no matter that the media query comes later in the file, and puts the member's name back on a
   375px bar. Measured when this was unscoped: the phone bar went from 57px tall to 101px. The cap is
   only wanted where the pill is actually shown, so it is scoped to exactly that band. */
@media(min-width:761px){
  .top .pill.who{display:block;min-width:0;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
}
/* ONE long site name must not set the whole bar's width: a <select> is sized to its LONGEST OPTION,
   and "Limassol Seafront SeaNatra" alone measured 214px. Capped, and floored at 96px so it stays a
   usable target rather than collapsing to the arrow. Same rule covers the dashboard's org switcher,
   which is the same .sitesw markup. Chrome ellipsises the closed <select>'s own label natively. */
.top .sitesw{flex:0 1 auto;min-width:0}
.top .sitesw .sw-select{max-width:180px;min-width:96px}
/* the one interactive control in the bar that is not an .iconbtn, so it needs the 44px floor too
   (same treatment as .unitrow select). Width is untouched — this must not widen the bar. */
@media (pointer:coarse){.top .sitesw .sw-select{min-height:44px}}
/* The account cluster wraps as ONE unit. Eight loose siblings each wrapped on their own, which at 1024
   left the search icon alone at the end of line 1 and dropped the other four buttons to the far LEFT of
   line 2 — the cluster torn in half at the width most laptops run at. margin-left:auto rather than
   relying on .spacer: flex-grow is resolved before auto margins, so on a line that still holds the
   spacer the spacer does the pushing, and on a line holding only this group the auto margin does — which
   is what keeps it right-aligned once it has wrapped onto a line of its own. Its own flex-wrap is the
   backstop for a group that is wider than the whole line. */
.top .topright{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-left:auto;min-width:0}
.top .spacer{flex:1 1 0;min-width:0}
.pill{display:inline-flex;align-items:center;gap:7px;font-size:var(--fs-footnote);font-weight:500;border-radius:999px;padding:6px 13px;background:var(--fill);color:var(--text2)}
.pill .dot{width:7px;height:7px;border-radius:50%;background:var(--green)}
.pill.role{background:var(--accent-tint);color:var(--accent-text);font-weight:600;text-transform:capitalize}
.trialpill{font-size:var(--fs-caption);font-weight:600;border-radius:999px;padding:6px 12px;white-space:nowrap;
  background:var(--warn-bg);color:var(--warn-fg);transition:filter .2s}
.trialpill:hover{filter:brightness(1.05)} .trialpill.ending{background:var(--al-bg);color:var(--al-fg)}
/* "prices fresh" status pill — green when every cost is current, amber + clickable when some are stale */
.pill.fresh{font-weight:600;white-space:nowrap}
.pill.fresh.ok{background:var(--ok-bg);color:var(--ok-fg)}
.pill.fresh.warn{background:var(--warn-bg);color:var(--warn-fg);transition:filter .2s}
.pill.fresh.warn:hover{filter:brightness(1.05)}
.iconbtn{width:36px;height:36px;border:0;border-radius:50%;background:var(--fill);display:grid;place-items:center;font-size:var(--fs-callout);transition:transform .12s,background .2s}
/* --fill-strong, not --fill-2: .iconbtn rests on --fill, so --fill-2 made it FAINTER when pointed
   at (.12 -> .08 light, .24 -> .16 dark). Same inversion the ghost button had; fixed here too rather
   than left on a to-do list (2026-08-17). Components that hover FROM transparent still want --fill-2. */
.iconbtn:hover{background:var(--fill-strong)} .iconbtn:active{transform:scale(.92)}
/* Touch devices (phones at the pass): meet the 44x44 minimum tap target; desktop stays compact. */
@media (pointer:coarse){.iconbtn{width:44px;height:44px;font-size:18px}}

nav.side{grid-area:nav;background:var(--nav-bg);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
  /* padding-top is 0 and .navtop carries the 14px instead (2026-08-16). A sticky child cannot be
     positioned above its containing block, which is this element's CONTENT box, so with a
     padding-top here the pinned band sat 14px low and links scrolled visibly through the strip
     above it. Measured: the band pinned at 113 with the nav's own top edge at 99. */
  border-right:.5px solid var(--sep);padding:0 12px 14px;display:flex;flex-direction:column;gap:2px;
  /* Pin the sidebar and let it scroll internally so the 21 items + footer stay reachable on short
     laptop screens (<~1030px tall) instead of overflowing below the fold. (2026-06 nav-overflow fix)

     top is the MEASURED bar height, NOT 0 (chef 2026-08-10: "Menu collapse / Expand shall survive the
     scroll"). .top is sticky top:0 z-index:20 and this nav was sticky top:0 too, so once the page
     scrolled the nav pinned into the bar's own band and the collapse/expand button — the first thing
     in the nav — sat underneath it: measured at 1440 after 197px of scroll, bar 0->59 and toggle
     14->56, entirely behind it. max-height had the matching bug: 100vh made the nav a full viewport
     tall when only (100vh - bar) is actually visible, so its last items fell off the bottom.
     Measured, never hardcoded — the bar is 57px on a phone, 59px at 1280 and 73px around 1024 where
     its pills wrap; --appbar-h is the scripting-off fallback. */
  position:sticky;top:var(--bar-h,var(--appbar-h));align-self:start;
  max-height:calc(100vh - var(--bar-h,var(--appbar-h)));overflow-y:auto}
nav.side .navsec{font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin:16px 12px 6px}
nav.side a{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:10px;font-size:var(--fs-subhead);font-weight:500;color:var(--text);transition:background .15s}
nav.side a .ic{font-size:var(--fs-body);width:22px;text-align:center}
nav.side a:hover{background:var(--fill-2)}
nav.side a.active{background:var(--accent-tint);color:var(--accent-text);font-weight:600;position:relative}
/* A SHAPE, NOT ONLY A COLOUR (2026-08-21 design audit). The bottom bar has carried a 3px marker
   since 2026-08-14; the sidebar and the phone 'more' sheet signalled the current page with hue
   alone, which fails WCAG 1.4.1 and disappears entirely for the ~8% of men with a red-green
   deficiency — a real share of any brigade. Same 3px, same token, on the leading edge because
   these rows are read down a column rather than across a bar. */
nav.side a.active::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:18px;border-radius:0 2px 2px 0;background:var(--accent-text)}
/* Touch devices: the nav rows meet the same 44px floor as every other control in this file.

   MEASURED, NOT ESTIMATED (2026-08-14, verify pass) — Chrome, rendered owner sidebar, 33 rows: a nav
   row is 42.65px (9px padding round a 21.75px subhead line), the Collapse toggle is 42px, and the
   three .foot links are 40.5px. nav.side is still the navigation on a 768-1024px tablet (it only
   hands over to .botnav below 760px), so a chef tapping the menu on an iPad was aiming at every one
   of those. This rule lifts all three to 44.

   THE .foot LINKS (Support / Privacy / Terms) ARE IN ON PURPOSE. They are not nav destinations — the
   parity tests cut them off for exactly that reason — but they are still links a thumb has to hit,
   and at 40.5px they were the shortest thing the selector reaches. `nav.side a` covering them is the
   decision, not an accident.

   .botmore-panel a WAS LISTED HERE AND IS NOT ANY MORE: it was dead twice over. Those rows measure
   50.09px (12px padding round an 18px glyph) — adding a floor at 44 changed nothing, verified by
   applying it by hand in the browser — and .botmore-panel a already carries an UNCONDITIONAL
   min-height:44px below. Repeating it here implied the floor was pointer-conditional on that surface
   when it is not. The comment this replaces claimed ~38px and ~42px for these two; both were wrong,
   and a false measurement in a comment is how the next person talks themselves into deleting a rule
   that was load-bearing.

   Keys off the POINTER, not the width, so desktop keeps its compact rhythm. */
@media (pointer:coarse){nav.side a,.navtoggle{min-height:44px}}
nav.side .foot{margin-top:auto;font-size:var(--fs-caption);color:var(--text3);padding:12px;line-height:1.5}

/* ---- NAV RAIL — collapse the sidebar to icons (chef 2026-08-01: "we need an option to hide menu
   leaving only the icons"). The class is set on <html> in <head>, BEFORE paint: applied from app.js at
   end-of-body the sidebar would render 248px wide and snap to 64px a frame later, on every page load.

   THE LABELS ARE HIDDEN, NOT REMOVED. `display:none` on .lbl would take the text out of the
   accessibility tree too, so a screen-reader user collapsing the menu would be left with 30 links
   announced as their emoji. Clipping keeps the text readable to AT while hiding it visually, and the
   title attribute gives a sighted user the tooltip. ---- */
.navtoggle{display:flex;align-items:center;gap:12px;width:100%;border:0;background:none;cursor:pointer;
  padding:9px 12px;border-radius:10px;color:var(--text3);font-size:var(--fs-subhead);font-weight:500;
  margin-bottom:4px}
.navtoggle:hover{background:var(--fill-2);color:var(--text)}
.navtoggle .ic{font-size:var(--fs-body);width:22px;text-align:center}

/* THE TOGGLE DOES NOT SCROLL AWAY WITH THE MENU (chef 2026-08-16: "side menu on the left the collapse
   shall survive scroll" — his second report of this).

   THE 2026-08-10 FIX WAS RIGHT AND INCOMPLETE. It cured the PAGE scroll, and that half still measures
   clean: nav.side pins at the measured --bar-h, so at 1416/1256/1076/1000/876/796, in both rail
   states, at every scroll offset, the bar occupies 0->98.7 and the toggle 113->155 with zero overlap
   and the toggle hit-tests as itself.

   BUT nav.side IS A SCROLLER OF ITS OWN, and the toggle is just its first scrolling item. The menu is
   1908px of content in a 575px box on a 1366x768 laptop, and 1960px in a 980px box on an iPad where
   every row sits on the 44px touch floor. Measured in headed Chrome, four wheel notches with the
   cursor over the menu: the page does not move at all (scrollY stays 0), nav.scrollTop goes to 480 and
   the toggle lands at -367->-325, clipped out of the box. Six notches of scrolling the page back up
   do not bring it back, because the page was never what moved. That is what he is losing.

   STICKY, NOT A SECOND MEASURED OFFSET. There is nothing to measure here: the control must simply not
   be able to leave the box it lives in. That holds at any width, in either rail state, and does
   nothing at all on a screen tall enough that the menu never scrolls.

   WHY A WRAPPER AND NOT position:sticky ON THE BUTTON. Two reasons, both visible. The band has to be
   OPAQUE, or the links ghost through it — --nav-bg is rgba(...,.8) — and .navtoggle:hover already owns
   `background`, so an opaque base painted on the button would be wiped the moment the cursor lands on
   it. And the wrapper carries nav.side's own padding (14px/12px, 8px sides in the rail), so the button
   pins at exactly the offset it rests at and there is no jump when it starts sticking.
   background-color:var(--bg) under a solid layer of var(--nav-bg) reproduces what the nav already
   looks like over the page. Both tokens are declared, so neither declaration is silently dropped.

   AND THE 14px TOP PADDING MOVED OFF nav.side ONTO THIS BAND. A sticky box cannot be placed above its
   containing block, and that containing block is nav.side's CONTENT box — so while nav.side kept
   padding-top:14px the band pinned at 113 with the nav's top edge at 99, and the menu scrolled
   visibly through the 14px strip above it. A negative margin does not help: sticky clamps it straight
   back down. Owning the padding is what makes the band flush with the top of the scrollport. */
.navtop{position:sticky;top:0;z-index:2;margin:0 -12px;padding:14px 12px 4px;
  background-color:var(--bg);background-image:linear-gradient(var(--nav-bg),var(--nav-bg))}
.navtop .navtoggle{margin-bottom:0}
.nav-rail .navtop{margin-left:-8px;margin-right:-8px;padding-left:8px;padding-right:8px}

.nav-rail .app{grid-template-columns:64px 1fr}
.nav-rail nav.side{padding:0 8px 14px}
.nav-rail nav.side a,.nav-rail .navtoggle{justify-content:center;gap:0;padding:10px 0}
/* Clipped, not display:none — see the note above. */
.nav-rail nav.side .lbl{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);
  white-space:nowrap}
.nav-rail .navtoggle .ic{transform:rotate(180deg)}
/* A section HEADING with no words is just a gap, and a gap is the right separator here — keeping the
   text would wrap "Compliance" into three lines inside a 64px rail. */
.nav-rail nav.side .navsec{height:1px;margin:10px 8px;padding:0;overflow:hidden;background:var(--sep);
  color:transparent}
.nav-rail nav.side .foot{display:none}
/* The rail is a DESKTOP affordance. Below 760px nav.side is hidden entirely and .botnav takes over, so
   the class must not leak into a layout it was never measured against. */
@media(max-width:760px){.nav-rail .app{grid-template-columns:1fr}}

/* ---- mobile bottom nav + "More" sheet (phones only; revealed in the <=760px media query below).
   Hidden on desktop, where nav.side is the navigation. Frosted like the sidebar/top bar. ---- */
.botnav{display:none}
.botnav{position:fixed;left:0;right:0;bottom:0;z-index:30;
  background:var(--nav-bg);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);
  border-top:.5px solid var(--sep);padding:4px 4px calc(4px + env(safe-area-inset-bottom,0px))}
/* `flex:1` shares the row by CHILD COUNT, which is why core/nav.py's PHONE_TABS must stay the same
   length for every role: while it varied, the same tab sat 76px further along the bar for a cook than
   for an owner. position:relative anchors the active marker below. */
.botnav a,.botnav .botmore-btn{flex:1;min-width:0;min-height:44px;border:0;background:transparent;
  position:relative;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
  padding:6px 2px;border-radius:12px;color:var(--text2);font-weight:600;transition:background .15s,color .15s}
.botnav a .ic,.botnav .botmore-btn .ic{font-size:21px;line-height:1}
.botnav a .bl,.botnav .botmore-btn .bl{font-size:var(--fs-caption2);letter-spacing:.01em}
.botnav a:hover,.botnav .botmore-btn:hover{background:var(--fill-2)}
/* "YOU ARE HERE", AS A SHAPE AND NOT ONLY AS A HUE (2026-08-14). The active tab was distinguished by
   colour alone, which is the weakest possible signal on a phone with steam on it, propped on a bench
   at arm's length — and it is invisible to anyone who cannot separate the two greens at all. A 3px
   bar is a difference in FORM: present or absent, legible at a glance and at a distance.

   ONE COLOUR FOR THE WHOLE MARKED SLOT, and it is --accent-text (2026-08-14, review). The first cut
   painted the 3px bar in --accent-text for WCAG 1.4.11's 3:1 floor and left the LABEL in --accent,
   which produced two greens in one slot AND — measured in Chrome at 375px — put the active word at
   2.02:1 on the composited bar while its four inactive neighbours sat at 9.97:1. The word for the tab
   you are standing on was the least readable word on the bar, which is backwards everywhere and worst
   in the steam this whole phase exists for. Re-measured in headed Chrome at 375px after the change,
   sampling the composited bar rather than the declared --nav-bg: light #0b6b2c on (245,245,247) =
   6.12:1, dark #30D158 on (18,18,21) = 9.25:1, against 10.05:1 / 15.79:1 for the four inactive
   labels. Both clear the 4.5:1 AA floor for text at 11px/600, and the same pair clears 1.4.11's 3:1
   for the marker, which is now the same colour. --accent-text IS the bright accent in dark mode by
   construction, so DARK DID NOT MOVE — this fixes light mode, where the bug was.

   `.botnav .active`, not `.botnav a.active`: slot 5 is the More BUTTON, and it marks itself whenever
   the current destination is a sheet row (core/nav.py's more_active). Dashboard left the bar, so
   without that `/` — the post-login landing — had nothing marked at all. */
.botnav .active{color:var(--accent-text)}
.botnav .active::before{content:"";position:absolute;top:0;left:50%;margin-left:-14px;
  width:28px;height:3px;border-radius:0 0 2px 2px;background:var(--accent-text)}
/* "More" bottom sheet — slides up over a dimmed backdrop; hidden via [hidden] by default */
.botmore{position:fixed;inset:0;z-index:40;background:rgba(0,0,0,.4);
  display:flex;align-items:flex-end;backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
.botmore[hidden]{display:none}
.botmore-panel{width:100%;max-height:80vh;overflow:auto;
  background:var(--bg-elev);border-top-left-radius:var(--r-card);border-top-right-radius:var(--r-card);
  box-shadow:var(--shadow-lift);padding:8px 12px calc(16px + env(safe-area-inset-bottom,0px))}
.botmore-grip{width:38px;height:5px;border-radius:999px;background:var(--text3);margin:8px auto 6px}
.botmore-h{font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin:4px 8px 8px}
.botmore-panel a{display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;
  font-size:var(--fs-subhead);font-weight:500;color:var(--text);min-height:44px;transition:background .15s}
.botmore-panel a .ic{font-size:18px;width:24px;text-align:center}
.botmore-panel a:hover{background:var(--fill-2)}
.botmore-panel a.active{background:var(--accent-tint);color:var(--accent-text);font-weight:600;position:relative}
.botmore-panel a.active::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:18px;border-radius:0 2px 2px 0;background:var(--accent-text)}

main.content{grid-area:main;overflow:auto;padding:26px 30px 70px}

/* SCROLLBARS — always there, never an overlay that fades away (chef 2026-07-27: "the scroll when you
   point it with a mouse it disappears"). This app scrolls INNER containers — main.content above, the
   sidebar, the Cmd-K list, the assistant log, dialogs — rather than the window, and it never styled the
   scrollbar, so each one inherited whatever the OS/browser preferred. On Windows that is an overlay bar
   that fades out after scrolling and can vanish exactly as you reach for it, which is unusable as a drag
   target. Styling it explicitly opts out of the overlay behaviour entirely, so it looks and behaves the
   same on every machine.

   The thumb uses the existing text tokens, so it themes itself in light AND OLED-dark with no new
   variables; the transparent border + background-clip trick gives the thumb padding without a second
   element. scrollbar-gutter keeps the space reserved so content never reflows when a bar appears. */
main.content{scrollbar-gutter:stable}
*{scrollbar-width:thin;scrollbar-color:var(--text3) transparent}   /* Firefox */
::-webkit-scrollbar{width:12px;height:12px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-corner{background:transparent}
::-webkit-scrollbar-thumb{background:var(--text3);border-radius:8px;
  border:3px solid transparent;background-clip:content-box}
::-webkit-scrollbar-thumb:hover{background:var(--text2)}
::-webkit-scrollbar-thumb:active{background:var(--text2)}
.ph{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:2px 0 4px}
h1{font-size:30px;font-weight:700;letter-spacing:-.03em;margin:0}
h2{letter-spacing:-.02em;margin:0} h3{letter-spacing:-.01em}
.sub{color:var(--text2);margin:0 0 22px;font-size:14px;max-width:760px}
.sech{font-size:var(--fs-footnote);font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);margin:24px 4px 10px}

/* ---- A WORKFLOW'S OWN NAV ROW — "← Recipes … View import history →" (2026-08-25) ----
   Lives here rather than in a page <style> because _import_nav.html is included by BOTH the import
   entry screen and the import history screen, and app.css's promotion rule is that a widget moves in
   the moment it gets a second user: a copied widget always drifts.

   NOT .btn. History was a `.btn ghost sm` pill parked in the page header next to the title, and the
   chef's report was that nobody ever saw it — two ghost buttons up there read as a toolbar of page
   actions and compete with the primary control, so the eye skips them. These are navigation: text,
   arrows, edge-aligned. */
.rimp-nav{display:flex;justify-content:space-between;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-4)}
.rimp-navlink{color:var(--text2);text-decoration:none;font-size:var(--fs-footnote);font-weight:600;padding:6px 2px;border-radius:var(--r-sm)}
.rimp-navlink:hover{color:var(--accent-text);text-decoration:underline}
.rimp-navlink:focus-visible{outline:2px solid var(--accent);outline-offset:2px}

/* ---- cards / kpis ---- */
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:15px;margin-bottom:18px}
.kpi{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);padding:var(--sp-4) var(--sp-5);box-shadow:var(--shadow)}
.kpi .lab{font-size:var(--fs-caption);font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.4px}
.kpi .val{font-size:var(--fs-large);font-weight:700;letter-spacing:-.03em;line-height:1;margin-top:var(--sp-2)}
.kpi .note{font-size:var(--fs-footnote);color:var(--text2);margin-top:8px}
/* THE SEMANTIC KPI COLOURS, MEASURED (a11y review 2026-08-17). A .val is 34px/700, so the WCAG floor
   that applies is 3:1 (large text), not 4.5:1 — and two of the three did not clear even that, on a
   number that is the entire content of the tile:
     good  --green  #34C759 on a white card = 2.22:1  ->  --ok-fg  #1d8a3f = 4.41:1
     warn  --orange #FF9500 on a white card = 2.20:1  ->  --warn-fg #9a5a00 = 5.47:1
     bad   --red    #FF3B30 on a white card = 3.55:1  ->  LEFT ALONE: it clears the floor it has to.
   Dark mode moves barely at all and passes either way (good 8.42:1 both, warn --orange 8.28 ->
   --warn-fg 9.54, bad 4.99). --ok-fg / --warn-fg are the tokens the design system already declares for
   ok/amber TEXT, which is why they are used rather than a new pair — and the same reasoning the --focus
   and --btn-fill comments at the top of this file worked out for the ring and the buttons. The brand
   green stays the FILL colour everywhere it is a fill; this is about ink. */
.kpi.good .val{color:var(--ok-fg)} .kpi.bad .val{color:var(--red)} .kpi.warn .val{color:var(--warn-fg)}
.theo{display:inline-block;font-size:10px;font-weight:700;background:var(--ok-bg);color:var(--ok-fg);border-radius:6px;padding:2px 7px;vertical-align:middle;margin-left:7px;letter-spacing:.3px}

/* ---- hint banner ---- */
.hint{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);padding:13px 16px;
  font-size:var(--fs-footnote);color:var(--text2);margin-bottom:20px;display:flex;gap:10px;align-items:center;box-shadow:var(--shadow)}
.hint b{color:var(--text)} .hint a{font-weight:600}

/* ---- recipe cards ---- */
.rgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
.rcard{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow);
  overflow:hidden;display:block;color:inherit;transition:transform .18s cubic-bezier(.2,.7,.3,1),box-shadow .18s}
a.rcard:hover{transform:translateY(-4px);box-shadow:var(--shadow-lift)}   /* lift only clickable cards */
.rcard .top2{height:92px;background:linear-gradient(135deg,var(--accent-tint),var(--fill-2));display:grid;place-items:center;font-size:42px}
.rcard .body{padding:14px 16px}
.rcard .nm{font-weight:600;font-size:var(--fs-callout);letter-spacing:-.01em}
.rcard .meta{display:flex;justify-content:space-between;align-items:baseline;margin-top:10px}
.rcard .cost{font-size:21px;font-weight:700;letter-spacing:-.02em}
.rcard .tgt{font-size:var(--fs-caption);color:var(--text2)}
/* pro monogram placeholder (chef feedback: the emoji looked cartoonish) — the dish's initial on the tint */
.rcard .top2.rmono{font-size:34px;font-weight:800;color:var(--accent-text);letter-spacing:-.02em}
/* A dish photo in the card's header tile (chef 2026-08-02). object-fit:cover fills the fixed 92px band
   at any aspect ratio without letterboxing — a grid of cards whose headers were different heights, or
   padded with bars, reads as broken rather than as photography. padding:0 overrides the tile's centring
   so the image reaches the edges. */
/* PHOTO CARD: a full square, the name under it, everything else on hover (chef 2026-08-03: "The card
   view shall show the images full sqare and the name below and on hoover all the information").

   AT REST the card is what a chef actually browses by — a picture and a name. The costing, the station,
   the yield and the timestamp are all still there, they are simply not what you scan a grid with.

   ON HOVER the rest fades in as an OVERLAY on the picture, not as extra card height. That is the whole
   trick: revealing it in flow would grow the card, push every card to its right down a row, and the
   grid would churn under the cursor. Absolute + opacity leaves the layout untouched, so only the thing
   under the pointer changes.

   THE META IS ONE WRAPPER (.rcard-more), not a set of :not(.nm) siblings, because .rmeta3 carries an
   inline display:flex — and an inline style beats a stylesheet rule, so hiding the children
   individually would have hidden every line except that one.

   Square via aspect-ratio, contain inside it: the tile is a full square whatever the photo's shape, and
   the photo is never cropped to fill it (chef, earlier the same day: "the imgage is not fit it's
   zoomed").

   Hover-only is fine here: a touch device has no hover, and there the card's job is to be tapped — the
   full information is one tap away on the recipe page. */
.rcard.has-photo{position:relative}
.rcard.has-photo .top2.rphoto{height:auto;aspect-ratio:1}
/* Anchored to the BODY with bottom:100%, so it grows UPWARD over the picture and stops exactly where
   the name begins. Anchored to the card instead it would sit on top of the name — covering the one
   thing the chef asked to keep visible at rest. */
.rcard.has-photo .body{position:relative}
.rcard.has-photo .rcard-more{position:absolute;left:0;right:0;bottom:100%;z-index:2;
  padding:12px 16px 14px;background:linear-gradient(to top,rgba(0,0,0,.88),rgba(0,0,0,.72) 62%,rgba(0,0,0,0));
  color:#fff;opacity:0;transition:opacity .18s;pointer-events:none}
.rcard.has-photo:hover .rcard-more,.rcard.has-photo:focus-visible .rcard-more{opacity:1}
/* The overlay sits on a dark scrim, so the muted greys the card uses on its own background would be
   unreadable. Everything inside it goes light. */
.rcard.has-photo .rcard-more,.rcard.has-photo .rcard-more *{color:#fff!important}
.rcard.has-photo .rcard-more .chip,.rcard.has-photo .rcard-more .fcpill{background:rgba(255,255,255,.18)!important}
.rcard .top2.rphoto{padding:0;overflow:hidden;background:var(--fill)}
/* contain, matching the tile on the recipe page (chef 2026-08-03: "the imgage is not fit it's zoomed").
   cover cropped a wide plate shot to its centre band and magnified it, which on a 92px header is a
   green smear rather than a dish. The card headers stay a uniform height either way — that is what
   keeps the grid a grid — but now the whole photo is inside it. */
.rcard .top2.rphoto img{width:100%;height:100%;object-fit:contain;display:block}
.rview-toggle{margin-left:auto}   /* push the Table|Cards view switch to the right of the control bar */
/* ---- horizontal bar chart (analytics) ---- */
.barrow{display:grid;grid-template-columns:150px 1fr 150px;gap:14px;align-items:center;margin:11px 0;font-size:var(--fs-footnote)}
.barrow .nm{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.barrow .bt{background:var(--fill);border-radius:var(--r-sm);height:13px;overflow:hidden}
.barrow .bf{display:block;height:100%;background:linear-gradient(90deg,var(--accent),#7AD98F);border-radius:var(--r-sm);min-width:2px}
.barrow .bv{font-variant-numeric:tabular-nums;color:var(--text2);text-align:right}
.barrow .bv b{color:var(--text);font-weight:600}

/* ---- top/bottom movers list (menu-engineering quadrants + price-intel biggest moves).
   Was page-scoped in price_intel.html's <style>; promoted here so menu_engineering (which reuses
   .movers-grid/.moverow) isn't left unstyled. ---- */
.movers-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}
@media (max-width:760px){.movers-grid{grid-template-columns:1fr}}
/* Flex + wrap so name / price / badge reflow instead of overflowing at ~360px. */
.moverow{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;
  padding:10px 2px;border-bottom:.5px solid var(--sep)}
.moverow:last-child{border-bottom:0}
.moverow .nm{font-weight:600;color:var(--text);min-width:0;flex:1 1 40%;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.moverow .spk{display:inline-flex;align-items:center;flex:0 0 auto}
.moverow .spk.up{color:var(--red)} .moverow .spk.down{color:var(--green)}
.moverow .spk svg{display:block}
/* Let the price group wrap its own contents on the narrowest screens. */
.moverow .mv{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 7px;
  font-variant-numeric:tabular-nums;flex:1 1 auto;justify-content:flex-end}
.moverow .act{flex:1 1 100%;font-size:var(--fs-caption);font-weight:600;color:var(--accent-text);
  text-decoration:none}
.moverow .act:hover{text-decoration:underline}

.panel{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow);padding:var(--sp-5);margin-bottom:var(--sp-5)}
.panel>h2,.panel>.sech:first-child{margin-top:0}

/* ---- tables ---- */
/* overflow-x:auto AT EVERY WIDTH, not only on a phone (2026-08-11). The phone block near the
   .respcards rules has carried `.tablewrap{overflow-x:auto}` with the comment "wide tables scroll
   inside their card, not off-page" since it was written — but it sits inside @media(max-width:760px),
   which is the SAME breakpoint where .respcards already turns every row into a stacked card. So the
   rule only ever applied where it was least needed, and the band from 761px to about 1200px had
   neither: no cards, and `overflow:hidden` clipping the columns that did not fit.
   Measured on the recipe ingredients table at 1024px with the Yield loss column added: a 1019px table
   inside a 694px wrapper, columns unreachable, and the PAGE scrolling sideways instead. Horizontal
   page scroll is the thing this rule exists to prevent.
   overflow-y stays hidden, which is what keeps the card's rounded corners clipping the header row.

   position:relative IS LOAD-BEARING, NOT DECORATION (2026-08-11). overflow only clips a descendant
   whose CONTAINING BLOCK is inside the scroller. A position:absolute descendant of a *static*
   .tablewrap resolves its containing block to the initial containing block instead, so it is neither
   clipped nor scrolled by the wrapper — it lands in the DOCUMENT's scrollable overflow.
   That is the whole of the "recipe page scrolls sideways by 268px" bug: .sr-only is
   position:absolute (see its rule below), the ingredients table carries two of them in its LAST
   column — the `Row actions` <th> label and each row's `Remove <name>` on the 🗑 button — and at a
   1024px viewport their 1px boxes take their static position at x≈1281 inside a 1019px table that
   is itself inside a 705px wrapper. Measured: documentElement.scrollWidth 1282 vs
   document.body.scrollWidth 1024, and window.scrollTo(600,0) left scrollX at 258.
   It reads as unattributable because (a) the boxes are 1px and invisible, and (b) body.scrollWidth
   stays clean — body is not their containing block either, so only <html> grows.
   Making the scroller a containing block puts them back inside the thing that already scrolls. A
   scroll container that establishes no containing block is a leaky scroll container; do not drop
   this to "clean up" the rule. Guarded by core/tests/test_recipe_h_overflow.py.
   Safe for the popovers that deliberately escape a tablewrap (.cbx-panel, .ing-name-matches, the
   comboboxes): those re-anchor with position:FIXED, which position:relative does not capture, and
   z-index:auto means this creates no stacking context. */
.tablewrap{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);
  position:relative;overflow:hidden;overflow-x:auto;box-shadow:var(--shadow)}
table{border-collapse:collapse;width:100%;font-size:14px}
th,td{text-align:left;padding:13px 16px;vertical-align:middle}
thead th{font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);border-bottom:.5px solid var(--sep)}
tbody tr+tr td{border-top:.5px solid var(--hair)} tbody tr{transition:background .12s} tbody tr:hover{background:var(--fill-2)}
tfoot td{border-top:.5px solid var(--sep)}
.num{text-align:right;font-variant-numeric:tabular-nums}
/* sticky first column for wide horizontally-scrolling tables (e.g. the allergen matrix) */
.stickycol th:first-child,.stickycol td:first-child{position:sticky;left:0;background:var(--bg-elev)}
.stickycol thead th:first-child{z-index:2} .stickycol tbody td:first-child{z-index:1}

/* ---- tap-to-edit price (ingredients) ---- */
/* A PRICE MUST NEVER WRAP (2026-08-17). This is the rule, not a tweak: on a costing product, on the
   screen where prices ARE the point, "EUR" on one line and "18.40" on the next is a number the chef
   has to reassemble, and the ✎ ends up beside neither half of it. A name that wraps is still the
   whole name; money that wraps is a figure in two pieces.

   WHAT IT FIXES. The Market list gained an sku column and a Labels column on 2026-08-16. Both stay —
   they are the features — but they cost width, and the table is table-layout:auto, so PURCHASE PRICE
   was squeezed until it broke. Measured in headed Chrome against the marketing demo tenant, light
   theme, scrollbars hidden, 45 priced rows: the widest price needs 96.1px of content box and the
   cell was handed 67.8px at 1280, 78.8px at 1366, 88.4px at 1440. Every one of the 45 prices bent
   from 1280 to 1440 inclusive; the table only laid out clean from 1515 up (97.9px). That is a live
   regression for anyone on a 1366 or 1440 laptop. With this rule the column asks the auto algorithm
   for the 128.1px it actually needs and no price wraps at any width from 800 to 2560.

   WHAT IT COSTS, MEASURED, because something had to give. The table's minimum width goes 943.3 ->
   972.3px, i.e. 29px, all of it taken from PRODUCT / CATEGORY / SUPPLIER, which wrap onto a second
   line a little sooner. Rows get SHORTER overall, not taller (tbody 3386 -> 3051px at 1440), because
   45 two-line prices cost more height than a few wrapped names. From 1300px up the table still fits
   its card exactly as before. Below 1300 .tablewrap already scrolls by design (462px of it at 800),
   and this widens that existing scroll by the same 29px; at 1280 the card picks up 11px of scroll
   where it had none. The page itself never scrolls sideways at any width. Nothing is hidden.

   WHAT WAS REJECTED, also measured, not assumed:
   · min-width:128px on the cell — the width the fix below settles on, handed over as a number.
     Measured: the column does become 128px, and 10 rows STILL break at 1280, 1366 and 1440. A width
     given to the CELL is not a width given to the money: .price adds its own 8px of padding either
     side inside it, so 96px of cell content is 80px of text against the ~88px "EUR 18.40" needs.
     Getting that right means hand-adding up the padding of an inner box, per currency and per font,
     and re-doing it whenever either moves. nowrap asks for the real figure instead of guessing it.
   · table-layout:fixed + a colgroup. This table's column set is not fixed — bulk, Labels and Actions
     each appear on a permission — so it would need four colgroups for the four role combinations,
     and .respcards discards table-layout on phones anyway. .stock-tbl and .plan-tbl can do it
     because their columns never vary.
   · ellipsis truncation on Product and Supplier. In an auto-layout table an ellipsis needs a px
     max-width on an inner box (the .usedin pattern), and a max-width small enough to bite at 1280
     (~90px) still clipped 14 product names at 1920, where there are 185px of room and nothing to
     fix — while the price STILL wrapped at 1280 and 1366, because freeing name width does not make
     the auto algorithm hand it to the price column. It went to Category and Actions instead.
   Hiding the sku or Labels columns was never on the table: they were added deliberately, and buying
   layout with data is how the last six bugs on this page started.

   SCOPE. On .price, which is not a layout class — it means "a money value you can click to edit".
   Its only two users are the Market list and the supplier-prices table on the product page, both
   prices, both measured. landing.html's pricing cards use the same class name but that page carries
   its own <style> and never loads app.css, so they are untouched. .respcards is untouched too: at
   390px the cell is 327px wide and the price fits on one line with or without this. */
.price{display:inline-flex;gap:6px;align-items:center;justify-content:flex-end;border-radius:8px;padding:4px 8px;cursor:text;transition:background .15s;white-space:nowrap}
.price:hover{background:var(--accent-tint)}
.price .pen{opacity:.4;font-size:var(--fs-caption)}
.priceedit{display:inline-flex;gap:6px;align-items:center;justify-content:flex-end}

/* ---- chips / badges ---- */
.chip{display:inline-block;font-size:var(--fs-caption);font-weight:500;border-radius:var(--r-sm);padding:4px 10px;margin:1px 2px;background:var(--fill);color:var(--text2)}
/* "Used in" cell on the preparations list — EVERY dish that uses a prep (chef 2026-08-02: "show all
   the dishes in the badges, not just 3"). It used to render three chips and a "+7", because
   .tablewrap is overflow:hidden on desktop and an uncapped row of inline chips widened the column
   until the later ones fell off the card.
   A BLOCK-LEVEL box inside the cell, not a max-width on the <td>: with table-layout:auto a browser
   treats a cell's max-width as a suggestion and will still stretch the column to fit its content,
   while a div inside the cell honours it — so the badges wrap and the row grows downwards instead of
   the table growing sideways. */
.usedin{display:flex;flex-wrap:wrap;gap:2px;max-width:360px}

/* ---- Dish photo (chef 2026-08-02) -----------------------------------------------------------------
   The stage is position:relative so the crop rectangle can sit over the canvas in the SAME coordinate
   space the drag is measured in — app.js converts that rectangle back to source pixels, and any offset
   between the two boxes would show up as a crop that lands slightly off what was drawn.
   touch-action:none stops a phone scrolling the page while a chef drags a crop across the picture. */
/* SQUARE TILE + DROP ZONE (chef 2026-08-03: "it has to be shown in square... we need to support drag
   and drop zone"). aspect-ratio:1 + object-fit:cover crops to the square for DISPLAY only — the stored
   master keeps its true frame, which is what the spec sheet and the menu print. A grid of dish photos
   only reads as a grid when the tiles agree on their shape.
   The tile is a <label for>, so a click opens the picker with no JS; app.js adds drop on the same box. */
/* THE IDENTITY ROW: photo left, headline cost right (chef 2026-08-03). Wraps on a narrow window, so a
   phone stacks them rather than squeezing a 150px tile against a KPI card. */
.rhead{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap;margin:0 0 18px}
.rhead-photo{flex:0 0 auto}
.rhead-kpis{flex:1 1 320px;min-width:280px}
.rhead-kpis .cards{margin:0}
.dishphoto{margin:0}
.dishphoto-row{display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap}
.dishphoto-side{display:flex;flex-direction:column;gap:8px;max-width:320px}
.dishphoto-tile{position:relative;width:150px;aspect-ratio:1;border-radius:14px;overflow:hidden;
  display:grid;place-items:center;background:var(--fill);cursor:pointer;flex:0 0 auto}
/* CONTAIN, NOT COVER (chef 2026-08-03: "the imgage is not fit it's zoomed"). cover fills the square by
   cropping to the centre, which on a wide plate shot throws away both ends of the dish and magnifies
   what is left — it reads as a zoomed smudge rather than as a photograph. contain fits the WHOLE frame
   inside the square and pads what is left over, so the tile is still square (the grid still lines up)
   but nothing is hidden. The pad is the tile's own fill, so it reads as a mount, not as a gap. */
.dishphoto-tile img{width:100%;height:100%;object-fit:contain;display:block}
/* The empty state is a dashed well, so an EMPTY tile reads as "put something here" rather than as a
   broken image — the difference between an invitation and a defect. */
.dishphoto.empty .dishphoto-tile{border:2px dashed var(--sep);background:transparent}
.dishphoto-hint{display:grid;gap:4px;text-align:center;font-size:var(--fs-caption);color:var(--text2);
  padding:8px;line-height:1.35}
/* "Change" only on hover/focus of a tile that HAS a photo: a permanent overlay would sit on top of the
   dish in every screenshot the chef ever takes of his own page. */
.dishphoto-over{position:absolute;inset:auto 0 0 0;background:rgba(0,0,0,.55);color:#fff;
  font-size:var(--fs-caption);padding:5px;text-align:center;opacity:0;transition:opacity .15s}
.dishphoto-tile:hover .dishphoto-over,.dishphoto-tile:focus-within .dishphoto-over{opacity:1}
/* Dragging over it. Set by app.js rather than :hover, because a drag does not fire hover. */
.dishphoto-tile.dropping{outline:3px solid var(--accent);outline-offset:2px;background:var(--accent-tint)}
.dishphoto-stage{position:relative;display:inline-block;max-width:100%;margin:10px 0;touch-action:none}
.dishphoto-canvas{max-width:100%;height:auto;display:block;border-radius:10px;cursor:crosshair}
.dishphoto-crop{position:absolute;border:2px solid var(--accent);background:rgba(255,255,255,.18);
  pointer-events:none;border-radius:2px}
@media print{.dishphoto-edit,.dishphoto-current .btn,.dishphoto-current form{display:none!important}}
/* THE SPEC PHOTO LIVES IN THE IDENTITY BAND (chef 2026-08-08: "Big nice photo, easy to see on paper the
   recipe name when you turn the pages"), photo LEFT and name RIGHT — the mirrored arrangement he chose.
   It is no longer floated and no longer square: it is the left cell of a two-cell band, so it SHARES the
   band's height with the name block instead of pushing the document down, which is what pays for making
   it four times bigger than the 34mm thumbnail it replaced. Sized in .ss-band below.
   object-fit:contain, never cover — a printed sheet that crops the ends off the dish is showing the cook
   a different plate from the one in the photograph. */
.specsheet .ss-foot,.specsheet .ss-fineprint{clear:both}

/* Remove-recipe popup (chef 2026-08-02: "I would like this to be in the popup instead"). Wider than the
   stock .ao-dialog because the two choices are full sentences, not OK/Cancel — the whole point of the
   question is that the chef reads what each one does to a recipe he may have spent a year on. */
.rmdlg{max-width:560px}
.rmdlg .se-body{padding:0 18px 4px}
/* NOT scoped to .rmdlg: the same partial renders on the standalone /delete/ page, which is the fallback
   for a browser with no <dialog> and the server's backstop for an answerless POST. Scoping the layout to
   the popup would have left that page's buttons stacked and unstyled — the copy a chef only ever sees
   when something has already gone sideways. */
.rmchoice{display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin:0}

/* ---- Advanced multi-select filter (.msel) ----------------------------------------------------
   A searchable, multi-select category picker in a <details>. Built for the recipes list
   (chef 2026-07-19) and MOVED HERE from that page's local <style> on 2026-08-02, when the chef asked
   for the same control on the stock count sheet ("can it be the advanced select ... we did this before
   in other sections"). Same reason .rtabs and .bulkbar moved before it: the second copy of a widget is
   where the two start to drift.

   TWO MODES share this markup, and the difference is in app.js, not here:
     * FORM mode (recipes list) — the checkboxes are named "category", post as a GET getlist, and an
       Apply button submits. The page is paginated and server-filtered, so a round trip is unavoidable.
     * LIVE mode (stock sheet) — [data-filter="#rows"] on the <details>. Ticking filters the table
       immediately, because that sheet autosaves each quantity on blur and a reload would throw away a
       number the chef was halfway through typing. It has no Apply for the same reason: there is
       nothing to defer. */
.msel{position:relative;display:inline-block}
.msel-summary{width:auto;cursor:pointer;list-style:none;user-select:none;white-space:nowrap;display:inline-flex;align-items:center;gap:8px}
.msel-summary::-webkit-details-marker{display:none}
.msel[open] .msel-summary{border-color:var(--accent)}
.msel-caret{transition:transform .15s;color:var(--text2)}
.msel[open] .msel-caret{transform:rotate(180deg)}
.msel-pop{position:absolute;z-index:30;top:calc(100% + 6px);left:0;min-width:240px;max-width:340px;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:12px;box-shadow:var(--shadow-lift,var(--shadow));padding:10px}
.msel-search{width:100%;margin-bottom:8px}
.msel-list{max-height:240px;overflow-y:auto;display:grid;gap:2px}
.msel-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;font-size:14px;cursor:pointer}
.msel-opt:hover{background:var(--fill)}
.msel-opt input{width:auto}
.msel-opt.hide{display:none}
.msel-actions{display:flex;gap:8px;margin-top:8px;padding-top:8px;border-top:.5px solid var(--sep)}
.badge{font-size:var(--fs-caption2);font-weight:600;border-radius:var(--r-sm);padding:3px 8px;display:inline-block}
.badge.ok,.badge.good{background:var(--ok-bg);color:var(--ok-fg)} .badge.warn{background:var(--warn-bg);color:var(--warn-fg)} .badge.bad{background:var(--al-bg);color:var(--al-fg)}
/* Neutral count badge — for a non-zero problem count that is NOT good news, so it must not borrow the
   green "ok" style (e.g. info-severity Today cards). */
.badge.neutral{background:var(--fill);color:var(--text2)}
/* Allergen badges (shared core/_allergen_badge.html): CONTAINS = filled red chip, MAY-CONTAIN = outlined
   amber chip prefixed "~" — presence carried by SHAPE+FILL, not colour alone, so it survives colour-blind
   readers and a black-&-white inspection photocopy. The pictogram is decorative (aria-hidden). */
.badge.may{background:transparent;color:var(--warn-fg);border:1px solid var(--warn-fg)}
.allergen-ic{margin-right:3px}
@media print{
  .badge.bad.allergen{background:#fff !important;color:#000 !important;border:1.5px solid #000}
  .badge.may.allergen{background:#fff !important;color:#000 !important;border:1px dashed #000}
}

/* ---- buttons / forms ---- */
.btn{border:0;border-radius:var(--r-ctrl);padding:10px 17px;font-weight:600;font-size:var(--fs-subhead);transition:transform .12s,filter .2s;display:inline-flex;align-items:center;gap:8px}
.btn:active{transform:scale(.97)} .btn.primary{background:var(--btn-fill);color:var(--btn-ink)} .btn.ghost{background:var(--fill);color:var(--text)}
/* A DELIBERATE hover colour rather than brightness(1.05) on the primary: brightening a fill that was
   chosen for its contrast ratio walks it back toward the ratio it was chosen to escape. Ghost and the rest
   keep the filter — they are not carrying a contrast guarantee. */
.btn:hover{filter:brightness(1.05)} .btn.primary:hover{background:var(--btn-fill-hover);filter:none}
/* THE GHOST BUTTON GOT FAINTER WHEN YOU POINTED AT IT, EVERYWHERE, AND NOBODY HAD MEASURED IT
   (2026-08-17). .btn.ghost rests on --fill and this rule handed it --fill-2, which is the WEAKER of
   the pair — measured in a real browser on the menu page: rgba(120,120,128,.12) -> .08 in light,
   .24 -> .16 in dark. And `filter:none` cancels the .btn:hover brightness at the same time, so the
   ghost button was the one control in the app with no positive hover response at all.

   FIXED ONCE, HERE, RATHER THAN SCOPED to the block that reported it: 273 ghost buttons across 72
   templates, and grep finds NO other rule anywhere that overrides .btn.ghost's background, so every
   one of them rests on --fill and every one improves by the same step. Text stays --text: 13.4:1 on
   the new light fill over white and 11.2:1 in dark, both up from where they were.

   NOT FIXED BY SWAPPING THE TOKEN VALUES, which was the tempting one-liner. --fill-2 is correct for
   its other five users; they hover from transparent, where .08 is an increase. See its note above.

   THE OTHER TWO INSTANCES ARE NOW FIXED TOO (2026-08-17), because a token inversion fixed once and
   left standing in two known places is a to-do list, not a fix: .iconbtn:hover above, and the pair
   core/templates/core/landing.html re-declares in its own page <style> — which mattered more than it
   looks, since that page carries the product's primary "Open the live demo" CTAs. landing.html
   duplicates the palette, so --fill-strong had to be declared in BOTH its light and dark blocks
   first: an undeclared custom property silently drops the whole declaration. */
.btn.ghost:hover{background:var(--fill-strong);filter:none}

/* ---- toasts (Django messages) ---- */
.toasts{position:fixed;top:64px;right:18px;z-index:120;display:flex;flex-direction:column;gap:8px;max-width:340px}
.toast{background:var(--bg-elev);border:.5px solid var(--sep);border-left:3px solid var(--accent);
  border-radius:12px;box-shadow:var(--shadow-lift);padding:12px 15px;font-size:var(--fs-footnote);color:var(--text);
  animation:toastin .25s ease;display:flex;align-items:flex-start;gap:10px}
.toast-msg{flex:1 1 auto}
.toast-x{flex:0 0 auto;background:none;border:0;color:var(--text3);font-size:18px;line-height:1;
  cursor:pointer;padding:0 2px;margin:-2px -4px 0 0}
.toast-x:hover{color:var(--text)}
.toast.success{border-left-color:var(--green)} .toast.warning{border-left-color:var(--orange)}
.toast.error{border-left-color:var(--red)}
.toast.cl-hide{opacity:0;transition:opacity .4s}
@keyframes toastin{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:none}}
@media(max-width:760px){.toasts{left:12px;right:12px;max-width:none;top:56px}}
/* Responsive "stacked cards" for wide, interaction-heavy tables (e.g. the invoice-import preview),
   so each row is a tap-friendly label:value card on a phone instead of a 10-col horizontal scroll.
   Opt-in via class="respcards"; cells carry data-label for the field name. */
@media(max-width:760px){
  .respcards thead{display:none}
  .respcards, .respcards tbody, .respcards tr, .respcards td{display:block;width:auto}
  .respcards tr{border:.5px solid var(--sep);border-radius:12px;padding:10px 14px;margin-bottom:12px;
    background:var(--bg-elev);box-shadow:var(--shadow)}
  .respcards tr.row-warn{border-left:3px solid var(--orange)}
  .respcards tr.row-save{border-left:3px solid var(--accent)}
  .respcards td{padding:6px 0;border:0;text-align:left}
  .respcards td.num{text-align:left}
  .respcards td::before{content:attr(data-label);display:block;font-weight:600;font-size:var(--fs-caption);
    color:var(--text2);margin-bottom:3px}
  .respcards td[data-label=""]::before,.respcards td:not([data-label])::before{content:none}
}
/* Per-column filter row under a table header: compact, full-width search inputs. */
.colfilter th{padding-top:2px;padding-bottom:8px;vertical-align:top}
.colfilter input,.colfilter select{width:100%;min-width:80px;margin:0;font-size:var(--fs-caption);padding:5px 8px;font-weight:400}
/* Dashboard mini-panels: 2-col grid of compact, click-through rows (collapses to 1 col on phones). */
.dashgrid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}
@media(max-width:760px){.dashgrid{grid-template-columns:1fr}}
.dashrow{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 0;
  border-bottom:.5px solid var(--sep)}
.dashrow:last-child{border-bottom:0}
.dashrow a{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Feed card rows (dashboard recent price moves) — a coloured direction icon gives each move visual
   weight + per-item separation vs the flat .dashrow text. Price UP = bad (red), DOWN = good (green). */
.feed{display:flex;flex-direction:column}
.fr{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:.5px solid var(--sep)}
.fr:last-child{border-bottom:0}
.fr .ic{flex:0 0 28px;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-size:var(--fs-caption)}
.fr .ic.up{color:var(--red);background:var(--al-bg)}
.fr .ic.down{color:var(--green);background:var(--ok-bg)}
.fr .ic.flat{color:var(--text2);background:var(--fill)}
.fr-body{flex:1;min-width:0}
.fr-body a{font-weight:600;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fr-meta{font-size:var(--fs-caption);color:var(--text2)}
/* Import-preview cost-delta tint: a price drop (saving) reads green, a moderate rise amber, a big
   rise red — so the dangerous jumps pop on a long sheet. .delta-flat (±5%) stays untinted. */
.delta-down{background:var(--ok-bg)}
.delta-up{background:var(--warn-bg)}
.delta-up-big{background:var(--al-bg)}
.btn.sm{padding:8px 13px;font-size:var(--fs-footnote)}
/* visible keyboard focus for everything interactive (mouse users see nothing, per :focus-visible) */
a:focus-visible,button:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible,
.price:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:8px}
label{display:block;font-size:var(--fs-footnote);font-weight:600;margin-bottom:5px}
.sinput,input[type=text],input[type=email],input[type=password],input[type=number],select,textarea{
  background:var(--fill);border:.5px solid var(--sep);border-radius:9px;padding:9px 12px;font:inherit;font-size:14px;color:var(--text);width:100%}
/* NOTE: this rule has the same specificity as the :focus-visible block above and comes LATER, so it wins
   for every input/select/textarea. It used to paint the ring in --accent-tint, which composites to ~1.11:1
   on --bg — a de-facto invisible focus indicator on the app's most common control. (a11y audit, critical) */
.sinput:focus,input:focus,select:focus,textarea:focus{outline:2px solid var(--focus);outline-offset:2px;border-color:var(--accent)}
select{cursor:pointer;color-scheme:light}
/* Dark theme: the OPEN option list is drawn by the browser, not our CSS. Root-level color-scheme
   alone proved unreliable for the popup in Chromium (chef report, 2026-07), so belt-and-braces:
   (1) color-scheme on the <select> ELEMENT (the popup follows the element's used scheme), and
   (2) explicit option colors — Chromium applies option background/color inside the popup on
   Windows/Linux, which guarantees readable dark-on-dark even where (1) is ignored. */
:root[data-theme="dark"] select{color-scheme:dark}
option{background:var(--bg-elev);color:var(--text)}
/* datalist (type-to-search) inputs keep the styled box but drop the native dropdown arrow, which
   rendered as a dated beveled control; suggestions still appear on type/focus. */
input[list]::-webkit-calendar-picker-indicator{display:none}
.formrow{display:flex;gap:12px;flex-wrap:wrap;align-items:end}
/* Default sizing for formrow fields, but at 0-specificity for .formrow (:where) so a field can override
   flex/min-width with a plain utility class (.flex-0 .minw-170 …). Inline styles used to win here; after
   the CSP inline-style removal (audit 9.1) the overrides are utility classes, which must beat this. */
:where(.formrow)>div{flex:1;min-width:120px}
.muted{color:var(--text2)} .right{text-align:right} .up{color:var(--red);font-weight:600} .down{color:var(--green);font-weight:600}
/* Visually-hidden but screen-reader-readable. Used to carry severity labels that are otherwise only
   conveyed by colour/emoji (e.g. Today cards). Standard clip pattern. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
  clip:rect(0,0,0,0);white-space:nowrap;border:0}

/* ---- command palette (Ctrl/Cmd+K) ---- */
.cmdk{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.4);display:grid;place-items:start center;
  padding-top:13vh;backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
.cmdk[hidden]{display:none}
.cmdk-panel{width:min(560px,92vw);background:var(--bg-elev);border:.5px solid var(--sep);
  border-radius:16px;box-shadow:var(--shadow-lift);overflow:hidden}
.cmdk-input{width:100%;border:0;border-bottom:.5px solid var(--sep);background:transparent;
  padding:16px 18px;font-size:var(--fs-callout);color:var(--text);outline:none}
.cmdk-list{list-style:none;margin:0;padding:6px;max-height:52vh;overflow:auto}
.cmdk-list li{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:10px;font-size:var(--fs-subhead)}
.cmdk-list li[aria-selected="true"]{background:var(--accent-tint);color:var(--accent-text)}
.cmdk-list li .ic{width:22px;text-align:center;font-size:var(--fs-callout)}
.cmdk-empty{padding:14px 18px;color:var(--text2);font-size:var(--fs-footnote)}

/* ---- kitchen service mode (big-type, hands-free) ---- */
.svc-wrap{max-width:920px;margin:0 auto;padding:22px 20px 80px}
.svc-ing{font-size:19px;line-height:1.7}
.svc-line{padding:3px 0}
.svc-step{display:flex;gap:16px;align-items:flex-start;background:var(--bg-elev);border:.5px solid var(--sep);
  border-radius:var(--r-card);box-shadow:var(--shadow);padding:18px 20px;margin-bottom:12px;cursor:pointer;
  transition:transform .12s,opacity .2s,border-color .2s}
.svc-step .svc-no{flex:0 0 40px;height:40px;border-radius:50%;background:var(--fill);color:var(--text2);
  display:grid;place-items:center;font-weight:700;font-size:18px}
.svc-step .svc-txt{font-size:var(--fs-title2);line-height:1.4;letter-spacing:-.01em}
.svc-step.current{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-tint),var(--shadow)}
.svc-step.current .svc-no{background:var(--accent);color:#fff}
.svc-step.done{opacity:.45}
.svc-step.done .svc-txt{text-decoration:line-through}
.svc-step.done .svc-no::after{content:"✓"}
.svc-step.done .svc-no{font-size:0} .svc-step.done .svc-no::after{font-size:18px}

/* ---- centered auth/standalone shell ---- */
.authwrap{min-height:100vh;display:grid;place-items:center;padding:24px}
.authcard{width:100%;max-width:460px;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow-lift);padding:34px 32px}

/* ---- print: drop the app chrome, keep the content (recipe sheet / stock count / allergen matrix) ---- */
@media print{
  .top,nav.side,.botnav,.botmore,.iconbtn,.btn,.hint,[data-filter],.price .pen{display:none !important}
  .app{display:block}
  main.content{padding:0;overflow:visible}
  html,body{background:#fff;color:#000}
  a{color:#000}
  thead th{color:#000}
  .panel,.tablewrap,.kpi,.rcard{box-shadow:none;border:1px solid #ddd;break-inside:avoid}
  .print-only{display:block !important}             /* legends/notes hidden on screen, shown on paper */
  /* ...AND THE OPPOSITE OPT-OUT: A BANNER THAT IS NOT DECORATION (2026-08-14).
     `.hint` is hidden by the rule above because a hint is normally a screen affordance — "tap a chip
     to filter", "price more dishes and this sharpens". That is right for those. It is a COMPLIANCE
     DEFECT the moment a legally load-bearing sentence is wearing the class for its screen styling:
     the menu-scoped allergen sheet's two "this grid covers fewer dishes than the menu" disclosures
     were .hint, so the sheet a server hands a guest with an allergy PRINTED LOOKING COMPLETE while
     silently omitting dishes. `.print-keep` puts one banner back on the paper.

     THE RULE, so this does not happen again: a sentence the reader needs in order to read the
     PRINTOUT correctly — what the document omits, what its numbers are relative to, what it is not —
     must not inherit a class chosen for how it looks on screen. Mark it .print-keep, or give it its
     own class. Everything wearing .print-keep today is that kind of sentence and nothing else.

     It prints as a ruled block rather than a tinted card: the tint is a screen cue that a mono laser
     drops anyway, and --al-bg / --warn-bg are theme tokens, so a printed disclosure must not depend
     on one for its contrast. */
  .hint.print-keep,.print-keep{display:block !important;background:transparent !important;
    border:1px solid #666 !important;box-shadow:none !important;color:#000 !important;
    padding:2.5mm 3mm;margin:0 0 3.5mm;break-inside:avoid}
  .print-keep b,.print-keep span{color:#000 !important}
  .stickycol [role=img]{color:#000 !important}       /* allergen ●/◐/· stay legible in B&W (colour is lost) */
  /* NO @page HERE. There is exactly ONE @page rule in this file now, in the global print block at the
     bottom — see the comment there (2026-08-08). This block used to declare margin:14mm, the spec sheet
     declared 14mm and then 12mm, the QR labels 12mm, and the global block 16mm; @page rules all carry
     the same specificity and cascade in SOURCE ORDER, so the LAST one in the file silently won for every
     printed page in the app. The spec sheet's 12mm had been dead for as long as the global block has
     existed, which is ~26mm of extra height at 16 ingredients — on the one print path with no page
     count test behind it. */
}
.print-only{display:none}                            /* screen: hidden; @media print reveals it */

@media(max-width:760px){
  .app{grid-template-columns:1fr;grid-template-areas:"top" "main"}
  nav.side{display:none}
  main.content{padding:20px 16px 76px}   /* room for the fixed bottom nav so content isn't hidden */
  h1{font-size:24px}
  .top{padding:10px 14px;gap:8px}
  .top .kitchen{display:none}            /* keep the top bar from overflowing on phones */
  .top .pill{display:none}               /* hide ALL pills incl. role — decorative on phones, and the
                                            prices-fresh alert lives in the More sheet */
  .top .trialpill{display:none}          /* the trial / payment-failed nudge is in the More sheet too */
  .top .sitesw{display:none}             /* site switch lives in the More sheet on phones (.botmore) */
  .top .cs-switch{display:none}          /* Chef Space switch lives in the More sheet on phones too */
  .brand .bword{display:none}            /* mark-only brand on phones so the 5 action icons + logo fit
                                            a 375px bar (full wordmark pushed the row to ~436px) */
  .tablewrap{overflow-x:auto}            /* wide tables scroll inside their card, not off-page */
  .ph{flex-wrap:wrap}                    /* page-header actions wrap below the title instead of overflowing a ~375px bar */
  .ph>span{flex-wrap:wrap}               /* and a multi-button action group wraps too (e.g. payroll Print + Export CSV) */
  .botnav{display:flex}                  /* phone-only: reveal the bottom nav (the sidebar is hidden) */
}

/* ---- AI Kitchen Assistant drawer (reuses the .cmdk overlay shell) ---- */
.asst-panel{padding:0;display:flex;flex-direction:column;max-height:80vh;height:min(80vh,640px)}
.asst-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
  padding:14px 16px 12px;border-bottom:.5px solid var(--sep)}
.asst-title{font-weight:700;font-size:var(--fs-callout);letter-spacing:-.01em;display:flex;align-items:center;gap:8px}
.asst-head .iconbtn{width:30px;height:30px;font-size:14px}
/* Chat log: the growing, scrolling middle; chips + input sit below it (chef 2026-07-22 "like a chat"). */
.asst-log{flex:1 1 auto;min-height:80px;overflow-y:auto;padding:16px 18px;display:flex;
  flex-direction:column;gap:10px;scroll-behavior:smooth}
.asst-hint{margin:0;font-size:var(--fs-footnote)} .asst-hint b{color:var(--text)}
/* One chat message. AI bubbles tint left; the chef's own messages sit right in a neutral fill. */
.asst-msg{max-width:88%;border-radius:14px;padding:11px 14px}
.asst-msg.ai{align-self:flex-start;background:var(--accent-tint)}
.asst-msg.me{align-self:flex-end;background:var(--fill);color:var(--text)}
.asst-b{font-size:var(--fs-subhead);line-height:1.5;color:var(--text);white-space:pre-wrap}
.asst-msg.ai .asst-b{font-weight:500}
.asst-d{font-size:var(--fs-caption);margin-top:6px;line-height:1.4}
.asst-dots{animation:asstpulse 1.2s ease-in-out infinite}
@keyframes asstpulse{0%,100%{opacity:.45}50%{opacity:.9}}
@media (prefers-reduced-motion:reduce){.asst-dots{animation:none}.asst-log{scroll-behavior:auto}}
.asst-chips{padding:8px 18px;border-top:.5px solid var(--hair)}
.asst-chiprow{display:flex;flex-wrap:wrap;gap:7px}
.asst-chip{cursor:pointer;border:0;background:var(--fill);color:var(--text2);font-size:var(--fs-footnote);
  transition:background .15s,color .15s}
.asst-chip:hover{background:var(--accent-tint);color:var(--accent-text)}
/* Input row pinned at the bottom: text field + Send. */
.asst-form{display:flex;gap:8px;align-items:center;padding:10px 14px;border-top:.5px solid var(--sep)}
.asst-input{flex:1 1 auto;border:0}
.asst-send{flex:0 0 auto}
/* Voice dictation mic (chef 2026-07-22): a ghost button that turns red + pulses while listening. */
.asst-mic{flex:0 0 auto;font-size:16px;line-height:1;padding:8px 11px}
.asst-mic.rec{background:#e5484d;color:#fff;border-color:#e5484d;animation:asstpulse 1s ease-in-out infinite}
@media(max-width:760px){.asst-panel{max-height:88vh;height:88vh}}
/* ---- Recipe spec sheet (core/specsheet.py + recipe_spec.html) ---- */
.specsheet{max-width:820px;margin:18px auto;padding:34px 40px;color:var(--text);
  background:var(--card);border-radius:var(--r-card)}
/* ---- THE IDENTITY BAND: photo LEFT (~42%), name + subline + meta RIGHT (~58%) ----
   Chef 2026-08-08: "rethink the design ... make it a masterpiece, easily readable from paper. Big nice
   photo, easy to see on paper the recipe name when you turn the pages." He chose this arrangement,
   mirrored from the first sketch.

   THIS REVERSES THE 2026-08-01 CENTRING, deliberately, and the old rule is worth stating because it was
   right about the problem: "the tittle has to be sentered when u scroll the cook book so each
   information easy identifiable" — scrolling a stack of sheets, a left-aligned name sat in the same
   column as the body text under it and nothing marked where one recipe ended. Centring fixed that by
   making the name an anchor a third of the way down the page.
   The band plus the RUNNING HEADER fix it better. The name now sits beside a 72-84mm photograph, and it
   is repeated in tracked caps at the top edge of EVERY printed page (.ss-runhead here, a page-end canvas
   callback in core/specsheet.py). Flicking a paper stack reads the name off the edge instead of hunting
   for a centred line inside the sheet — which is the thing he actually wanted. The matching comment in
   core/specsheet.py was updated in the same pass; a comment left arguing with its own code is how the
   next person quietly re-centres it. */
.specsheet .ss-band{display:flex;align-items:center;gap:20px;margin-bottom:16px;
  padding-bottom:14px;border-bottom:1.2px solid var(--accent)}
.specsheet .ss-photo{flex:0 0 42%;width:42%;aspect-ratio:4/3;object-fit:contain;background:var(--fill);
  border-radius:10px;display:block}
.specsheet .ss-ident{flex:1 1 auto;min-width:0}
.specsheet .ss-meta{font-size:var(--fs-caption);color:var(--text3);margin:6px 0 0;line-height:1.6}
.specsheet .ss-title{font-size:var(--fs-title1);font-weight:800;margin:0 0 2px;letter-spacing:-.02em}
.specsheet .ss-sub{color:var(--text2);margin:0;font-size:14px}
/* RUNNING HEADER / FOOTER — print only, and hidden entirely on screen (the .print-only utility). Chrome
   and Edge repeat a position:fixed element on every printed sheet, which is what makes these a running
   strip; Firefox prints them once and degrades harmlessly. NOT @page margin boxes (no browser ships
   them) and NOT display:table-header-group (it breaks the layout the photo band depends on). */
.specsheet .ss-runhead,.specsheet .ss-runfoot{display:none}
/* auto-fit, not repeat(4,1fr) (2026-08-09). The band is 2 tiles on a dish's kitchen copy, 3 on a prep,
   4 on a prep's costing copy and 5 on a dish's — a fixed four-column grid left a hole at three and
   would have wrapped the fifth onto a second row. minmax(0,...) so a long tile value cannot blow the
   track out past its share. */
.specsheet .ss-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));
  gap:12px;margin-bottom:26px}
.specsheet .ss-kpi{border:.5px solid var(--hair);border-radius:var(--r-card,12px);padding:12px 14px}
.specsheet .ss-lab{font-size:var(--fs-caption2);text-transform:uppercase;letter-spacing:.06em;color:var(--text3)}
.specsheet .ss-val{font-size:var(--fs-title3);font-weight:700;margin:4px 0 2px}
.specsheet .ss-note{font-size:var(--fs-caption2);color:var(--text3)}
/* SECTION HEADINGS ARE A RULE WITH A LABEL ON IT, and the label is INK (2026-08-08). Two problems went
   at once: `margin:26px 0 10px` x four headings was most of an inch of an A4 column spent on white
   space, and brand green at 11px renders on a mono laser — which is what a kitchen owns — as light grey.
   Colour may not be the only carrier of a heading, so the green survives as the RULE and the text is
   ink. The PDF renderer draws exactly this shape (core/specsheet.py: RuleHead). */
.specsheet .ss-h{font-size:var(--fs-footnote);text-transform:uppercase;letter-spacing:.08em;
  color:var(--text);font-weight:700;margin:16px 0 7px;padding-bottom:5px;
  border-bottom:1.2px solid var(--accent)}
/* Allergens beside nutrition+diet — see the template. One column until .ss-paired says there is
   something to pair with, and one column again on a phone. */
.specsheet .ss-twoup{display:block}
.specsheet .ss-twoup.ss-paired{display:grid;grid-template-columns:40% 1fr;gap:22px;align-items:start}
.specsheet .ss-col{min-width:0}
.specsheet .ss-col:empty{display:none}
@media (max-width:680px){.specsheet .ss-twoup.ss-paired{grid-template-columns:1fr}
  .specsheet .ss-band{flex-direction:column;align-items:stretch}
  .specsheet .ss-photo{width:100%;flex-basis:auto}}
/* Numbered ingredients on the printed sheet too (chef 2026-08-03). Same counter idiom as the recipe
   page, so a cook reading "6." on the sheet is reading the same 6 he sees on screen. On the FIRST cell
   rather than a column of its own: a # column would cost width on a sheet whose whole problem is
   fitting one page. */
.specsheet .ss-table tbody{counter-reset:specrow}
.specsheet .ss-table tbody tr{counter-increment:specrow}
/* THE NUMBER OCCUPIES A FIXED BOX, so the name starts on ONE hard left edge (chef 2026-08-09: "the
   ingredient sequential numbering shall be in the separate column, 9 to 10 causes the missalignment of
   the ingredient name"). He is exactly right about the mechanism: an inline counter with a margin makes
   the name start wherever the number ends, and "10." is a digit wider than "9." — so every ingredient
   from line 10 down shifted right. A list with two different left edges reads as two lists.
   inline-block + a fixed width + text-align:right is the pseudo-element's equivalent of the PDF's real
   # column (core/specsheet.py: ing_cols): 3.2em is sized for THREE digits, so a 100-line recipe does
   not shift either, and box-sizing:border-box (line 76) means the .55em gap is inside that width. */
.specsheet .ss-table tbody td:first-child::before{content:counter(specrow) ".";color:#888;
  font-variant-numeric:tabular-nums;display:inline-block;width:3.2em;padding-right:.55em;
  text-align:right;margin-right:0}
/* The batch-total row is a SUMMARY, not an ingredient — numbering it would claim the recipe has one
   more line than it does. tfoot is outside tbody so it never increments; this guards the case where a
   future edit moves that row inside. */
.specsheet .ss-table tfoot td:first-child::before{content:none}
.specsheet .ss-table{width:100%;border-collapse:collapse;font-size:14px}
.specsheet .ss-table th,.specsheet .ss-table td{padding:7px 8px;text-align:left;
  border-bottom:.5px solid var(--hair)}
.specsheet .ss-table thead th{font-size:var(--fs-caption2);text-transform:uppercase;letter-spacing:.05em;color:var(--text3)}
.specsheet .ss-table .num{text-align:right;white-space:nowrap}
/* THE QUANTITY SITS BESIDE THE INGREDIENT, NOT AT THE PAPER'S EDGE (chef 2026-08-09: "i would like the
   Qty to be closer to ingredients so users dont have to look to the next edge of the paper").

   It was already the column immediately right of the name — the distance came from WIDTH, not order. A
   right-aligned column whose right edge IS the table's right edge puts the number as far from the name
   as the sheet allows, so on the costing copy the eye crossed ~40% of an A4 and on the kitchen copy
   (where Qty is the LAST column) it crossed the lot. Two fixes, one per copy:
     - costing: the name column is capped and the leftover goes to the COST column after it, so Qty's
       right edge lands around 62% instead of 76%;
     - kitchen: there is no third column to absorb anything, so the TABLE itself stops short of the
       margin. The zebra bands stop with it, which keeps the block reading as one object.
   The numbers stay right-aligned and tabular inside their own column, so the decimals still stack. The
   PDF reaches the same shape by measuring its own content (core/specsheet.py: ing_cols). */
.specsheet .ss-table thead th:first-child{width:46%}
.specsheet .ss-table thead th:nth-child(2){width:16%}
.specsheet.ss-kitchen .ss-table{width:74%}
.specsheet .ss-table tfoot th{font-weight:700;border-top:1.5px solid var(--text);border-bottom:none}
.specsheet .ss-method{list-style:none;margin:0;padding:0;counter-reset:none}
.specsheet .ss-method li{display:flex;gap:12px;padding:9px 0;font-size:14px;line-height:1.5;
  border-bottom:.5px solid var(--hair)}
.specsheet .ss-step-n{flex:0 0 24px;height:24px;border-radius:50%;background:var(--accent-tint);
  color:var(--accent-text);display:grid;place-items:center;font-weight:700;font-size:var(--fs-caption)}
.specsheet .ss-step-body{flex:1}
.specsheet .ss-step-meta{display:block;margin-top:3px;font-size:var(--fs-caption);color:var(--text3)}
.specsheet .ss-allergen{font-size:14px;line-height:1.7;margin:0}
/* SIGNATURE + FINE PRINT ON ONE BASELINE ROW (2026-08-08). It used to be two 30px rules with captions
   under them and then a centred provenance paragraph — ~24mm of column for two blank lines and a
   sentence. The caption now sits ON the baseline the signer writes above, and the fine print rides the
   same row. */
.specsheet .ss-foot{display:flex;gap:24px;align-items:flex-end;margin-top:20px}
.specsheet .ss-sign{flex:0 0 auto;min-width:150px;border-bottom:1px solid var(--text);
  padding-bottom:2px;height:34px;display:flex;align-items:flex-end;gap:6px}
/* "Prepared by" is PREFILLED TEXT, not a line to sign on (chef 2026-08-09) — so it carries no rule. A
   rule under a printed name reads as "sign here" and gets signed, which is the exact confusion that
   splitting the old combined "Prepared / approved by" slot exists to remove. */
.specsheet .ss-sign-filled{border-bottom:none}
.specsheet .ss-signer{font-weight:600}
.specsheet .ss-cap{font-size:var(--fs-caption2);color:var(--text3);text-transform:uppercase;letter-spacing:.06em}
/* THE BRAND MARK IN THE PRINT FOOTER STRIP (chef 2026-08-09: "a nice minimalistic reference to chefslab
   icon and website in the footer"). Minimal means one line at the type's own size, in the strip's grey,
   in the margin the sheet already reserves — not a banner. It carries no information a reader needs, so
   it never competes: same #444 as the rest of the strip, and the mark takes currentColor so it prints
   as flat grey on the mono laser a kitchen actually owns. Screen-hidden with the strip itself. */
.specsheet .ss-brand{display:inline-flex;align-items:center;gap:1.2mm;white-space:nowrap}
.specsheet .ss-brandmark{width:3.2mm;height:3.2mm;flex:0 0 auto}
.specsheet .ss-fineprint{margin-top:10px;font-size:var(--fs-caption2);color:var(--text3)}
.specsheet .ss-foot .ss-fineprint{flex:1 1 auto;margin:0;text-align:right}
.specsheet .muted{color:var(--text3)}
@media (max-width:680px){.specsheet{padding:22px 18px}.specsheet .ss-kpis{grid-template-columns:repeat(2,1fr)}}

/* ---- THE SPEC SHEET ON PAPER (chef 2026-08-08: "make it a masterpiece, easily readable from paper").

   A kitchen prints in MONO. Every rule below assumes a black-and-white laser, an A4 sheet held at arm's
   length under a heat lamp, and a photocopy of that sheet a week later:

     - NO information is carried by hue alone. Headings are ink with a rule; warn states carry a glyph
       AND a rule AND dark ink; the step number is an OUTLINED circle, not a filled grey disc (a grey
       blob at 300dpi mono). The brand green survives as RULES only.
     - Ingredient rows are zebra-striped at 4% grey rather than separated by hairlines — a band the eye
       tracks across a wide row, and it survives greyscale where a .4pt #e5e5ea line does not.
     - MINIMUM TYPE: 9.5pt body on the costing copy, 10pt on the kitchen copy (a cook reads it with his
       hands full), 10.5pt semibold for the kitchen quantity, and nothing anywhere below 8pt. The old
       7pt KPI note and 7.5pt fine print were unreadable on a photocopy.

   FOUR DENSITY TIERS, ss-dhero / ss-dlarge / ss-dstandard / ss-ddense, stamped on the article by the
   view. THE PDF RENDERER PICKS THE TIER BY MEASUREMENT (core/specsheet.py: density_tier + _fit_story)
   and hands the answer here, because the browser cannot measure anything before it paints. That is the
   only way the two printables can agree about type size, photo size and page count.

   ONE THING THE PDF DOES THAT THIS PATH CANNOT: at the two denser tiers the PDF runs the ingredient list
   in TWO COLUMNS, which is the single biggest lever on the page (~140mm of rows becomes ~70mm). An HTML
   <table> cannot be column-split without destroying the row semantics a screen reader depends on, so the
   browser-print path buys its page back from type, padding and photo instead. The Download PDF button is
   the canonical printable; this is the best-effort one. */
@media print{
  /* Hide the app chrome + on-screen-only controls. Adjust the chrome selectors to match
     base.html if its top bar / sidebar use different class names. */
  .no-print,.topbar,.sidebar,nav,header.appbar,.app-nav,.trial-pill{display:none !important}
  body{background:#fff !important}
  .specsheet{max-width:none;margin:0;border-radius:0;background:#fff !important;color:#000 !important;
    box-shadow:none;
    /* Room for the two fixed running strips. The PDF draws its equivalents in the page margins for
       nothing; a browser will not paint into the @page margin at all, so the body pays 7mm at each end
       here — still a net gain, because folding the letterhead into the strip gave back ~17mm. */
    padding:6mm 0 6.5mm}
  .specsheet .ss-title{color:#000 !important}
  .specsheet .ss-band{border-bottom-color:#000 !important}
  /* Heading TEXT is ink; the accent survives as the rule under it. */
  .specsheet .ss-h{color:#000 !important;border-bottom-color:#000 !important;
    border-bottom-width:1.2px !important}
  .specsheet .ss-method li{border-bottom-color:#ccc !important}
  .specsheet .ss-table tfoot th,.specsheet .ss-sign{border-color:#000 !important}
  /* An OUTLINED circle with ink digits. It was a filled #eee disc, which prints as a grey blob. */
  .specsheet .ss-step-n{background:#fff !important;color:#000 !important;
    border:.8px solid #000;border-radius:50%;
    -webkit-print-color-adjust:exact;print-color-adjust:exact}
  /* #444, not #555: these are the smallest type on the sheet and they are at the 8pt floor. */
  .specsheet .ss-lab,.specsheet .ss-note,.specsheet .ss-meta,
  .specsheet .ss-cap,.specsheet .ss-fineprint,.specsheet .ss-step-meta{color:#444 !important}
  .specsheet .ss-method li,.specsheet .ss-kpi{break-inside:avoid}
  .specsheet .ss-h{break-after:avoid}
  /* The ingredient table may cross a page break — it says so on screen and in the footer when it will —
     so its header row must repeat. thead does that natively once the table is allowed to split. */
  .specsheet .ss-table thead{display:table-header-group}
  .specsheet .ss-table tfoot{display:table-row-group}
  .specsheet .ss-table tr{break-inside:avoid}
  /* ZEBRA at 4% grey instead of a hairline per row. */
  .specsheet .ss-table tbody tr:nth-child(even) td,
  .specsheet .ss-nut tbody tr:nth-child(even) td{background:#f5f5f5 !important;
    -webkit-print-color-adjust:exact;print-color-adjust:exact}
  .specsheet .ss-table th,.specsheet .ss-table td{border-bottom:0 !important}
  .specsheet .ss-table thead th{border-bottom:.9px solid #000 !important}
  /* WARN STATES SURVIVE MONO. .badge.bad.allergen has had this treatment since the allergen matrix went
     to paper; .badge.warn never got it, so "⚠ no price" — the marker on the row a reader has to go and
     fix — printed as light amber on light grey. Glyph, rule and dark ink, none of them optional. */
  .badge.warn{background:#fff !important;color:#000 !important;border:1.2px solid #000;font-weight:700}

  /* ---- RUNNING HEADER AND FOOTER ----
     position:fixed, which Chrome and Edge repeat on every printed sheet. Firefox does not and prints
     them once, which is harmless. Deliberately NOT @page margin boxes (no browser ships them) and NOT
     display:table-header-group (it breaks the band layout). */
  .specsheet .ss-runhead,.specsheet .ss-runfoot{display:flex !important;
    position:fixed;left:0;right:0;justify-content:space-between;align-items:baseline;
    font-size:8pt;color:#444 !important;background:#fff}
  .specsheet .ss-runhead{top:0;padding-bottom:1.5mm;border-bottom:.4px solid #999}
  .specsheet .ss-runfoot{bottom:0;padding-top:1.5mm}
  .specsheet .ss-run-name{font-weight:700;color:#000 !important;text-transform:uppercase;
    letter-spacing:.09em;font-size:8pt}
  .specsheet .ss-run-over{font-weight:700;color:#000 !important}

  /* ---- LAYOUT COMMON TO EVERY TIER ----
     EVERYTHING INHERITS THE TIER'S BODY SIZE. .ss-table, .ss-method li and .ss-allergen each carried a
     hard 14px from the screen rules and .ss-nut a hard 12px from the template's own <style>, so the four
     tiers were moving the article's font-size and the blocks that actually fill the page ignored it —
     measured at 14px against a 9.5pt (12.7px) body, which is ~10% of the ingredient table's height for
     nothing. */
  .specsheet .ss-table,.specsheet .ss-method li,.specsheet .ss-allergen,
  .specsheet .ss-nut{font-size:inherit}
  .specsheet .ss-band{gap:5mm;margin-bottom:3mm;padding-bottom:2.5mm}
  .specsheet .ss-photo{background:none;border-radius:2mm}
  .specsheet .ss-sub{margin:0}
  .specsheet .ss-meta{margin-top:1.2mm}
  .specsheet .ss-kpis{gap:1.6mm;margin-bottom:0}
  .specsheet .ss-lab,.specsheet .ss-note{line-height:1.25}
  .specsheet .ss-val{margin:.6mm 0 .4mm}
  .specsheet .ss-allergen{line-height:1.4}
  .specsheet .ss-twoup.ss-paired{gap:5mm}
  /* ---- THE PROVENANCE BLOCK SITS ON THE BOTTOM RULE, ALWAYS (chef 2026-08-10: "this section has to be
     alligned to the bottom always").
     A four-ingredient prep used to leave the signature floating under the allergen paragraph with a third
     of the sheet blank beneath it, which reads as a document that was cut short. The article becomes a
     full-page flex COLUMN and the footer takes the slack with margin-top:auto — the one mechanism that
     needs no measurement, which matters because a browser cannot measure a page before it paints it.
     min-height, not height: a long recipe grows past one page exactly as before and the block lands on
     the foot of the last one. The PDF reaches the same place by a measured spacer it can compute at
     layout time (core/specsheet.py: _anchor_signature_to_the_foot). */
  .specsheet{display:flex;flex-direction:column;min-height:100vh}
  .specsheet .ss-foot{margin-top:auto;padding-top:3mm;gap:6mm}
  .specsheet .ss-sign{height:8mm;min-width:34mm}
  .specsheet .ss-fineprint{margin-top:1.5mm}
  .specsheet .ss-method{margin:0}
  .specsheet .ss-step-n{flex-basis:4.6mm;width:4.6mm;height:4.6mm}

  /* ---- THE FOUR TIERS ---- */
  .specsheet.ss-dhero{font-size:10pt;line-height:1.35}
  .specsheet.ss-dhero .ss-photo{flex-basis:42%;width:42%}
  .specsheet.ss-dhero .ss-title{font-size:20pt}
  .specsheet.ss-dhero .ss-sub{font-size:10pt}
  .specsheet.ss-dhero .ss-h{margin:5mm 0 2.4mm;padding-bottom:1.4mm}
  .specsheet.ss-dhero .ss-table th,.specsheet.ss-dhero .ss-table td{padding:1.5mm 1.9mm}
  .specsheet.ss-dhero .ss-nut th,.specsheet.ss-dhero .ss-nut td{padding:1.1mm 1.6mm}
  .specsheet.ss-dhero .ss-kpi{padding:2.4mm 2.6mm}
  .specsheet.ss-dhero .ss-val{font-size:14pt}
  .specsheet.ss-dhero .ss-method li{padding:1.8mm 0}

  .specsheet.ss-dlarge{font-size:9.5pt;line-height:1.3}
  .specsheet.ss-dlarge .ss-photo{flex-basis:36%;width:36%}
  .specsheet.ss-dlarge .ss-title{font-size:18pt}
  .specsheet.ss-dlarge .ss-sub{font-size:9.5pt}
  .specsheet.ss-dlarge .ss-h{margin:4mm 0 2mm;padding-bottom:1.2mm}
  .specsheet.ss-dlarge .ss-table th,.specsheet.ss-dlarge .ss-table td{padding:1.2mm 1.7mm}
  .specsheet.ss-dlarge .ss-nut th,.specsheet.ss-dlarge .ss-nut td{padding:.9mm 1.4mm}
  .specsheet.ss-dlarge .ss-kpi{padding:2mm 2.2mm}
  .specsheet.ss-dlarge .ss-val{font-size:13pt}
  .specsheet.ss-dlarge .ss-method li{padding:1.4mm 0}

  /* 9.5pt, not the tier table's nominal 9.0 — the readability floor outranks the ladder, exactly as it
     does in the PDF (core/specsheet.py: MIN_BODY_PT). Past the floor a tier buys its page from padding,
     heading gaps and the photo. */
  .specsheet.ss-dstandard{font-size:9.5pt;line-height:1.18}
  .specsheet.ss-dstandard .ss-photo{flex-basis:22%;width:22%}
  .specsheet.ss-dstandard .ss-title{font-size:16pt}
  .specsheet.ss-dstandard .ss-sub{font-size:9pt}
  .specsheet.ss-dstandard .ss-h{margin:3.2mm 0 1.6mm;padding-bottom:1mm}
  .specsheet.ss-dstandard .ss-table th,.specsheet.ss-dstandard .ss-table td{padding:.7mm 1.3mm}
  .specsheet.ss-dstandard .ss-nut th,.specsheet.ss-dstandard .ss-nut td{padding:.45mm 1.1mm}
  .specsheet.ss-dstandard .ss-kpi{padding:1.4mm 1.6mm}
  .specsheet.ss-dstandard .ss-val{font-size:12pt}
  .specsheet.ss-dstandard .ss-method li{padding:1.1mm 0}

  .specsheet.ss-ddense{font-size:9.5pt;line-height:1.12}
  .specsheet.ss-ddense .ss-photo{flex-basis:17%;width:17%}
  .specsheet.ss-ddense .ss-title{font-size:15pt}
  .specsheet.ss-ddense .ss-sub{font-size:8.5pt}
  .specsheet.ss-ddense .ss-h{margin:2.6mm 0 1.2mm;padding-bottom:.8mm}
  .specsheet.ss-ddense .ss-table th,.specsheet.ss-ddense .ss-table td{padding:.5mm 1mm}
  .specsheet.ss-ddense .ss-nut th,.specsheet.ss-ddense .ss-nut td{padding:.45mm 1mm}
  .specsheet.ss-ddense .ss-kpi{padding:1.1mm 1.3mm}
  .specsheet.ss-ddense .ss-val{font-size:11.5pt}
  .specsheet.ss-ddense .ss-method li{padding:.8mm 0}
  /* THE FLOORS, last so nothing above can undercut them.
     The kitchen copy reads at 10pt whatever the tier — the cook is holding it, not the accountant — and
     its QUANTITY column is the single most-read thing on the sheet, so it is bigger still, semibold and
     tabular. Nothing anywhere goes below 8pt. */
  .specsheet.ss-kitchen{font-size:10pt}
  .specsheet.ss-kitchen .ss-table td.num{font-size:10.5pt;font-weight:600}
  .specsheet .ss-table .num{font-variant-numeric:tabular-nums}
  .specsheet .ss-lab,.specsheet .ss-note,.specsheet .ss-meta,.specsheet .ss-cap,
  .specsheet .ss-fineprint,.specsheet .ss-step-meta,.specsheet .ss-runhead,
  .specsheet .ss-runfoot{font-size:8pt !important}
}

/* ---- Batch QR label sheet (core/labels.py + core/templates/core/qr_labels.html) ---- */
.qrlabels-sheet{
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:14px;
  margin-top:6px;
}
.qrlabel-cell{
  background:var(--bg-elev);
  border:.5px solid var(--sep);
  border-radius:var(--r-card);
  box-shadow:var(--shadow);
  padding:14px;
  display:flex;
  flex-direction:column;
  align-items:center;
  text-align:center;
  /* 6px, not the 8px this carried while a label was four elements. The label now stacks up to seven
     (photo, name, subline, portions-or-batch-yield, shelf life, allergens, may-contain, QR) and at 8px
     the identity block read as a list of unrelated lines rather than one block. The `code` line that
     used to sit in here went inline into the subline when the label was realigned to the scan card's
     opening rather than the spec sheet's identity band. 2026-08-13. */
  gap:6px;
  break-inside:avoid;
  page-break-inside:avoid;
}
/* THE DISH PHOTO (chef 2026-08-13: "the photo is missimg"). object-fit:contain, never cover — the
   rule recipe_spec.html states and its reason: a printed sheet that crops the ends off the dish is
   showing the cook a different plate from the one in the photograph. A recipe with no photo emits no
   element at all (see qr_labels.html), so there is no empty frame to shift the layout; the grid
   stretches every cell in a row to the tallest of them, which is what keeps the sheet square.
   The tinted background is what a letterboxed portrait shot sits on, so the gap reads as deliberate. */
.qrlabel-photo{
  width:100%;
  max-height:96px;
  object-fit:contain;
  border-radius:8px;
  background:var(--fill);
}
.qrlabel-qr{
  background:#fff;                 /* QR needs a white quiet-zone to scan reliably */
  border-radius:10px;
  padding:8px;
  width:128px;
  height:128px;
  display:grid;
  place-items:center;
}
.qrlabel-qr svg{width:100%;height:100%}
/* THE QR PINS TO THE BOTTOM OF THE CELL. Grid cells in a row stretch to the tallest, so without this
   a short label floats its QR halfway up and a row of three reads as three different documents. */
.qrlabel-qr{margin-top:auto}
.qrlabel-name{font-weight:600;color:var(--text);line-height:1.25}
/* .qrlabel-allergens / .qrlabel-tag / .qrlabel-url were REMOVED 2026-08-13 with the four-letter
   allergen codes ("GLUT MILK CRUS") and the printed scan URL — see the note in core/labels.py where
   _short_code() used to be, and core/qrlabelsheet.py's docstring. The label now uses the three text
   roles below, which mirror the spec sheet's ss-sub / ss-meta ladder so the two documents read as the
   same family. */
.qrlabel-sub{font-size:var(--fs-caption);color:var(--text2);line-height:1.3}
.qrlabel-meta{font-size:var(--fs-caption2);color:var(--text3);line-height:1.3}
.qrlabel-alg{font-size:var(--fs-caption);color:var(--text);line-height:1.35}

/* ---- Price import (core/invoices.py): flag rows needing attention ---- */
tr.row-warn>td{background:var(--warn-bg)}

/* A4 print: drop app chrome, fit 3 columns, never split a label across pages */
@media print{
  /* hide global app chrome — match whatever the shell uses; these are defensive selectors */
  .sidebar, .topbar, .app-nav, nav, .topright, .skip, .qrlabels-noprint{display:none !important}
  body{background:#fff !important}
  /* No @page here either — one declaration, in the global print block (2026-08-08). This one asked for
     12mm and got the global block's 16mm, same as the spec sheet did. */
  .qrlabels-sheet{grid-template-columns:repeat(3,1fr);gap:8mm}
  /* PAPER IS WHITE WHATEVER THE SCREEN IS (2026-08-13). This rule already forced colour printing via
     print-color-adjust:exact, but left background and text on the THEME tokens — so a kitchen running
     the app in dark mode printed a sheet of black rectangles with grey text on them, and the labels
     were unreadable off the printer. The PDF has always drawn dark ink on white; now the browser print
     does too, so Ctrl+P and the Download button produce the same sticker. */
  .qrlabel-cell{
    box-shadow:none;
    background:#fff;
    color:#111;
    border:.4mm solid #bbb;
    -webkit-print-color-adjust:exact;
    print-color-adjust:exact;
  }
  .qrlabel-name{color:#111}
  .qrlabel-qr{padding:2mm}
  /* 26mm, the same photo box core/qrlabelsheet.py fits the print master into, so a label cut off a
     browser print and one cut off the PDF are the same physical sticker. The 400px thumb across 26mm
     is ~390dpi — past the 300dpi target core/photos.py was built around.

     background:#fff COMPLETES THE 2026-08-13 "paper is white" rule above, which this class was the one
     member of the block to miss. Its screen background is var(--fill), and print-color-adjust:exact is
     an INHERITED property — so the cell's declaration reaches this element and the theme token really
     did get printed: a kitchen in dark mode letterboxing a portrait photo printed dark grey bars onto
     a white sticker, ink for nothing. Every other rule in this block already pins a fixed colour;
     this is the last one. */
  .qrlabel-photo{max-height:26mm;background:#fff}
  .qrlabel-sub,.qrlabel-alg{color:#333}
  .qrlabel-meta{color:#555}
}

/* ---- Activation funnel widget (core/activation.py, dashboard.html) ---- */
.actv-bar{height:8px;border-radius:6px;background:var(--fill);overflow:hidden;margin:2px 0 16px}
.actv-bar-fill{display:block;height:100%;background:linear-gradient(90deg,var(--accent),#7AD98F);
  border-radius:6px;min-width:2px;transition:width .3s ease}
.actv-steps{display:flex;flex-direction:column;gap:2px}
.actv-step{display:flex;gap:11px;align-items:flex-start;padding:8px 0;border-top:.5px solid var(--sep)}
.actv-step:first-child{border-top:none}
.actv-tick{flex:0 0 22px;height:22px;border-radius:50%;display:grid;place-items:center;
  font-size:var(--fs-footnote);font-weight:700;background:var(--fill);color:var(--text3)}
.actv-step.done .actv-tick{background:var(--ok-bg);color:var(--ok-fg)}
.actv-body{flex:1;min-width:0}
.actv-label{font-weight:600;color:var(--text)}
a.actv-label{color:var(--accent-text)}
.actv-step.done .actv-label{color:var(--text3)}
.actv-hint{font-size:var(--fs-caption);color:var(--text3);margin-top:2px;line-height:1.4}
.actv-empty{border:.5px solid var(--accent);box-shadow:0 0 0 2px var(--accent-tint),var(--shadow)}

/* ---- Price intelligence (core/price_intel.py, price_intel.html + ingredient_detail.html) ---- */
/* Sparklines inherit text colour via stroke="currentColor" — theme-safe in light + OLED-dark. */
.spark{display:block;overflow:visible}
.pi-trend{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.pi-spark{display:inline-flex;align-items:center;color:var(--text2)}
.pi-spark svg{display:block}
/* Movers columns + signed % badges: up = rising cost (red, bad), down = easing cost (green, good). */
.pi-movers{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}
@media (max-width:760px){.pi-movers{grid-template-columns:1fr}}
.pi-up{color:var(--red);font-weight:600}
.pi-down{color:var(--green);font-weight:600}

/* Searchable combobox (data-combobox): a text input + a floating filtered panel; "＋ New" is sticky at
   the TOP so it stays visible while scrolling a long list. */
.cbx{position:relative}
.cbx-panel{position:absolute;z-index:50;left:0;right:0;top:100%;margin-top:4px;background:var(--bg-elev);
  border:1px solid var(--fill);border-radius:10px;box-shadow:var(--shadow-lift);max-height:260px;overflow-y:auto;padding:4px}
/* A FLEX ROW, AND THE NAME IS THE ONLY PART THAT MAY BE CUT (chef 2026-08-13). This rule used to put
   white-space:nowrap + overflow:hidden + text-overflow:ellipsis on the OPTION ITSELF, so everything in
   the row was one inline run and the ellipsis fell wherever the box ended. On a long by-product name
   that was BEFORE the trailing chip: measured live at 549px of box against 644px of content, with the
   chip's right edge at 934 against the option's 849. The chip was in the DOM the whole time, which is
   exactly why every DOM-level test passed while the chef saw nothing at all. The marker is the reason
   the row exists, so it is the one piece that must never be the part that gets clipped.

   min-width:0 ON .cbx-optname IS THE LOAD-BEARING DECLARATION, not the ellipsis. A flex item's default
   min-width is auto, which refuses to shrink below its own min-content width — that would pin the row
   open at the full name and shove the chip back out of the box, the same clipping reached by a
   different route. Everything that is NOT the name is flex:none, so it holds its size while the name
   gives way.

   THE NAME HAS TO BE AN ELEMENT, in both builders: the static branch in _combobox.html and the rows
   app.js builds for a remote picker (including the "＋ Add" row). A bare text node in a flex container
   becomes an ANONYMOUS flex item, and an anonymous item cannot be styled at all — no min-width:0, no
   ellipsis — so a text node here would be unshrinkable by construction.

   Nothing escapes the panel: .cbx-panel is overflow-y:auto, which computes overflow-x to auto too, so
   an over-wide row scrolls the panel and never the page. */
.cbx-opt{display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:var(--r-sm);
  cursor:pointer;font-size:14px;overflow:hidden}
.cbx-optname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cbx-opt>:not(.cbx-optname){flex:none}
.cbx-opt:hover,.cbx-opt.cb-hi{background:var(--fill)}
.cbx-add{color:var(--accent-text);font-weight:600;position:sticky;top:0;background:var(--bg-elev);border-bottom:1px solid var(--fill);border-radius:var(--r-sm) var(--r-sm) 0 0}
.cbx-add:hover,.cbx-add.cb-hi{background:var(--accent-tint)}
/* Touch: ≥44px option rows + the panel becomes a bottom sheet on coarse pointers so options sit above
   the on-screen keyboard (chef on a phone/tablet, wet hands). (UI spec: touch-first) */
@media (pointer: coarse) {
  .cbx-opt{min-height:44px;font-size:16px}
  .cbx-panel:not([hidden]){position:fixed;left:0;right:0;bottom:0;top:auto;margin:0;max-height:55vh;
    border-radius:16px 16px 0 0;padding:8px 8px calc(8px + env(safe-area-inset-bottom))}
}

/* ---- States: empty + loading (Superplan Phase 3 primitives, built on the Phase-0 tokens). ADDITIVE:
   no existing markup uses these yet — templates adopt them to replace blank pages and bare spinners. ---- */
/* Empty state — centred + generous (replaces left-aligned text panels). Compose:
   <div class="empty"><div class="empty-ic">🍳</div><h3 class="empty-h">No recipes yet</h3>
     <p class="empty-sub">Add your first dish to start costing.</p>
     <a class="btn primary" href="…">＋ New recipe</a></div> */
.empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--sp-2);
  padding:var(--sp-10) var(--sp-5);max-width:440px;margin:0 auto}
.empty-ic{font-size:46px;line-height:1;margin-bottom:var(--sp-1)}
.empty-h{font-size:var(--fs-title3);font-weight:700;letter-spacing:-.02em;color:var(--text);margin:0}
.empty-sub{font-size:var(--fs-subhead);color:var(--text2);margin:0;line-height:1.5}
.empty .btn{margin-top:var(--sp-3)}
/* Skeleton loading — a shimmer of the eventual layout; prefer over a spinner. The global
   prefers-reduced-motion block below stills the shimmer for motion-sensitive users. */
.skeleton{position:relative;overflow:hidden;background:var(--fill);border-radius:var(--r-sm)}
.skeleton::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
  background:linear-gradient(90deg,transparent,var(--fill-2),transparent);
  animation:skel 1.3s var(--ease) infinite}
@keyframes skel{100%{transform:translateX(100%)}}
.skeleton.line{height:14px;margin:var(--sp-2) 0} .skeleton.line.short{width:55%}
.skeleton.card{height:88px;border-radius:var(--r-card)}

/* ---- Segmented control (.seg) + food-cost pill (.fcpill) — promoted from recipes_list.html's per-page
   <style> into the shared system (frontend-code audit P2). .seg is the canonical Apple segmented control;
   .fcpill is the dish food-cost status chip. Rules verbatim (font sizes tokenised to the ramp). ---- */
.seg{display:inline-flex;gap:2px;background:var(--fill);border-radius:var(--r-md);padding:var(--sp-1);flex-wrap:wrap}
.seg button{border:0;background:transparent;color:var(--text2);font-size:var(--fs-footnote);font-weight:600;
  border-radius:8px;padding:6px 12px;transition:background .12s,color .12s}
.seg button[aria-pressed="true"]{background:var(--bg-elev);color:var(--accent-text);box-shadow:var(--shadow)}
.fcpill{font-size:var(--fs-caption2);font-weight:700;border-radius:var(--r-sm);padding:3px 8px;display:inline-block;white-space:nowrap}
.fcpill.good{background:var(--ok-bg);color:var(--ok-fg)}
.fcpill.near{background:var(--warn-bg);color:var(--warn-fg)}
.fcpill.over{background:var(--al-bg);color:var(--al-fg)}

/* ---- .rtabs — the LINK flavour of .seg: a segmented control whose segments are <a>s, because each one
   is a server-side scope (?type=, ?program=) and must be a real, shareable, back-button-able URL rather
   than a button that mutates the page. .seg's own rules only style `button`, so the two compose:
   `<div class="seg rtabs">` gets the trough from .seg and the raised active segment from here.

   Promoted out of recipes_list.html's per-page <style> (Staff Food Program, stage 2) the moment the
   kitchen list grew the same control — the same reason .seg, .bulkbar and .modtile were promoted before
   it. A COPIED widget always drifts, and this one now carries a privacy scope, so a drift between the
   two copies would be a drift in how visibly the staff/menu split is presented. Rules verbatim. ---- */
.rtabs-wrap{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:6px 0 18px}
.rtabs-lab{font-size:11.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--text3)}
.rtabs{display:inline-flex;gap:4px;background:var(--fill);border:1px solid var(--sep);border-radius:12px;padding:4px}
.rtabs a{display:inline-flex;align-items:center;gap:7px;color:var(--text2);font-size:15px;font-weight:600;
  border-radius:9px;padding:9px 18px;text-decoration:none;transition:background .14s,color .14s,box-shadow .14s}
.rtabs a:hover{color:var(--text)}
.rtabs a.active{background:var(--accent);color:#fff;box-shadow:0 1px 4px rgba(0,0,0,.22)}
/* THE STRIP IS NOW A PHONE CONTROL, so it takes the app's 44px floor (2026-08-14, phase 3). Four
   sidebar rows were demoted to tabs on their host pages, which makes a segment the ONLY way a phone
   reaches Period usage, Leftovers and QR labels — and Period usage was the one destination in the app
   a phone could not open at all until this morning. MEASURED in headed Chrome at 375x812 with touch
   emulation on (the media query does not match a merely narrow desktop window): a segment came out at
   39.75px, under the floor every other tap target in this file carries. min-height rather than more
   padding, so the desktop control does not change size at all — at 1280 with a mouse the segments
   measure 39.75px and this rule does not apply, which is correct and was checked.

   THIS LINE IS THE WHOLE OF THE 44px, and it is the only rule in this block that is. Deleting it is
   the one mutation that moves a segment's height: 44px -> 39.75px on all three strips.

   A SECOND RULE WAS DELETED HERE (2026-08-14, verify pass), and this is what it was:
   `@media (max-width:760px){.rtabs{flex-wrap:wrap}}`. Wrapping is real and it matters — but that rule
   never provided any of it. The markup is `<nav class="seg rtabs">` and `.seg` twenty lines up already
   declares flex-wrap:wrap at EVERY width, which .rtabs does not override; the deleted rule covered
   max-width:760 only, a strict subset, so it could not be the thing wrapping anything anywhere.
   Measured in headed Chrome, not reasoned about — four builds of app.css, /waste/ with the noun
   renamed so the strip overflows:
       both rules         2 rows, segments 44px, nothing past the right edge
       media rule gone    IDENTICAL, to the pixel
       .seg's wrap gone   IDENTICAL, to the pixel   (each alone suffices at 375px)
       BOTH gone          1 row, both segments 83.25px, labels broken across three lines
   So the pair was one mechanism behind two switches, and removing either one changed nothing. That is
   why the rule survived three reviews: it looked load-bearing and could never be caught.

   WHAT ACTUALLY KEEPS A SEGMENT ON SCREEN, then. Nothing does — nothing has to. `.rtabs` is one flex
   item inside .rtabs-wrap, so it SHRINKS to the content box rather than overflowing it; across every
   variant above, at 375 and at 1280, no segment ever crossed the right edge. The failure mode is not a
   tab off the edge, it is two tabs CRUSHED onto one line with their words split. Content box 343.33px
   at 375; the default English strips are 306.57 (Stock), 262.76 (Waste) and 241.28 (Labels), so the
   tightest has 36.76px of headroom and never wraps at all. core/glossary.py allows a 60-character
   noun, and that is when wrap starts working: "Spoilage & wastage" puts the Waste strip at exactly
   343.33px and moves the second segment to its own row at a full 44px.

   Wrap therefore has exactly ONE owner now, `.seg`, and it is the right one — it covers every width,
   including the phone band the deleted rule covered twice. It is pinned by
   core/tests/test_nav_demotions.py, which can now fail for a reason: with the redundant switch gone,
   deleting .seg's flex-wrap is the 83.25px collapse above. */
@media (pointer:coarse){.rtabs a{min-height:44px}}

/* ---- Bulk selection: a tick-box column + an action bar that appears once a row is ticked. Promoted
   out of recipes_list.html's per-page <style> (2026-07-27) when the Market list asked for the same
   thing ("implement the tick boxes as we did in the recipes. Bulk delete"). A COPIED widget always
   drifts — see the warning at the top of _allergen_badge.html — so there is exactly one set of rules
   and one driver (app.js "BULK SELECTION"). Only the ACTION buttons stay page-local, because each list
   bulk-does something different. Rules verbatim from the recipes page, including the coarse-pointer
   enlargement (a 13px checkbox is not a thumb target on the pass). ---- */
.bulkcol{width:34px;text-align:center;padding-left:8px;padding-right:0}
.bulkcol input{width:auto;cursor:pointer;margin:0}
.bulkbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 14px;padding:10px 14px;
  background:var(--accent-tint);border:1px solid var(--accent);border-radius:12px}
.bulkbar-count{font-weight:600}
.bulkbar-hint{margin-left:auto}
@media (pointer:coarse){.bulkcol input{width:22px;height:22px}}
/* The bar is revealed by JS and the ticks are only meaningful to JS, so without it the column is a row
   of controls that do nothing. .cl-js is set in <head> (base.html), so this hides before first paint —
   the same gate .plan-add-js uses. The no-JS baseline keeps the per-row Delete, which needs no script. */
html:not(.cl-js) .bulkcol{display:none}

/* ---- DIET TAG GROUPS (chef 2026-08-01: "braistorm to improce the design of Diet tags"). The three
   kinds of tag are not equally trustworthy — the app can DISPROVE the allergen ones, CHECK the nutrition
   ones against EU 1924/2006, and only take the declared ones on trust. That was already the design idea
   and it was carried by a badge on two of seven otherwise identical rows. Grouping makes it structural,
   so the trust level is read before the tag rather than after it. ---- */
.dietgrp + .dietgrp{margin-top:14px;padding-top:14px;border-top:1px solid var(--sep)}
.dietgrp-h{font-size:var(--fs-caption);font-weight:600;color:var(--text2);margin-bottom:8px}

/* ---- LABELS COLUMN (chef 2026-08-01: "We would need the labels icons in a separate coulmn and choose
   the better icon. Column named 'Labels'"). Defined HERE, beside .bulkcol, because both are shared
   list mechanics used by the Market list and the recipes list — test_ingredient_bulk_delete.py forbids
   page-local copies of exactly this kind of thing, and two copies is where they drift.

   THE CELL CARRIES NO WORDS, and that is load-bearing rather than aesthetic: the shared list filter
   matches a row by its whole textContent, so a cell reading "On the label list" would make every row
   match a search for "label". The state lives in aria-pressed (which the CSS reads) and in `title`
   (which is the button's accessible name). ---- */
.labelcol{width:52px;text-align:center;padding-left:4px;padding-right:4px}
.labelmark{border:0;background:none;cursor:pointer;padding:4px 6px;border-radius:var(--r-sm);
  font-size:var(--fs-body);line-height:1;white-space:nowrap;
  transition:background var(--dur-fast) var(--ease)}
.labelmark:hover,.labelmark:focus-visible{background:var(--accent-tint)}
/* VISIBILITY, not display: the ✓ keeps its box either way, so a 60px column does not jump a pixel as the
   chef ticks his way down it. An unmarked row is legible as "off" from the dimmed tag alone. */
.labelmark .lm-tick{visibility:hidden;color:var(--accent-text);font-weight:700;margin-left:1px}
.labelmark[aria-pressed="true"] .lm-tick{visibility:visible}
.labelmark[aria-pressed="false"] .lm-tag{opacity:.45;filter:grayscale(1)}
@media(pointer:coarse){.labelmark{min-height:44px;min-width:44px}}

/* ---- Disclosure (progressive disclosure: collapse a secondary panel so the primary content leads —
   e.g. recipe settings tucked above the costing). Styled <summary>; the ▸ rotates open. No JS. ---- */
.disclose > summary{display:flex;align-items:center;gap:8px;width:max-content;cursor:pointer;
  list-style:none;user-select:none;font-size:var(--fs-footnote);font-weight:600;text-transform:uppercase;
  letter-spacing:.5px;color:var(--text2);margin:24px 4px 10px}
.disclose > summary::-webkit-details-marker{display:none}
.disclose > summary::before{content:"▸";font-size:.85em;color:var(--text3);
  transition:transform var(--dur-fast) var(--ease)}
.disclose[open] > summary::before{transform:rotate(90deg)}

/* ---- Ingredient detail: Supplier prices as the page hero (chef UI feedback 2026-07-11 — uniform
   boxes were hard to orient; this is the table to focus on). Titled header (name + "cost last updated")
   over an elevated table card; the Add form is folded into the card as an accent "+" disclosure so it
   reads as "add another supplier here" rather than a separate box. ---- */
.ing-suppliers-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px 16px;flex-wrap:wrap;margin:28px 4px 8px}
.ing-suppliers-head h2{font-size:var(--fs-title3);font-weight:700;margin:0}
.ing-suppliers-head .upd{font-size:var(--fs-footnote);color:var(--text2);white-space:nowrap}
.perunit{font-size:var(--fs-caption);margin-top:2px;font-weight:400}   /* muted per-unit rate under the pack price */
/* Inline-edit title (.inedit): the whole title is ONE focusable button; a quiet ✎ + dotted underline
   signal editability on hover AND focus (never hover-only — keyboard/touch reveal via :focus-visible).
   Editing swaps to a heading-sized input; Enter or click-away saves, Esc reverts — no Save button
   (chef feedback 2026-07-12: the old pencil + Save form read as clunky). (UI spec: inline rename) */
.ie-title{background:none;border:0;margin:0 -4px;padding:2px 4px;font:inherit;color:inherit;cursor:text;
  display:inline-flex;align-items:baseline;gap:6px;border-radius:8px;text-align:left;max-width:100%}
/* Give the title the row's free width so a short name isn't squeezed; wrap long names at spaces rather
   than collapsing to "B…" (chef 2026-07-18: "shows B... instead of Beer / splits into 2 rows"). The old
   overflow:hidden made the flex item's min-content 0, letting the layout crush it to one char. */
.ph .inedit{flex:1 1 auto;min-width:0}
.ie-title{flex-wrap:wrap}
.ie-title .ie-val{white-space:normal;overflow-wrap:break-word;min-width:0}
.ie-title .pen{opacity:0;font-size:.6em;flex:0 0 auto;transition:opacity var(--dur-fast) var(--ease)}
.ie-title:hover .pen,.ie-title:focus-visible .pen{opacity:.55}
.ie-title:hover .ie-val,.ie-title:focus-visible .ie-val{text-decoration:underline dotted;text-underline-offset:4px;text-decoration-color:var(--text3)}
@media (pointer:coarse){.ie-title{min-height:44px;align-items:center}.ie-title .pen{opacity:.5}}
/* The rename input reads as the heading itself — same size/weight, only a calm accent underline marks it
   as editable — so there's no jarring "form" look or layout jump. */
.ie-form{display:inline-flex;gap:10px;align-items:baseline} .ie-form[hidden]{display:none}
.ie-input{font-size:var(--fs-title2);font-weight:700;line-height:1.1;border:0;border-bottom:2px solid var(--accent);
  background:transparent;color:var(--text);padding:1px 2px;min-width:160px;max-width:min(520px,80vw)}
.ie-input:focus{outline:none}
.ie-hint{font-size:var(--fs-caption2);color:var(--text3);align-self:center}
@media (max-width:560px){.ie-hint{display:none}}
/* Per-supplier price-history expander (chef feedback #4): a full-width sub-row under the supplier row. */
.subrow > td{background:var(--fill);padding:0}
.subrow-in{padding:10px 14px 14px}
/* recipe_detail: per-line cost detail folded under the › toggle in the merged Ingredients table (audit UX) */
.cost-detail{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:var(--fs-footnote)}
.detailtog{padding:4px 9px;transition:transform var(--dur-fast) var(--ease)}
.detailtog[aria-expanded="true"]{transform:rotate(90deg)}
.histtable{width:100%;font-size:var(--fs-footnote)}
.histbtn{vertical-align:baseline;margin-left:2px}
@media (pointer:coarse){.histbtn{min-height:44px}}
/* Reorder-supplier arrows (chef feedback #5): keyboard-native, touch-sized, disabled at the ends. */
.moveorder{display:inline-flex;gap:1px;margin-right:4px;vertical-align:middle}
.mvbtn{padding:5px 7px;line-height:1;font-size:11px}
.mvbtn[disabled]{opacity:.3;cursor:default}
@media (pointer:coarse){.mvbtn{min-width:44px;min-height:44px;font-size:14px}}
/* Add-to-Order dialog (native <dialog>): centered card on desktop, bottom sheet on phones. (UI spec) */
.ao-dialog{border:0;border-radius:16px;padding:0;max-width:420px;width:calc(100% - 32px);background:var(--bg-elev);color:var(--text);box-shadow:var(--shadow-lift)}
/* One backdrop for every modal in the app (the event page's guest allergen sheet joins it here rather
   than repeating the literal — the scrim is a shared surface, not a per-feature choice). */
.ao-dialog::backdrop,.gal-dialog::backdrop{background:rgba(0,0,0,.4)}
.ao-body{padding:20px}
/* CONFIRM DIALOG — the destructive-action variant of .ao-dialog (2026-08-21 design audit).
   Every data-confirm used to go through window.confirm(), whose affirmative button is always "OK": the
   browser owns that label and the app cannot change it, so "empty the Trash permanently" and "leave this
   page" offered the identical choice. The verb now names the act, which is the same argument app.css:737
   already makes for destructive BUTTONS — window.confirm was simply the one place it could not reach.
   The danger styling is deliberately on the VERB only: a red panel shouts before the chef has read
   anything, while a red button shouts at the exact moment of decision. */
.confirm-dialog .ao-body{padding:22px}
.confirm-dialog .cf-title{font-size:var(--fs-title3);font-weight:700;margin:0 0 8px}
.confirm-dialog .cf-body{margin:0 0 18px;color:var(--text2);font-size:var(--fs-subhead);line-height:1.45}
.confirm-dialog .cf-acts{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}
.confirm-dialog .cf-go{background:var(--red);color:#fff;border:0}
.confirm-dialog .cf-go:hover{filter:brightness(1.08)}
/* Phone: the actions stack and the destructive verb goes LAST, so the thumb resting position is Cancel. */
@media(max-width:560px){
  .confirm-dialog .cf-acts{flex-direction:column-reverse}
  .confirm-dialog .cf-acts .btn{width:100%;justify-content:center}
}
.ao-name{font-size:var(--fs-title3);font-weight:700;margin:0 0 14px}
.ao-field{margin-bottom:14px}
.ao-lab{display:block;font-size:var(--fs-footnote);font-weight:600;color:var(--text2);margin-bottom:6px}
.ao-sup{display:flex;flex-direction:column;gap:6px}
.ao-radio{display:flex;align-items:center;gap:8px;padding:9px 11px;border:.5px solid var(--sep);border-radius:10px;cursor:pointer;font-size:var(--fs-subhead)}
/* stretch so the − / + buttons are exactly the input's height (were floating, mis-aligned); grid-place
   centres the glyph in the box regardless of the −/＋ glyph metrics. */
.ao-qty{display:flex;align-items:stretch;gap:8px}
.ao-qty input{flex:1;text-align:center;font-size:var(--fs-title3);font-weight:600;min-height:48px}
.ao-step{min-width:52px;min-height:48px;font-size:22px;line-height:1;padding:0;display:grid;place-items:center}
/* Per-unit price on a supplier option — the comparable figure across different pack sizes (accent). */
.ao-perunit{font-weight:600;color:var(--accent-text);white-space:nowrap}
.ao-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
/* Live order total in the ＋ Order dialog — qty × pack price, so the spend is clear before adding. */
.ao-total{display:flex;justify-content:space-between;align-items:baseline;margin:14px 0 2px;padding-top:12px;
  border-top:1px solid var(--sep);font-size:var(--fs-callout);font-weight:600}
.ao-total b{font-variant-numeric:tabular-nums;font-size:var(--fs-title3);color:var(--accent-text)}
.ao-total[hidden]{display:none}
.toast-act{margin-left:10px;font-weight:600;white-space:nowrap;text-decoration:underline;
  background:none;border:0;padding:0;font-size:inherit;color:var(--accent-text);cursor:pointer}
@media (max-width:560px){
  .ao-dialog{position:fixed;inset:auto 0 0 0;margin:0;width:100%;max-width:100%;border-radius:16px 16px 0 0}
}

/* ---- Round 5 (chef feedback 2026-07-12): confirm-to-save inline edits, +Order accent + in-cart rows,
   order-preview popup, category pill ---- */
/* Tap-to-edit-and-confirm: the read-only value (.tapval mirrors .price) opens an editor with explicit
   ✓ / ✕ so a price/pack change is deliberate, not an accidental blur-commit. */
.tapval{display:inline-flex;gap:6px;align-items:center;border-radius:8px;padding:4px 8px;cursor:text;transition:background .15s}
.tapval:hover{background:var(--accent-tint)}
.tapval .pen{opacity:.4;font-size:var(--fs-caption)}
.tapval:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:8px}
.uomedit{display:inline-flex;gap:5px;align-items:center;flex-wrap:wrap}
.uomedit input{width:66px;text-align:right} .uomedit select{width:auto;padding:6px 8px}
.tapedit[hidden]{display:none}                                   /* beats .priceedit/.uomedit inline-flex */
.tapok,.tapx{border:0;border-radius:var(--r-sm);width:28px;height:28px;line-height:1;font-size:13px;
  display:inline-grid;place-items:center;padding:0;flex:0 0 auto}
.tapok{background:var(--accent);color:#fff} .tapx{background:var(--fill);color:var(--text2)}
.tapx:hover{background:var(--fill-2)}
@media (pointer:coarse){.tapok,.tapx{width:40px;height:40px;font-size:16px}}
/* ＋ Order — a distinct BLUE action so it reads apart from the green primary + grey ghost buttons. */
.btn.order{background:rgba(0,122,255,.16);color:var(--blue)}
.btn.order:hover{background:rgba(0,122,255,.26);filter:none}
/* Rows already on a draft order: a clear blue tint + accent edge (chef feedback: the old .06 alpha was
   barely visible — boosted in BOTH themes, dark overrides below). */
tbody tr.in-order>td{background:rgba(0,122,255,.13)}
tbody tr.in-order:hover>td{background:rgba(0,122,255,.19)}
tbody tr.in-order td:first-child{box-shadow:inset 4px 0 0 var(--blue)}
:root[data-theme="dark"] .btn.order{background:rgba(10,132,255,.22)}
:root[data-theme="dark"] .btn.order:hover{background:rgba(10,132,255,.34)}
:root[data-theme="dark"] tbody tr.in-order>td{background:rgba(10,132,255,.24)}
:root[data-theme="dark"] tbody tr.in-order:hover>td{background:rgba(10,132,255,.34)}
.inorder-chip{cursor:pointer;white-space:nowrap}
.is-hidden{display:none}
/* order-action swap: NOT on order -> ＋Order; ON order -> ✎ Edit order + ✕ Remove (driven by tr.in-order).
   Hide rules are scoped `tbody .order-*` (0,1,1) so they beat the later `.d-inline`/`.btn` utilities (0,1,0). */
tbody .order-edit,tbody .order-remove{display:none}
tbody tr.in-order .order-add{display:none}
tbody tr.in-order .order-edit{display:inline-flex}
tbody tr.in-order .order-remove{display:inline}
@media (max-width:600px){.respcards tr.in-order{border-color:var(--blue);border-left-width:4px}}
/* Order-preview popup (reuses .ao-dialog): supplier + date + line list, checked without leaving the list. */
.op-dialog{max-width:480px}
.op-meta{margin:-6px 0 12px;font-size:var(--fs-footnote)}
.op-lines{max-height:46vh;overflow-y:auto;margin:0 -2px}
.op-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
/* Editable order line: product | qty input + unit | line total | remove. Wraps the note under it. */
.op-list li{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 2px;border-bottom:.5px solid var(--hair)}
.op-prod{flex:1 1 40%;font-weight:500;min-width:120px} .op-note{font-weight:400}
/* Per-item note in the order-preview drawer (chef 2026-08-12). Its own full-width row BELOW the qty and
   total, because the li is a flex row that already carries four things and a fifth would push the price
   off a phone. Borderless until touched, like the cart's. .op-qtynote is the machine's pack hint. */
.op-noterow{flex:1 1 100%;order:9}
.op-notein{width:100%;font-size:var(--fs-caption);color:var(--text);border:.5px solid transparent;
  background:transparent;border-radius:8px;padding:4px 7px;min-height:30px}
.op-notein::placeholder{color:var(--text3)}
.op-notein:hover{border-color:var(--sep)}
.op-notein:focus{border-color:var(--accent);background:var(--bg-elev)}
.op-qtynote{font-weight:400;font-style:italic}
.op-qtywrap{display:inline-flex;align-items:center;gap:5px;color:var(--text2);white-space:nowrap}
.op-qtyin{width:64px;text-align:right;padding:5px 7px} .op-qtyv{font-variant-numeric:tabular-nums}
.op-linetot{min-width:76px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
.op-rm{flex:0 0 auto;border:0;border-radius:var(--r-sm);width:28px;height:28px;line-height:1;font-size:13px;
  display:inline-grid;place-items:center;padding:0;background:var(--fill);color:var(--text2)}
.op-rm:hover{background:var(--al-bg,var(--warn-bg));color:var(--al-fg,var(--warn-fg))}
@media (pointer:coarse){.op-rm{width:40px;height:40px;font-size:15px}.op-qtyin{width:72px}}
.op-total{display:flex;justify-content:space-between;align-items:baseline;margin:12px 2px 2px;
  padding-top:10px;border-top:1px solid var(--sep);font-size:var(--fs-callout)}
.op-total b{font-variant-numeric:tabular-nums;font-size:var(--fs-subhead)}
.op-total[hidden]{display:none}
/* Category pill + popover (ingredient detail): one clean control that opens the searchable picker. */
/* Category — a MAJOR product attribute, so make it prominent (chef 2026-07-18: "hardly to notice"):
   an uppercase label + a sizeable pill, accent-tinted when set, amber+dashed when still uncategorised
   (reads as a to-do that draws the eye). */
.catrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:8px 0 18px;position:relative}
.catrow-lab{font-size:11.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text3)}
.catpill{display:inline-flex;align-items:center;gap:8px;background:var(--accent-tint);color:var(--accent-text);
  border:1px solid transparent;border-radius:10px;padding:8px 15px;font-size:14.5px;font-weight:700;
  transition:filter .15s,background .15s;cursor:pointer}
.catpill:hover{filter:brightness(1.06)}
.catpill.uncat{background:var(--al-bg);color:var(--al-fg);border-style:dashed;border-color:currentColor}
.catpill .caret{font-size:.72em;opacity:.8}
.catpop{position:absolute;z-index:60;top:calc(100% + 6px);left:0;min-width:280px;background:var(--bg-elev);
  border:.5px solid var(--sep);border-radius:14px;box-shadow:var(--shadow-lift);padding:12px}
.catpop[hidden]{display:none}
.catform{display:flex;gap:8px;align-items:flex-start}
@media (max-width:560px){.catpop{left:0;right:0;min-width:0}}
.panel.hero{box-shadow:var(--shadow-lift)}
/* .disclose.addrow (the accent "＋ Add a supplier price" summary) was removed 2026-07-28: its only user,
   ingredient_detail.html, no longer folds that form behind a disclosure — the chef wants to add a
   supplier price the way he adds a product on the Market List, i.e. with the form always visible. */

/* ---- Dashboard module tiles (.modgrid + .modtile) — promoted from dashboard.html's per-page <style>
   into the shared system (frontend-code P2). Font sizes tokenised to the ramp (13.5px->footnote). ---- */
.modgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.modtile{display:flex;align-items:center;gap:10px;background:var(--bg-elev);border:.5px solid var(--sep);
  border-radius:14px;padding:13px 14px;box-shadow:var(--shadow);font-weight:600;font-size:var(--fs-footnote);
  transition:transform .15s,box-shadow .15s}
.modtile:hover{transform:translateY(-2px);box-shadow:var(--shadow-lift)}
.modtile .mic{font-size:var(--fs-title3)}

/* ---- Reduced motion (Superplan Phase 0 — a11y + polish, flagged by ui-visual & a11y audits). Honour
   the OS "Reduce Motion" setting: near-zero every transition/animation and neutralise the card lift +
   toast slide, so motion-sensitive chefs aren't hit by movement. Placed last so it overrides all rules. ---- */
@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{
    animation-duration:.001ms !important; animation-iteration-count:1 !important;
    transition-duration:.001ms !important; scroll-behavior:auto !important;
  }
  a.rcard:hover{transform:none}
}

/* ============================================================================================
   UTILITY CLASSES — the CSP inline-style removal toolkit (audit 9.1). Goal: eliminate every inline
   style="" attribute (599 across 51 templates) so style-src can drop 'unsafe-inline'. These cover the
   dominant inline patterns; convert templates to these, then flip the CSP directive (see
   docs/CSP-INLINE-STYLE-REMOVAL.md). Kept small + composable; add sparingly as conversions need them.
   ============================================================================================ */
/* margin / spacing */
/* THE INDEX NOW MEANS THE SAME THING IN BOTH FAMILIES (2026-08-21 design audit). `.mt-N` was a
   private ladder — 8/12/14/16 — while `.pt-N` referenced the real tokens, so `.mt-3` was 14px and
   `.pt-3` was 12px: the same number on the same page meaning two different distances. 14px is not
   even on the 4pt grid this file's own spacing section declares, so nothing in the app could line
   up with it. Both families now resolve to --sp-N, which is what a reader assumes the digit means.
   Net effect: 139 placements tighten by 2-4px each. Verify in the visual pass. */
.m-0{margin:0} .mt-0{margin-top:0} .mt-1{margin-top:var(--sp-1)} .mt-2{margin-top:var(--sp-2)} .mt-3{margin-top:var(--sp-3)} .mt-4{margin-top:var(--sp-4)}
.mb-1{margin-bottom:8px} .mb-2{margin-bottom:12px} .mb-3{margin-bottom:14px}
/* flex item + row */
.flex-0{flex:0} .flex-1{flex:1} .self-end{align-self:flex-end} .items-end{align-items:flex-end}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.row-8{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* display */
.d-none{display:none} .d-inline{display:inline} .d-inline-block{display:inline-block}
/* sizing */
.w-auto{width:auto} .w-80{width:80px} .w-full{width:100%}
.mw-640{max-width:640px} .mw-760{max-width:760px}
.minw-90{min-width:90px} .minw-110{min-width:110px} .minw-120{min-width:120px} .minw-130{min-width:130px}
.minw-150{min-width:150px} .minw-160{min-width:160px} .minw-200{min-width:200px} .minw-230{min-width:230px} .minw-260{min-width:260px}
/* text */
.nowrap{white-space:nowrap} .fs-12{font-size:12px} .fs-13{font-size:13px} .fw-400{font-weight:400} .fw-600{font-weight:600}
/* CSP component classes (audit 9.1) — semantic styles that were inline; not one-off utilities. */
.mb-4{margin-bottom:18px}
.hint.warn{background:var(--warn-bg);border-color:transparent}
.hint.ok{background:var(--ok-bg);border-color:transparent;color:var(--ok-fg)}
.glance{display:flex;flex-wrap:wrap;gap:8px 20px;align-items:center}      /* dashboard "At a glance" panel row */
.sw-select{width:auto;padding:6px 26px 6px 10px}                          /* org/site switcher <select> */
.botmore-form{margin:0 8px 12px}                                          /* switcher form in the mobile More sheet */
.botmore-pills{display:flex;flex-wrap:wrap;gap:8px;margin:0 8px 12px}     /* trial/stale-price pills row in the More sheet */
nav.side .foot a{color:inherit}   /* footer links inherit the muted foot colour; must out-specify nav.side a (0,1,2) */
/* Compositional flex / gap / align + a few sizing utilities (added for recipe_detail.html; reused by the
   remaining templates). Compose e.g. class="flex gap-12 items-center". */
.flex{display:flex} .fwrap{flex-wrap:wrap}
.gap-4{gap:4px} .gap-6{gap:6px} .gap-8{gap:8px} .gap-10{gap:10px} .gap-12{gap:12px} .gap-14{gap:14px} .gap-18{gap:18px}
.items-center{align-items:center} .items-start{align-items:flex-start}
.justify-end{justify-content:flex-end} .justify-between{justify-content:space-between}
.grow-full{flex:1 1 100%} .grow-200{flex:1 1 200px} .flex-2{flex:2}
.minw-100{min-width:100px} .minw-140{min-width:140px} .minw-170{min-width:170px} .minw-180{min-width:180px} .minw-210{min-width:210px} .minw-220{min-width:220px} .minw-320{min-width:320px}
/* Fixed-width form fields: the `input.` companion reaches specificity (0,1,1) so it beats the base control
   rule `input[type=number]{width:100%}` (0,1,1) by source order — a plain .w-NN (0,1,0) would LOSE to it.
   The plain selector still serves non-input uses. */
.w-64,input.w-64{width:64px} .w-66,input.w-66{width:66px} .w-84,input.w-84{width:84px}
.w-88,input.w-88{width:88px} .w-90,input.w-90{width:90px} .w-120,input.w-120{width:120px}
.tar{text-align:right} .mw-300{max-width:300px}
.ml-1{margin-left:8px} .valign-mid{vertical-align:middle} .no-marker{list-style:none} .pointer{cursor:pointer}
.pb-9{padding-bottom:9px} .fs-14{font-size:14px} .fw-500{font-weight:500}
.pt-3{padding-top:var(--sp-3)}   /* was used in recipe_detail.html but never defined -> divider had no padding */
/* recipe_detail.html semantic components (audit 9.1) — named things, not one-off utilities. */
.hint.alert{background:var(--al-bg);border-color:transparent}
.hint.alert-fg{background:var(--al-bg);border-color:transparent;color:var(--al-fg)}
.chip.fill{background:var(--fill);font-weight:400}                        /* line-note / source chip */
.badge.prep{background:var(--fill);color:var(--text2)}                    /* sub-recipe "prep" status badge */
.card-foot{padding:14px 16px;border-top:.5px solid var(--sep)}           /* add-ingredient footer inside a card */
.hair-top{border-top:.5px solid var(--hair);padding-top:12px}            /* hairline separator above a block (compose with .mt-*) */
.qr-swatch{background:#fff;border-radius:12px;padding:10px;width:140px;height:140px}  /* QR stays on white so it scans, even in dark mode */
.grid-fill-250{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:8px}  /* allergen checkbox grid */
.hist-row{font-size:12.5px;padding:3px 0;display:flex;gap:8px;align-items:center}  /* allergen change-history row */
.stepnum{flex:0 0 26px;height:26px;border-radius:50%;background:var(--accent-tint);color:var(--accent-text);display:grid;place-items:center;font-weight:700;font-size:13px}
.step-block{padding:10px 0} .step-sep{border-top:.5px solid var(--hair)} /* method step wrapper (+ hairline between steps) */
.step-meta{margin-top:4px} .step-edit{margin:6px 0 0 38px}               /* chips under a step; edit disclosure indented past the number */
.step-instr{white-space:pre-wrap;line-height:1.5}                        /* preserve the chef's line breaks in the instruction */
/* recipe method step editor — a large, expandable instruction box (chef feedback) */
.step-textarea{width:100%;min-height:66px;resize:vertical;padding:8px 10px;border-radius:10px;
  border:.5px solid var(--sep);background:var(--bg-elev);color:var(--text);font-family:inherit;font-size:var(--fs-footnote);line-height:1.5}
/* recipe Ingredients: the costing detail as tinted columns in the table, toggled by "Hide costing" */
.ingtbl .cost-col{background:var(--cost-tint)}
#ingtable.costing-hidden .cost-col{display:none}
/* ingredients_list.html (market list) semantic components (audit 9.1). */
.cat-manage{margin-top:12px;background:var(--fill);max-width:460px}      /* nested "Manage categories" panel (toggled via [hidden]) */
.chip.warn{background:var(--warn-bg);color:var(--warn-fg)}               /* "Unverified yields ✕" active-filter chip */
/* org_settings.html semantic components (audit 9.1). */
.logo-preview{max-height:64px;max-width:200px;border-radius:10px;background:#fff;padding:6px}  /* logo on white so it reads in dark mode */
.check-note{margin:6px 0 0 26px}                                        /* helper note indented under a checkbox+label */
.sech.compact{margin:2px 4px 8px}                                       /* tight section header inside a nested subrow (ingredient price history) */
/* waste.html — moved out of the page's inline <style> block so style-src can drop 'unsafe-inline' (audit 9.1). */
.spread-row{display:flex;justify-content:space-between;gap:10px;margin:7px 0;font-size:14px}  /* label -> value row (Top items) */
.wchips{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0}                /* reason chips = radio pills, one-tap capture at the bin */
.wchip{position:relative;cursor:pointer}
.wchip input{position:absolute;opacity:0;width:0;height:0}
.wchip span{display:inline-block;padding:7px 12px;border:.5px solid var(--sep);border-radius:999px;
  background:var(--bg-elev);font-size:13px;white-space:nowrap;transition:background .12s,border-color .12s}
.wchip input:checked+span{background:var(--accent-tint);border-color:var(--accent);color:var(--accent-text)}
.wchip input:focus-visible+span{outline:2px solid var(--focus);outline-offset:2px}
.wbars{display:flex;align-items:flex-end;gap:4px;height:72px;margin:6px 0 2px}   /* 14-day trend + by-reason bars */
.wbar{flex:1;min-width:5px;min-height:2px;background:var(--accent);border-radius:3px 3px 0 0;opacity:.85}
.wbar.z{background:var(--sep);opacity:.5}
.wreason{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:10px;margin:7px 0;font-size:14px}
.wreason .track{background:var(--fill);border-radius:6px;height:10px;overflow:hidden}
.wreason .fill{background:var(--accent);height:100%;border-radius:6px}
.wperiod a{padding:5px 12px;border-radius:999px;font-size:13px;text-decoration:none;color:var(--text2)}
.wperiod a.on{background:var(--accent-tint);color:var(--accent-text);font-weight:600}

/* order_planner.html — the unified "Today's shop" (cross-supplier order planner). CSP-clean: bar widths
   come from data-width-pct via app.js CSSOM, no inline style; switch is a native <select data-switch-supplier>. */
.plan-ph-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.plan-datebar{display:flex;gap:10px;align-items:center;margin:2px 0 14px}
.plan-date{font-weight:600}
.plan-note{font-size:var(--fs-caption);color:var(--text2);margin-top:2px}
/* top band: grand total(s) + savings pill + a per-supplier rail */
.plan-top{margin-bottom:18px}
.plan-totals{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.plan-total-chip{background:var(--bg-elev);border:.5px solid var(--sep);border-radius:12px;padding:8px 14px;font-size:var(--fs-body)}
.plan-total-chip b{font-size:var(--fs-subhead)}
.plan-savings{display:inline-flex;align-items:center;gap:6px;background:var(--ok-bg);color:var(--ok-fg);
  font-weight:600;border-radius:12px;padding:8px 14px;text-decoration:none;border:.5px solid transparent}
.plan-savings:hover{border-color:var(--ok-fg)}
.plan-rail{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}
.plan-supcard{display:block;background:var(--bg-elev);border:.5px solid var(--sep);
  border-left:3px solid hsl(var(--sup,220 8% 52%));border-radius:var(--r-card);
  box-shadow:var(--shadow);padding:12px 14px;text-decoration:none;color:inherit;transition:border-color .12s}
.plan-supcard:hover{border-color:hsl(var(--sup,220 8% 52%))}
.plan-supcard-h{display:flex;align-items:center;gap:8px}
.plan-supcard-h b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plan-supcard-h .muted{margin-left:auto;white-space:nowrap}
.plan-supcard-tot{font-size:var(--fs-subhead);font-weight:600;margin:2px 0 6px}
.plan-mov{display:flex;align-items:center;gap:8px;margin-top:4px}
.plan-mov-bar{flex:1;height:8px;background:var(--fill);border-radius:6px;overflow:hidden}
.plan-mov-fill{display:block;height:100%;border-radius:6px;background:var(--accent);width:0}
.plan-mov.under .plan-mov-fill{background:var(--warn-fg)}
.plan-mov-lab{font-size:var(--fs-caption);color:var(--text2);white-space:nowrap}
.plan-mov.met .plan-mov-lab{color:var(--ok-fg)}
.plan-mov.under .plan-mov-lab{color:var(--warn-fg)}
/* body: one COLOR-SPINED, sticky-headed card per supplier — the supplier is the LANE its products live in,
   so mid-scroll there is never doubt which supplier a row belongs to. --sup is set by the .sup-cN palette. */
.plan-section{position:relative;background:var(--bg-elev);border:.5px solid var(--sep);
  border-left:4px solid hsl(var(--sup,220 8% 52%));border-radius:var(--r-card);box-shadow:var(--shadow);
  margin-bottom:22px;overflow:hidden}
.plan-section:target{box-shadow:0 0 0 2px hsl(var(--sup,220 8% 52%)),var(--shadow)}
.plan-section .tablewrap{border:0;box-shadow:none;margin:0}
/* Sticks under the app bar, so it takes the MEASURED bar height like everything else that pins there.
   It used --appbar-h raw, and that token was 3px short of the real 59px bar at 1280 — the supplier
   header's top edge disappeared under the bar on every scrolled order plan. (2026-08-10) */
.plan-sech{position:sticky;top:var(--bar-h,var(--appbar-h));z-index:5;padding:12px 16px;
  background:linear-gradient(hsl(var(--sup,220 8% 52%)/.12),var(--bg-elev) 92%);
  border-bottom:1px solid hsl(var(--sup,220 8% 52%)/.4)}
.plan-sech-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.plan-avatar{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:50%;flex:0 0 auto;
  background:hsl(var(--sup,220 8% 52%));color:#fff;font-weight:700;font-size:13px;
  text-shadow:0 1px 1.5px rgba(0,0,0,.35)}   /* legible white initial across the lighter muted hues */
.plan-sech-name{margin:0;font-size:var(--fs-title3)}
.plan-sech-tot{font-weight:600;color:var(--text2)}
.plan-send{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.plan-sech-details{display:flex;flex-wrap:wrap;gap:5px 16px;margin:6px 0 0 36px;font-size:var(--fs-caption);color:var(--text2)}
.plan-sech-details a{color:var(--text2)}
.plan-sech-min{color:var(--warn-fg);font-weight:600}
/* 8 stable per-supplier accent hues (H S% L%). MUTED, harmonious set (chef feedback 2026-07-14: the old
   full-saturation rainbow "looked like kindergarten") — low saturation (34-52%) + even lightness so the
   spine/avatar/chip read as a refined label system, still distinct from semantic green/orange/red. */
.sup-c0{--sup:210 52% 56%} .sup-c1{--sup:172 40% 46%} .sup-c2{--sup:244 44% 64%} .sup-c3{--sup:284 34% 62%}
.sup-c4{--sup:340 46% 62%} .sup-c5{--sup:198 48% 54%} .sup-c6{--sup:32 44% 54%} .sup-c7{--sup:220 13% 56%}
.plan-switch{display:inline-block}
.plan-switch select{max-width:100%;padding:6px 8px;border-radius:8px;border:.5px solid var(--sep);
  background:var(--bg-elev);color:var(--text);font-family:inherit;font-size:var(--fs-footnote)}
.save-badge{display:inline-block;margin-top:5px;font-size:var(--fs-caption);font-weight:600;
  color:var(--ok-fg);background:var(--ok-bg);border-radius:var(--r-sm);padding:3px 8px}
/* add-a-product row */
.plan-add{padding:14px 16px}
.plan-add .formrow{align-items:flex-end}
.plan-add-prod{flex:1;min-width:220px}
.plan-add-qty{flex:0;width:96px;min-width:96px}
.plan-add-qty input{width:100%}
.plan-add-btn{flex:0}
.plan-add-hint{margin:6px 0 0}
/* NO-JS FLOOR for the add box (chef 2026-07-27: add to the cart the way the market list does). With JS,
   picking a product opens the shared ＋Add-to-order dialog, which owns the supplier, the pack and the
   quantity — so the bare Qty box would be a second, contradictory control and is hidden. Without .cl-js
   the dialog never opens, so the Qty box and the honest old hint come back and the form posts as before.
   Hidden, NOT removed: it is still the field app.js writes the chosen quantity into before submitting. */
.cl-js .plan-add-qty{display:none}
.cl-js .plan-add-nojs{display:none}
html:not(.cl-js) .plan-add-js{display:none}
/* inline-editable line qty — a − [digit] + stepper (dense variant of the Add-to-Order dialog's .ao-step) */
.plan-qty{display:inline-block}
.plan-qty-ctl{display:inline-flex;align-items:center;gap:4px}
.btn.plan-step{padding:0;width:30px;height:30px;min-width:30px;justify-content:center;font-size:17px;
  font-weight:600;background:var(--fill);border-radius:8px}
.btn.plan-step:hover{background:var(--fill-2);filter:none}
.plan-qty-input{width:52px;padding:6px 4px;border-radius:8px;border:.5px solid var(--sep);
  background:var(--bg-elev);color:var(--text);font-family:inherit;font-size:var(--fs-footnote);text-align:center}
.plan-qty-unit{margin-left:4px;font-size:var(--fs-caption);color:var(--text2)}
/* The "n × UOM = total" line: the LOUD, single-line quantity statement the chef reads first (feedback
   2026-07-14: "qty x UOM = total, all big one line"). Tabular figures so the numbers align down the card. */
[data-qty-readout]{font-size:var(--fs-callout);font-weight:600;color:var(--text);letter-spacing:-.01em;
  font-variant-numeric:tabular-nums;margin-top:6px;line-height:1.2}
[data-qty-readout]:empty{display:none}
/* fixed layout + shared col widths so columns line up ACROSS supplier sections (screen only; on a phone
   .respcards turns the table into stacked cards, where table-layout/colgroup no longer apply) */
/* Stock count sheets (chef 2026-07-22): the ingredients + preps tables are separate <table>s, so a shared
   fixed layout + colgroup makes their columns line up, and a left-aligned Counted cell keeps the input at
   a constant x so a wider unit like "portion" no longer shoves it. Screen only; phone card-stacking
   ignores table-layout, same as .plan-tbl. */
/* OPENING-BALANCE opt-in (chef 2026-08-01: the old tick box "looks very bad"). It was a checkbox inside
   a .formrow, where the GLOBAL `label{display:block;font-weight:600}` at the top of this file turned the
   word into a field caption sitting above nothing — and `flex:0` never cleared the `min-width:120px` that
   `:where(.formrow)>div` sets, so it and the Start button both wrapped. It is its own row now.
   Every one of display/font-size/font-weight/margin has to be restated, because that global label rule
   is what this is undoing. The whole strip is the hit area, not a 13px box. */
.openbal{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px 12px;cursor:pointer;
  border:1px solid var(--sep);border-radius:var(--r-ctrl);
  font-size:var(--fs-body);font-weight:400;margin-bottom:0}
.openbal>input{width:auto;flex:0 0 auto;margin:3px 0 0}
.openbal-txt{display:block}
.openbal-txt>b{display:block;font-weight:600}
.openbal:hover{background:var(--fill-2)}
/* Ticked reads as a positive state via the same --ok pair the ★ badge on the sheet itself uses, so the
   two surfaces agree about what "this is the opening sheet" looks like. :has() is progressive
   enhancement — without it the box is still ticked and the explanation still reads. */
.openbal:has(input:checked){background:var(--ok-bg);border-color:transparent}
.stock-tbl{table-layout:fixed;width:100%}
.stock-tbl col.col-cat{width:150px}
.stock-tbl col.col-count{width:200px}
.stock-tbl col.col-value{width:120px}
.stock-tbl col.col-status{width:120px}
.stock-tbl th.c-count,.stock-tbl td.c-count{text-align:left;white-space:nowrap}
/* PORTIONS-SOLD BOX (chef 2026-08-16). Its own class rather than an inline style, because it needs the
   coarse-pointer floor below and an inline width cannot carry a media query.
   44px UNDER (pointer:coarse) — this sheet is 40 boxes typed in a row off a till printout, half of them
   on a phone, and a 34px box between two other 34px boxes is the miss that makes a chef give up on the
   Monday job. `font-size:16px` on touch as well: under 16px iOS Safari zooms the page on focus, which on
   a fixed-layout table throws the row the chef is typing into off screen.
   The placeholder is an em dash, NOT a 0 — an empty box means "not counted" here and a ghosted 0 would
   teach exactly the wrong thing (see core/sales.py). */
.sales-in{width:96px;text-align:right}
/* SELECTOR SPECIFICITY, NOT SOURCE ORDER — the trap this file already documents at the .w-NN rule 175
   lines up. The base control rule is `input[type=number]{...font-size:14px;width:100%}` (0,1,1), so a
   plain `.sales-in` (0,1,0) LOSES to it however far down the file it sits: measured at 375px the box
   took the 44px min-height (nothing competes for that) while its font stayed 14px, which is under the
   16px threshold at which iOS Safari zooms the page on focus — and on a fixed-layout table that throws
   the row being typed into off screen. `input[type=number].sales-in` is (0,2,1) and wins. */
@media (pointer:coarse){input[type=number].sales-in{min-height:44px;font-size:16px}}
/* SECTION HEADER on a count sheet (chef 2026-08-01: "split visually into categories"). A <tr> inside the
   one table rather than a table per section, so the shared <colgroup> keeps every section's columns
   aligned with each other AND with the preps table below — the alignment asked for on 2026-07-22. */
/* .mkt-grp is the Market list's twin of the same idea (chef 2026-08-01) and shares the styling so the two
   pages that both list products cannot drift. Scoped on the ROW class, never on #ingtable — that id is
   shared with the recipe page's own ingredient table (recipe_detail.html), which must not grow section
   headers. */
.stk-grp td,.mkt-grp td{background:var(--fill-2);font-weight:700;font-size:var(--fs-caption);
  text-transform:uppercase;letter-spacing:.4px;color:var(--text2);padding:7px 8px;
  border-top:.5px solid var(--sep)}
@media(max-width:760px){
  /* .respcards makes every <tr> a bordered card. A section header must not become one, or each title
     renders as an empty full-width box on the phone this sheet is actually filled on. Strip the card
     back to a plain tinted strip. */
  .respcards tr.stk-grp,.respcards tr.mkt-grp{border:0;border-radius:0;padding:0;margin:14px 0 6px;
    background:none;box-shadow:none}
  .respcards tr.stk-grp td,.respcards tr.mkt-grp td{padding:6px 8px;border-radius:8px}
}

/* Add-product Name typeahead: shows existing same/similar products so a chef doesn't create a duplicate. */
.ing-name-field{position:relative}
.ing-name-matches{position:absolute;z-index:30;left:0;right:0;top:100%;margin-top:4px;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow);overflow-y:auto;max-height:280px}
.ing-name-matches .inm-head{padding:7px 12px;font-size:12px;color:var(--text3);border-bottom:.5px solid var(--hair)}
.ing-name-matches .inm-warn{padding:9px 12px;font-size:13px;font-weight:600;color:var(--orange);background:var(--warn-bg,rgba(255,149,0,.12))}
.ing-name-matches .inm-row{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:8px 12px;font-size:14px;color:var(--text);text-decoration:none;border-bottom:.5px solid var(--hair)}
.ing-name-matches .inm-row:last-child{border-bottom:0}
.ing-name-matches .inm-row:hover{background:var(--fill-2)}
.ing-name-matches .inm-go{color:var(--accent-text);font-size:12px;white-space:nowrap}

/* Inline step-instruction suggestions (past steps), a CSSOM-positioned listbox on the step textarea. */
.step-sugg{z-index:60;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow);max-height:240px;overflow-y:auto}
.step-sugg-row{padding:8px 12px;font-size:14px;color:var(--text);cursor:pointer;border-bottom:.5px solid var(--hair)}
.step-sugg-row:last-child{border-bottom:0}
.step-sugg-row.on,.step-sugg-row:hover{background:var(--fill-2)}
.plan-tbl{table-layout:fixed}
.plan-tbl col.c-prod{width:23%}
.plan-tbl col.c-qty{width:27%}   /* wider: holds the stepper + the "n × UOM = total" line (Pack col folded in) */
.plan-tbl col.c-unit{width:11%}
.plan-tbl col.c-line{width:11%}
.plan-tbl col.c-sup{width:24%}
.plan-tbl col.c-act{width:4%}
.plan-tbl th,.plan-tbl td{overflow-wrap:anywhere}
/* cheaper-elsewhere row highlight — mirrors .row-warn (desktop tinted cells, mobile left border) */
tr.row-save>td{background:var(--ok-bg)}
/* per-supplier "add a product" row at each card's foot (existing product, or create a new one inline) */
.plan-addrow{padding:10px 16px 14px;border-top:.5px solid var(--sep)}
.plan-addrow-main{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.plan-addrow-prod{flex:1;min-width:200px}
/* New-product sub-row (chef feedback 2026-07-14: "fields in one column, 'Pack price' doesn't fit"). Each
   field is a stacked label+control of a fixed, comfortable width so they sit as a tidy inline row and the
   caption lives ABOVE the input (no more clipped placeholder). Wraps gracefully on a narrow card. */
.plan-addrow-new{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap;margin-top:12px;
  padding-top:12px;border-top:.5px dashed var(--sep)}
.plan-newlab{flex-basis:100%;font-size:var(--fs-caption);font-weight:600;color:var(--text2);margin-bottom:-4px}
.plan-field{display:flex;flex-direction:column;gap:5px;font-size:var(--fs-caption);color:var(--text2)}
.plan-field>span{font-weight:600;letter-spacing:.01em}
.plan-field input,.plan-field select{padding:8px 10px;border-radius:10px;border:.5px solid var(--sep);
  background:var(--bg-elev);color:var(--text);font-family:inherit;font-size:var(--fs-subhead)}
.plan-field.f-size input{width:104px} .plan-field.f-unit select{width:96px} .plan-field.f-price input{width:132px}
/* Settings -> Glossary term-override table */
.gloss-tbl{table-layout:fixed}
.gloss-tbl col,.gloss-tbl th:first-child,.gloss-tbl td:first-child{width:34%}
.gloss-tbl input{width:100%}
/* Whole-basket footer: grand total(s) + Order all, ALWAYS in view (chef feedback 2026-07-14: "the total at
   the end should be static, like a footer"). position:fixed — NOT sticky — because the app scroller is the
   window (main.content grows to its content, so sticky:bottom pinned to an off-screen edge and never floated).
   left:248px == the sidebar column width (grid-template-columns:248px 1fr); the phone rule below drops it. */
.plan-foot{position:fixed;left:248px;right:0;bottom:0;z-index:15;display:flex;gap:14px;align-items:center;
  flex-wrap:wrap;justify-content:space-between;background:var(--bar-bg);backdrop-filter:saturate(180%) blur(20px);
  -webkit-backdrop-filter:saturate(180%) blur(20px);border-top:.5px solid var(--sep);
  padding:12px 30px;box-shadow:0 -6px 24px rgba(0,0,0,.28)}
.plan-foot-tot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.plan-foot-pad{height:36px}   /* reserve flow space so the fixed footer never covers the last supplier card */
@media (max-width:760px){
  .plan-foot{left:0;right:0;bottom:calc(64px + env(safe-area-inset-bottom,0px));padding:10px 16px}  /* sit clear above the 60px fixed bottom nav */
  .plan-foot-pad{height:132px}
  .plan-section .respcards tbody tr{border-left:3px solid hsl(var(--sup,220 8% 52%))}  /* a lone mid-scroll card still declares its supplier */
  .plan-sech-details{margin-left:0}
}
/* delivery calendar (cart phase 2): a Week strip + Month grid of draggable supplier chips; drag a chip to a
   day cell or pick a date on it to reschedule that supplier's whole order. List | Week | Month toggle. */
.plan-viewtoggle{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 12px}
.plan-viewtoggle-btns{display:inline-flex;gap:6px}
.plan-viewtoggle .btn.on{background:var(--accent-tint);color:var(--accent-text)}
.plan-cal{margin:0 0 18px}
.cal-week{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px}   /* minmax(0,..) so day cells shrink & chips ellipsize instead of overflowing the row */
.cal-day{min-height:96px;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:12px;padding:8px;display:flex;flex-direction:column;gap:6px}
.cal-day-h{font-size:var(--fs-caption);color:var(--text2)} .cal-day-h b{color:var(--text);font-size:var(--fs-subhead);margin-left:3px}
.cal-day.today,.cal-cell.today{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.cal-day.past,.cal-cell.past{opacity:.5}
.cal-month-label{font-weight:600;margin:0 0 8px}
.cal-month{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}
.cal-mh{font-size:var(--fs-caption2);color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;padding-bottom:2px}
.cal-cell{min-height:74px;background:var(--bg-elev);border:.5px solid var(--sep);border-radius:10px;padding:5px;display:flex;flex-direction:column;gap:4px}
.cal-cell-h{font-size:var(--fs-caption);color:var(--text2);text-align:right}
.cal-cell.dim{background:transparent;opacity:.5}
.cal-chip{background:hsl(var(--sup,220 8% 52%)/.14);border-left:3px solid hsl(var(--sup,220 8% 52%));border-radius:var(--r-sm);padding:4px 6px;font-size:var(--fs-caption);cursor:grab}
.cal-chip:active{cursor:grabbing}
.cal-chip-name{display:flex;align-items:center;gap:5px;font-weight:600;color:var(--text);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cal-dot{width:8px;height:8px;border-radius:50%;background:hsl(var(--sup,220 8% 52%));flex:0 0 auto}
.cal-chip-meta{display:block;color:var(--text2);font-size:var(--fs-caption2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.cal-chip-date{width:100%;margin-top:3px;font-size:var(--fs-caption2);padding:2px 4px;border-radius:6px;border:.5px solid var(--sep);background:var(--bg-elev);color:var(--text);font-family:inherit}
.cal-other{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:.5px solid var(--sep)}
.cal-other .cal-chip{min-width:160px}
/* month cells are small — name-only draggable chips there; the week strip + Other bucket keep the date picker */
.cal-month .cal-chip-meta,.cal-month .cal-chip-date{display:none}
.cal-month .cal-chip{padding:3px 5px}
.cal-drop.drop-hot{outline:2px dashed var(--accent);outline-offset:-3px;background:var(--accent-tint)}
@media (max-width:600px){
  .cal-week{grid-template-columns:1fr} .cal-day{min-height:auto}
  .cal-month{gap:3px} .cal-cell{min-height:56px}
}

/* ============================================================================================
   CART REDESIGN 2026-07-16 — compact per-supplier cards: the product sits next to its price, no
   repeated column headers, no redundant supplier column (a "switch" chip shows only when a cheaper
   supplier exists). New class names; the old .plan-section/.plan-tbl rules above are now unused.
   ============================================================================================ */
.plan-cards{display:flex;flex-direction:column;gap:16px}
.plan-card{position:relative;background:var(--bg-elev);border:.5px solid var(--sep);
  border-left:4px solid hsl(var(--sup,220 8% 52%));border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
.plan-card:target{box-shadow:0 0 0 2px hsl(var(--sup,220 8% 52%)),var(--shadow)}
.plan-head{display:flex;align-items:center;gap:12px;padding:14px 18px 12px}
.plan-cav{display:inline-grid;place-items:center;width:38px;height:38px;border-radius:11px;flex:0 0 auto;
  background:hsl(var(--sup,220 8% 52%));color:#fff;font-weight:800;font-size:16px}
.plan-head-b{flex:1;min-width:0}
.plan-head-nm{font-size:var(--fs-title3);font-weight:800;letter-spacing:-.01em;line-height:1.15}
.plan-head-meta{display:flex;flex-wrap:wrap;gap:3px 14px;margin-top:3px;font-size:var(--fs-caption);color:var(--text2)}
.plan-head-meta a{color:inherit;text-decoration:none}
.plan-head-meta .dow{color:var(--accent-text);font-weight:600}
.plan-head-meta .min{color:var(--warn-fg);font-weight:600}
.plan-head-r{display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex:0 0 auto}
.plan-head-tot{font-size:var(--fs-title3);font-weight:800;letter-spacing:-.01em;font-variant-numeric:tabular-nums;white-space:nowrap}
.plan-mov-row{padding:0 18px 12px;margin:0}
.plan-lines2{border-top:.5px solid var(--sep)}
.plan-line{display:grid;grid-template-columns:1fr auto auto auto;align-items:center;gap:14px;
  padding:11px 18px;border-bottom:.5px solid var(--sep)}
.plan-line:last-child{border-bottom:0}
.plan-line.row-save{background:var(--ok-bg)}
.plan-line-l{min-width:0}
.plan-line-nm{font-size:var(--fs-body);font-weight:600}
.plan-line-sub{font-size:var(--fs-caption);color:var(--text2);margin-top:2px}
.plan-line-sub b{color:var(--text);font-weight:600}
.plan-line-tot{font-size:var(--fs-subhead);font-weight:700;text-align:right;min-width:70px;font-variant-numeric:tabular-nums}
.plan-line-x{border:0;background:transparent;color:var(--text3);font-size:15px;cursor:pointer;width:28px;height:28px;border-radius:var(--r-sm);line-height:1;padding:0}
.plan-line-x:hover{background:var(--red);color:#fff}
.plan-line .plan-qty-ctl{background:var(--fill-2);border:.5px solid var(--sep);border-radius:9px;overflow:hidden;gap:0}
.plan-line .plan-qty-ctl .plan-step{width:30px;height:30px;border-radius:0;border:0;background:transparent;padding:0;font-size:16px}
.plan-line .plan-qty-ctl .plan-step:hover{background:var(--fill)}
.plan-line .plan-qty-input{width:42px;height:30px;border:0;background:transparent;text-align:center;padding:0;font-weight:600}
/* PER-ITEM NOTE + PACK-PROVENANCE HINT (chef 2026-08-12). The note box is borderless until it is
   touched, so a fifty-line cart does not turn into a wall of empty input frames — the placeholder is the
   only thing announcing it, which is the same restraint the qty stepper shows. min-height 30px keeps the
   tap target honest on a phone at the pass even without a visible border. .is-saved is app.js's tick
   after a successful save; it clears on the next keystroke. .plan-qtynote is machine-written text, so it
   is dimmer than the chef's own and never editable. */
/* .plan-noteFORM, not .plan-note: that class was already taken by the "add a product" hint paragraph
   at the top of this same page (rule above), and reusing it silently restyled the hint. */
.plan-noteform{margin-top:3px;max-width:100%}
.plan-note-input{width:100%;max-width:340px;min-height:30px;font-size:var(--fs-caption);color:var(--text);
  border:.5px solid transparent;background:transparent;border-radius:8px;padding:4px 7px}
.plan-note-input::placeholder{color:var(--text3)}
.plan-note-input:hover{border-color:var(--sep)}
/* Border only — the global input:focus rule already paints the 2px var(--focus) ring, and killing it
   here to make the box look tidier would drop the one cue a keyboard user has. */
.plan-note-input:focus{border-color:var(--accent);background:var(--bg-elev)}
.plan-note-input.is-saved{border-color:var(--ok-fg)}
.plan-note-ro{font-size:var(--fs-caption);margin-top:3px}
.plan-qtynote{color:var(--text3);font-style:italic}
.plan-swchip{display:inline-flex;align-items:center;gap:5px;margin-top:6px;background:var(--ok-bg);border-radius:999px;padding:2px 6px 2px 9px}
.plan-swchip .sw-ic{color:var(--ok-fg);font-size:12px;font-weight:700}
.plan-swchip select{border:0;background:transparent;color:var(--ok-fg);font:inherit;font-size:var(--fs-caption);font-weight:700;cursor:pointer;max-width:230px}
.cal-grip{color:var(--text3);cursor:grab;font-size:11px;letter-spacing:-1px;margin-right:1px;touch-action:none}
.cal-chip{transition:box-shadow .12s,transform .1s}
.cal-chip:hover{box-shadow:var(--shadow);transform:translateY(-1px)}
.cal-chip.dragging{opacity:.4}
@media (max-width:600px){
  .plan-line{grid-template-columns:1fr auto;grid-template-areas:"prod total" "qty del";row-gap:8px}
  .plan-line-l{grid-area:prod}
  .plan-line-tot{grid-area:total;align-self:start}
  .plan-line>form.plan-qty{grid-area:qty}
  .plan-line>form.m-0:not(.plan-qty){grid-area:del;justify-self:end}
  .plan-head{flex-wrap:wrap}
}

/* ============================================================================================
   DISH PRICING & MARGIN panel (2026-07-16) — one row: selling price in -> live food cost % + gross
   margin out, a health bar carrying the target marker, and a one-tap suggested price.
   ============================================================================================ */
.rp{padding:0;overflow:hidden}
/* Cost/portion + selling price (auto) then food cost + gross margin (1fr) — cost in, price in, metrics out. */
.rp-main{display:grid;grid-template-columns:auto auto 1fr 1fr;gap:22px;align-items:center;padding:16px 18px}
.rp-zone{min-width:0}
.rp-lab{font-size:var(--fs-caption);font-weight:600;color:var(--text2);margin-bottom:7px}
.rp-pin{display:inline-flex;align-items:center;gap:2px;background:var(--fill);border:.5px solid var(--sep);border-radius:12px;padding:6px 10px}
.rp-pin .rp-cur{color:var(--text2);font-weight:700;font-size:15px}
.rp-pin input{width:100px;border:0;background:transparent;color:var(--text);font-size:25px;font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums;padding:0 2px}
/* THE RING IS ON THE WRAPPER, NOT REMOVED (2026-08-21 design audit). This was `outline:none` with
   nothing behind it — on the selling-price input, the one field the costing panel is built around.
   The outline is still suppressed on the input itself (it sits inside a bordered pill and a second
   rectangle inside the first reads as a rendering fault), but the pill now shows the focus, so the
   caret is never invisible. --focus, not --accent: 5.97:1 against 2.22:1. */
.rp-pin input:focus{outline:none}
.rp-pin:focus-within{outline:2px solid var(--focus);outline-offset:2px;border-radius:var(--r-sm)}
.rp-pin input::-webkit-outer-spin-button,.rp-pin input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.rp-status{display:inline-block;margin-left:8px;font-size:12px;vertical-align:middle}
.rp-out .rp-num{font-size:26px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums;color:var(--text)}
.rp-out[data-fc-box] .rp-num{color:var(--ok-fg)}
.rp-out[data-fc-box].over .rp-num{color:var(--al-fg)}
.rp-out .rp-cap{font-size:var(--fs-caption2);color:var(--text3);margin-top:6px}
.rp-bar-wrap{padding:2px 18px 14px}
.rp-bar-top{display:flex;justify-content:space-between;font-size:var(--fs-caption2);color:var(--text3);margin-bottom:6px}
.rp-bar{position:relative;height:9px;border-radius:6px;background:var(--fill)}
.rp-bar-fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;background:var(--green);transition:width .18s,background .18s;max-width:100%}
.rp-bar-fill.over{background:var(--red)}
.rp-bar-mark{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--text2);border-radius:2px}
.rp-sug{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:11px 18px;border-top:.5px solid var(--sep);background:var(--accent-tint);font-size:var(--fs-body);color:var(--text2)}
.rp-sug b{color:var(--text)}
.rp-use{margin-left:auto;white-space:nowrap}
@media (max-width:860px){ .rp-main{grid-template-columns:1fr 1fr} }
@media (max-width:620px){ .rp-main{grid-template-columns:1fr;gap:16px} .rp-use{margin-left:0} }
/* "to taste" — a no-quantity ingredient line's qty cell (chef 2026-07-17: show the label, not a bare "—").
   RED (chef 2026-07-17b: "so users see visually there's still work to do") — reads as a to-do, not a value. */
.totaste{display:inline-block;font-size:11.5px;font-weight:700;font-style:italic;color:var(--al-fg);background:var(--al-bg);border:.5px solid transparent;border-radius:999px;padding:2px 10px;white-space:nowrap;letter-spacing:.01em}

/* po_detail order-sheet header + supplier-details grid (moved out of the template's inline <style>) */
.posheet-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-bottom:6px}
.posheet-head .cell b{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text2)}

/* ---- RECIPE LINE — the full-control editor (chef 2026-08-07) --------------------------------------
   Layout only; the drawer chrome itself is .ao-dialog + .edit-drawer above, shared with the supplier and
   staff editors. Deliberately placed BEFORE the global print block below, so the print rules (which are
   all !important) still win over everything here on a printed page. */
/* CENTRED (chef 2026-08-10: "Make the popup bigger and centered. So it could work on all screen
   types"). It inherits .edit-drawer, which is a RIGHT-EDGE full-height panel — the right shape for the
   supplier and staff editors, where the drawer IS one form, and the wrong one here: this panel carries
   the line fields AND the product's whole supplier-price table, so at 520px it was a long single-column
   scroll pinned to one side of a wide screen.

   ...AND NARROWED BACK TO 720px ON 2026-08-11. It was 1040 because it held TWO columns; the chef asked
   for one column of ruled sections ("I would prefere lines for each section"), and 1040px of single
   -column form is a bad measure — a label/field pair that wide makes the eye travel the whole dialog to
   pair a caption with its box, and the fields themselves stretch far past any number a chef types into
   them. 720 is inside the usual 700-760 reading measure for a form, still wide enough for the supplier
   table's supplier/price/actions row (~380px of content) without a scrollbar, and it keeps the
   quantity/prep-note pair side by side at ~340px each.

   EVERY RULE IS .ao-dialog.lnedit-drawer (0,2,0) rather than .lnedit-drawer (0,1,0), so it beats
   .edit-drawer AND the .edit-drawer phone rule further down this file WITHOUT depending on source
   order. Same trick, and the same reason, as the form rules just below. The supplier and staff drawers
   are untouched — they keep being side panels.

   `margin:auto` is what centres it: a showModal()'d <dialog> centres on its own margins, and
   .edit-drawer's `margin:0 0 0 auto` is precisely what pinned it right. */
.ao-dialog.lnedit-drawer{
  width:min(720px, calc(100% - 48px)); max-width:min(720px, calc(100% - 48px));
  height:auto; max-height:88dvh; margin:auto; border-radius:16px;
}
/* PHONE: back to the bottom sheet, which is the right shape on a handset and the one the rest of the
   app uses. dvh, not vh, so the mobile browser's collapsing address bar does not crop the actions. */
@media (max-width:560px){
  .ao-dialog.lnedit-drawer{
    width:100%; max-width:100%; height:92dvh; max-height:92dvh;
    margin:auto 0 0 0; border-radius:16px 16px 0 0;
  }
}
/* The dialog's own header keeps only an eyebrow and the ✕ — the real title arrives with the fetched
   body (#lnedit-title), because the shell is rendered once for the whole table and cannot know which
   line is open. See recipe_detail.html. */
.lnedit-head .lnedit-eyebrow{font-size:var(--fs-caption);font-weight:600;text-transform:uppercase;
  letter-spacing:.5px;color:var(--text2)}
/* The no-JS full page: the same partial in a plain card. It tracks the drawer's own measure (720px +
   the card's 20px side padding), because it is the SAME single column of sections — a page twice as
   wide as the dialog would be a second, unreviewed layout of the same markup. Replaces an inline
   style= (CSP tidy-up). */
.lnedit-page{max-width:760px;padding:18px 20px}

/* ── LAYOUT ──────────────────────────────────────────────────────────────────────────────────────
   THREE BLOCKS, TOP TO BOTTOM: .lnedit-hd (the name + the cost strip), .lnedit-stack (the sections),
   .lnedit-foot (Cancel + Save line).

   .lnedit-stack REPLACED .lnedit-cols ON 2026-08-11. That was a two-up grid at >=900px, from when the
   panel held two independent stacks; the chef asked for one column of ruled sections and the grid went
   with the second column. The name went too — a class called "cols" over a single column is the kind of
   lie that survives three refactors.

   IT IS STILL A LEVEL BELOW .lnedit-wrap, and that part is unchanged and load-bearing: a sticky element
   can only travel inside its containing block, so if the sections were direct children of a grid or
   flex .lnedit-wrap the footer would have nowhere to stick to and would scroll away exactly like the
   buttons it replaced in 2026-08-10. Keeping the stack one level down leaves .lnedit-foot an ordinary
   block child whose containing block is the whole panel, which is what makes `position:sticky` work
   below. */
.lnedit-hd{margin:0 0 var(--sp-4)}
.lnedit-stack{display:block}
/* .edit-drawer form is a full-height flex column — right when the form IS the drawer (supplier/staff),
   wrong here, where the form is fetched INTO the scrolling body and must simply flow. That rule matches
   EVERY form in the drawer, and since 2026-08-08 the drawer also hosts the supplier-price forms (five
   per row plus the add form), which would otherwise each become a full-height flex column inside a
   table cell. Both are neutralised here, at a specificity that beats `.edit-drawer form` (0,2,0)
   regardless of source order; .d-inline is restored after, because the reorder / make-primary / delete
   forms sit side by side in one Actions cell. */
.lnedit-drawer form.lnedit-form{display:block;height:auto}
.lnedit-drawer .lnedit-prices form{display:block;height:auto;min-height:0}
.lnedit-drawer .lnedit-prices form.d-inline{display:inline-block}
.lnedit-form{display:block}

/* ── THE PANEL'S NAME, AND THE ANSWER IT WAS OPENED FOR ──────────────────────────────────────────── */
.lnedit-title{font-size:var(--fs-title3);font-weight:700;line-height:1.2;margin:0;
  display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lnedit-hd-sub{margin:3px 0 0;font-size:var(--fs-footnote);color:var(--text2)}
.lnedit-hd-sub a{font-weight:600;color:var(--accent-text)}
/* THE COST STRIP (chef 2026-08-10) — the figures a chef opened the panel to check, at the top, as four
   compact cells instead of a five-row definition list buried under the prose. The 1px "gaps" are the
   container's own background showing through, so the hairlines need no extra elements and stay on the
   token. flex-wrap + a 130px basis: four across at 1040, two-by-two on a phone, never a scrollbar. */
.lnedit-costbar{display:flex;flex-wrap:wrap;gap:1px;margin:var(--sp-3) 0 0;
  border:.5px solid var(--sep);border-radius:var(--r-card);background:var(--sep);overflow:hidden}
.lnedit-cost{flex:1 1 130px;min-width:0;padding:8px 12px;background:var(--bg-elev)}
.lnedit-cost-k{display:block;font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;
  letter-spacing:.4px;color:var(--text2)}
.lnedit-cost-v{display:block;margin-top:2px;font-size:var(--fs-footnote);color:var(--text)}

/* ── FOUR RULED SECTIONS, ORDERED BY BLAST RADIUS ────────────────────────────────────────────────
   (chef 2026-08-11: "Brainstorm to make it more structured. I would prefere lines for each section"
   and "White Mushrooms — prices & suppliers - THE last one".)

   WHAT WAS HERE BEFORE, AND WHY IT IS SAFE TO REPLACE IT. On 2026-08-10 the two scopes became BOXED
   REGIONS with header bars, replacing "a hairline rule and two pills" that a hurried eye slid straight
   past. That was a real fix for a real problem, and the problem has NOT gone away: the safety story of
   this panel is that a chef editing a PRICE from inside a RECIPE is changing a GLOBAL number from a
   LOCAL-LOOKING PLACE. So the boxes go, but what they carried does not.

   THE ORDER IS WHAT REPLACES THE BORDER, and it is the stronger signal of the two because it is READ
   rather than noticed — a border is a property of a region you may never look at directly, a sequence
   is something you go through:

       QUANTITY            affects this recipe only     <- this line
       YIELD LOSS          scope is yours to pick       <- this line, or this product everywhere
       BY-PRODUCT          creates a product            <- a new row in the catalogue
       PRICES & SUPPLIERS  affects every recipe         <- every recipe in the kitchen

   Each rule is a step outward. On top of it, kept from the boxed version: every section still carries
   its scope pill beside its heading, and the widest one still wears the warn colour — now on a 2px rule
   and its heading rather than on a border and a filled bar. In greyscale that section is still the only
   one with a heavy rule, so the cue is not hue-only. core/tests/test_line_editor_layout.py asserts the
   ORDER, because with the boxes gone the order is doing the work. */
.lnedit-sec{margin-top:var(--sp-4);padding-top:var(--sp-4);border-top:1px solid var(--sep)}
/* The first section already has the header block's own margin above it — two gaps stacked reads as a
   dropped element rather than as breathing room. The RULE stays: it is what separates the read-only
   cost strip from the first thing that is editable. */
.lnedit-form > .lnedit-sec:first-child{margin-top:0}
/* THE WIDEST SECTION, AND THE ONLY ONE THAT IS COLOURED. Both --warn tokens are theme-aware, so this
   holds in light and in OLED dark without a second rule. */
.lnedit-sec.lnedit-global{border-top-color:var(--warn-fg);border-top-width:2px}
.lnedit-global > .lnedit-sech .sech{color:var(--warn-fg)}
/* Section heading + its SCOPE PILL on one line. The pill is what survives the boxes going: a chef must
   be able to see, without reading a sentence, how far the section he is in reaches. It wraps under the
   heading on a narrow sheet rather than squeezing the title. */
.lnedit-sech{display:flex;align-items:baseline;justify-content:space-between;gap:8px;flex-wrap:wrap;
  margin:0 0 var(--sp-3)}
.lnedit-sech .sech{margin:0}
.lnedit-scope{flex:0 0 auto;white-space:nowrap}
/* THE BLAST RADIUS, AT THREE VOLUMES (views.recipe_line_edit picks the level; the thresholds are
   documented there). "quiet" is a chip and one muted sentence — a fact, not a warning; "note" is the
   stock .hint; "loud" is .hint.warn, unchanged from what every product used to get. */
.lnedit-scopenote{margin:0 0 var(--sp-3)}
.lnedit-scopequiet{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;
  margin:0 0 var(--sp-3);font-size:var(--fs-caption);line-height:1.45}
.lnedit-blast{flex:0 0 auto}
/* THE ADD-A-PRICE DISCLOSURE. Collapsed by default so the price that already exists is what a chef
   meets; the template opens it when there is no price yet. */
.lnedit-addwrap{margin-top:var(--sp-3);border-top:.5px solid var(--sep);padding-top:var(--sp-3)}
.lnedit-addsum{display:inline-flex;align-items:center;gap:6px;cursor:pointer;
  font-size:var(--fs-footnote);font-weight:600;color:var(--accent-text)}
.lnedit-addsum:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
/* The add-a-price row is the product page's .formrow inside a drawer column (a ~92dvh sheet on a phone),
   so it wraps to as many lines as it needs and its fields may shrink — the alternative is a horizontal
   scrollbar on the one control a chef reaches for most. .se-body already scrolls, so the sheet grows
   downward into its own scroll rather than past the viewport. */
.lnedit-addprice{flex-wrap:wrap;gap:10px;margin-top:var(--sp-3)}
.lnedit-addprice>div{min-width:0}
/* The live per-unit readout, LABELLED (2026-08-10) — a bare "= —" between two boxes reads as a broken
   render, not as a figure waiting for its inputs. app.js's paint() still owns the "= …" text. */
.lnedit-perunit{display:flex;flex-direction:column;gap:1px;padding-bottom:5px}
.lnedit-perunit-k{font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;
  letter-spacing:.4px;color:var(--text2)}
/* "Use this price for costing" is the highest-stakes control in the panel; it gets a label with weight
   rather than the bare box it had. */
.lnedit-primary{margin:0;align-self:flex-end;padding-bottom:7px;font-weight:600}
/* The supplier table is a real table in a narrow column: let it scroll inside its own wrapper instead of
   widening the drawer (.tablewrap already does the scrolling; this just stops the panel padding from
   eating the width on a phone). */
.lnedit-prices .panel{padding:10px}
@media (max-width:560px){
  .lnedit-prices .panel{padding:6px}
  .lnedit-addprice>div{flex:1 1 100%}
  .lnedit-addprice>div.flex-0{flex:0 0 auto}
}
.lnedit-field{margin:0 0 var(--sp-4)}
.lnedit-field:last-child{margin-bottom:0}
.lnedit-field>label{display:block;font-weight:600;font-size:var(--fs-caption);margin:0 0 5px}
.lnedit-field input[type=text],.lnedit-field input[type=number]{width:100%}
/* Quantity + its unit picker on ONE line (2026-08-08). The number takes the space and the <select>
   takes what it needs; min-width:0 lets the input shrink below its intrinsic size on a phone instead of
   pushing the select out of the drawer. */
.lnedit-qtyrow{display:flex;align-items:center;gap:var(--sp-2)}
.lnedit-qtyrow input[type=number]{flex:1 1 auto;min-width:0}
.lnedit-qtyrow select{flex:0 0 auto;margin:0}
.lnedit-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3) var(--sp-4)}
@media (max-width:560px){ .lnedit-row{grid-template-columns:1fr} }
/* A HINT IS ONE SHORT LINE NOW, with the long version on the element's title= (chef 2026-08-10: the
   panel had turned into documentation). cursor:help is the only affordance that says so without adding
   a glyph the printed sheet and the mono laser would have to carry too. */
.lnedit-cap{margin:5px 0 0;font-size:12px;line-height:1.45;color:var(--text2)}
.lnedit-cap[title]{cursor:help}
/* The checkbox and its words are ONE target — a 4mm box is not a control on a pass. */
.lnedit-check{display:flex;align-items:flex-start;gap:8px;margin:8px 0 0;font-weight:400;cursor:pointer}
.lnedit-check input{margin:2px 0 0;flex:0 0 auto}
/* ── YIELD LOSS, THE CONTROL (2026-08-11) ────────────────────────────────────────────────────────
   IT HAD A DASHED FENCE OF ITS OWN (.lnedit-loss) FOR ONE DAY, because it sat INSIDE the "affects this
   recipe only" box and its scope would have made that box's pill a lie the moment the chef picked "this
   product everywhere". Later the same day the boxes went and the panel became a stack of ruled sections
   ordered by reach — at which point the loss is simply the second section, with its own rule and its
   own "scope is yours to pick" pill, and a fence inside a section it now IS would read as a fourth
   nesting level for nothing. The rule the fence enforced is unchanged: every region declares its reach
   truthfully. */
.lnedit-lossrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:var(--sp-2)}
.lnedit-lossunit{font-size:var(--fs-caption);color:var(--text2)}
/* THE ARITHMETIC, and it is meant to be read as arithmetic — tabular figures so "300 ÷ 500 − 200"
   lines up rather than wandering, and a slightly stronger colour than a hint because this is the
   sentence the chef opened the control to check. */
.lnedit-lossmath{margin:var(--sp-2) 0 0;font-size:var(--fs-caption);line-height:1.5;
  color:var(--text2);font-variant-numeric:tabular-nums}
/* ── THE CHAIN (2026-08-11, the chef: "we need to include the Gross qty so users well see how the calc
      happens ... I need to see the loss in unit of measure when the percentage is entered") ──
   THREE ROWS, NOT A SENTENCE. The figures are what he is checking, so they get their own column at the
   right edge where the eye can run down them; a running sentence buries the gross weight mid-clause and
   is what he was reading before today. flex + space-between rather than a grid: the rows are toggled
   with [hidden] as the percentage changes, and `[hidden]{display:none!important}` beats a flex row
   cleanly where it would have to fight `display:contents` on a grid-subitem trick. */
.lnedit-lossgrid{margin-top:var(--sp-2)}
.lnedit-lossln{display:flex;justify-content:space-between;align-items:baseline;gap:var(--sp-4);
  padding:1px 0}
.lnedit-lossk{min-width:0}                                   /* the label yields; the figure never wraps */
.lnedit-lossv{white-space:nowrap}
/* THE ANSWER, weighted. Gross is the number he buys against, so it carries the rule above it and the
   page's full ink instead of the muted --text2 the working lines wear. */
.lnedit-lossln-sum{margin-top:2px;padding-top:3px;border-top:1px solid var(--sep);
  font-weight:600;color:var(--text)}
.lnedit-lossnote{margin:var(--sp-2) 0 0}
.lnedit-lossnote:empty{margin:0}
/* A REFUSED ENTRY (100 % or more) is the one state here that is an error rather than a reading. */
.lnedit-lossnote.is-bad{color:var(--al-fg);font-weight:500}
.lnedit-losscost{margin:var(--sp-2) 0 0;padding-top:var(--sp-2);border-top:1px dotted var(--sep)}
/* A PREVIEW IS TINTED AS WELL AS LABELLED. The words "Not saved yet" carry the meaning — this only
   makes the changed state visible at a glance, so the tint may never be the ONLY thing saying it. */
.lnedit-losscost.is-preview{color:var(--warn-fg);background:var(--warn-bg);
  border-radius:var(--r-xs);padding:var(--sp-1) var(--sp-2);border-top:0}
/* ── THE BY-PRODUCT (2026-08-11, chef: "he might create the ingridients from that yeild loss") ──
   IT WAS A HAIRLINE-SEPARATED TAIL INSIDE THE YIELD-LOSS FENCE and is now a SECTION OF ITS OWN, one
   rule further down the stack. The reasoning flipped with the restructure: as a tail it was "the next
   line of the same arithmetic", which is true of the NUMBER but not of the ACTION — naming one puts a
   new row in the product catalogue, usable on any recipe and listed on the Leftovers page. In a stack
   ordered by how far a section reaches, that is strictly further than a loss confined to this line or
   this product, so it earns its own rule. The ↳ readout still ties the figure back to the loss above it
   in words, which is what the hairline used to say visually. */
.lnedit-bprow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
/* The name takes whatever the row has left after the label, the % box and its unit — it is the only
   free-text field here and the one whose content varies most ("bones" vs "parmesan rinds"). */
.lnedit-bpname{flex:1 1 160px;min-width:0}
/* THE ANSWER, in the same muted caption voice as the loss chain above so the eye reads them as one
   block, and with the ↳ marker the app already uses for "derived from the thing above". */
.lnedit-bpout{margin:var(--sp-2) 0 0;font-size:var(--fs-caption);line-height:1.5;color:var(--text2);
  font-variant-numeric:tabular-nums}
.lnedit-bpout:empty{margin:0}
.lnedit-bpout b{color:var(--text);font-weight:600}
/* A refused share, or a by-product named on a line with no loss to recover from. Same treatment as the
   loss chain's own is-bad, because it is the same class of thing: a sentence the chef has to act on. */
.lnedit-bpout.is-bad{color:var(--al-fg);font-weight:500}
.lnedit-bpout.is-bad b{color:var(--al-fg)}
.lnedit-lossfrom{display:flex;align-items:baseline;gap:6px}
.lnedit-lossscope{border:0;margin:var(--sp-2) 0 0;padding:0;min-width:0}
.lnedit-lossblast{margin:var(--sp-2) 0 0}
/* ── ONE FOOTER, UNDER THE WHOLE STACK ───────────────────────────────────────────────────────────
   Save/Cancel used to live inside the left column of the old two-up grid, so on any product with a few
   supplier prices they landed halfway up the dialog with the right column still running past them.
   With the 2026-08-11 restructure there is one column and the footer simply ends it — and the sticky
   behaviour below matters MORE, not less, because four stacked sections are taller than two columns of
   the same content.

   IN THE DRAWER IT STICKS. .se-body is the scrollport and .lnedit-wrap is its only child, so a sticky
   bottom:0 here keeps Save on screen however long the price table gets.

   THE SCROLLPORT'S BOTTOM PADDING IS ZEROED rather than cancelled with a negative margin on the bar —
   measured, not assumed: with `margin-bottom:-18px` the stuck bar came to rest 18px above the sheet's
   edge (1440 x 900: bar bottom 828, body bottom 846) and the price table scrolled through the strip
   underneath it. Taking the padding off the container instead puts the bar flush on the edge, and the
   -20px side margins still bleed its background across the body's side padding.

   It is deliberately NOT sticky on the no-JS full page, whose scrollport is the window — a bar pinned to
   the bottom of the viewport there would cover the page it belongs to. */
.lnedit-foot{display:flex;gap:10px;justify-content:flex-end;align-items:center;flex-wrap:wrap;
  margin-top:var(--sp-4);padding-top:var(--sp-3);border-top:.5px solid var(--sep)}
.lnedit-drawer .se-body{padding-bottom:0}
.lnedit-drawer .lnedit-foot{position:sticky;bottom:0;z-index:3;
  margin:var(--sp-4) -20px 0;padding:12px 20px;background:var(--bg-elev)}
/* ONE of the two openers is ever visible: the enhanced button when app.js ran, the plain link when it
   did not. base.html stamps .cl-js on <html> before first paint, so there is no flash of both.

   SCOPED TO html.cl-js (0,2,0), NOT a bare .lnedit-open-nojs (0,1,0) — 2026-08-11, chef: "i see
   dubled pluses". The bare form tied on specificity with whatever class the link ALSO carried, so
   source order decided it. The row-actions link wears .btn (defined line ~498, BEFORE this) and was
   correctly hidden; the yield-loss link wears .cellval (defined line ~2173, AFTER this), which won
   the tie and un-hid it — two ＋ signs in one cell, and a second control that posts nowhere useful.
   The class this rule has to beat is not knowable from here, so the rule stops depending on where it
   sits in the file. It also now says what it means: hide the no-JS twin WHEN JS IS ON. */
html.cl-js .lnedit-open-nojs{display:none}
html:not(.cl-js) .lnedit-open{display:none}
html:not(.cl-js) .lnedit-open-nojs{display:inline-flex}
@media (pointer:coarse){
  .lnedit-open,.lnedit-open-nojs{min-height:44px;min-width:44px;justify-content:center}
  .lnedit-check{min-height:44px;align-items:center}
  .lnedit-addsum{min-height:44px}
  .lnedit-foot .btn{min-height:44px}
}

/* ============================================================================================
   GLOBAL PRINT STYLESHEET (chef feedback: "print looks like it's trying to print the webpage").
   Turns ANY page into a clean black-on-white document: hides the app shell + every interactive
   control, flattens panels, and renders tables as plain ledger tables. Page-specific rules
   (the order sheet, the cart) layer on top. One place, applied everywhere.
   ============================================================================================ */
@media print{
  /* THE ONLY @page RULE IN THIS FILE (2026-08-08). There were four — 14mm in the early print block,
     14mm then 12mm in the spec sheet's, 12mm in the QR labels', and 16mm here. @page carries no
     specificity, so they cascaded purely by SOURCE ORDER and this one, being last, silently won for
     every printed page in the app. Both documents that asked for 12mm had been printing at 16mm ever
     since; on the spec sheet that is ~26mm of extra height at 16 ingredients, on the browser-print path
     that had no page-count test behind it. The fix is one declaration, not another later override — an
     override would have been the fifth rule in the same argument.
     12mm because it is what the two documents that actually care about the margin were asking for, and
     an order sheet or a cart is no worse for 4mm more content. */
  @page{size:A4;margin:12mm}
  html,body{background:#fff!important;color:#000!important;font-size:11pt;-webkit-print-color-adjust:exact;print-color-adjust:exact}
  *{box-shadow:none!important;text-shadow:none!important}
  /* hide the whole app shell + anything interactive or explicitly non-print */
  nav.side,nav.botnav,.botmore,.toasts,.skip,.cbx-panel,.pager,
  .no-print,[data-print],button,.btn,.plan-add,.plan-top,.plan-datebar,.plan-switch,.plan-send,
  .save-badge,.plan-qty-unit,form.formrow{display:none!important}
  main.content,.content{padding:0!important;margin:0!important;max-width:none!important;width:auto!important;display:block!important}
  a{color:#000!important;text-decoration:none}
  h1{font-size:19pt;margin:0 0 2mm} h2{font-size:13pt;margin:5mm 0 2mm} .sub{color:#333!important}
  /* an editable qty cell prints as its value, not an input box */
  .plan-qty-input{border:0!important;padding:0!important;background:transparent!important;
    color:#000!important;text-align:left!important;width:auto!important;font-size:10pt!important;-webkit-appearance:none;appearance:none}
  /* panels + section wrappers become plain document blocks */
  .panel,.plan-section,.tablewrap{background:#fff!important;border:0!important;border-radius:0!important;
    box-shadow:none!important;padding:0!important;margin:0 0 6mm!important;overflow:visible!important}
  /* Tables print as clean ledger tables (screen uses .respcards card-stacking only under the phone
     media query).
     SCOPED AWAY FROM THE SPEC SHEET'S OWN TABLES (2026-08-08). These declarations are !important, which
     beats specificity outright, so `tbody td{padding:1.6mm 2mm!important}` was overriding the spec
     sheet's much tighter per-tier padding no matter what that block said — about 26mm of extra height on
     a 16-ingredient recipe, and the whole reason the browser-print path was fatter than the PDF of the
     same recipe. The !important stays (it is there to beat screen rules like .tablewrap td), but a
     document that lays out its own tables now opts out at the SOURCE of the rule rather than by
     appending a later one. */
  table{width:100%!important;border-collapse:collapse!important}
  table:not(.ss-table):not(.ss-nut) thead th{border-bottom:1pt solid #000!important;padding:2mm!important;text-align:left!important;
    font-size:8pt!important;text-transform:uppercase;letter-spacing:.4pt;color:#000!important;background:#fff!important}
  table:not(.ss-table):not(.ss-nut) tbody td{border-bottom:.4pt solid #999!important;padding:1.6mm 2mm!important;font-size:9.5pt!important;
    color:#000!important;background:#fff!important}
  tr{break-inside:avoid}
  /* cart: one clean order sheet per supplier */
  .plan-section{break-inside:avoid}
  .plan-section+.plan-section{break-before:page}
  .plan-sech-details{color:#000!important;margin-bottom:2mm}
  .plan-sech-tot{color:#000!important}
}

/* ---- Supplier edit drawer + delivery days + serving areas (chef 2026-07-19, suppliers UX redesign).
   The drawer is .ao-dialog pinned right-edge full-height on desktop; on phone it inherits .ao-dialog's
   bottom-sheet rule. .area-seg is deliberately NOT .seg (that's the canonical segmented control). ---- */
.edit-drawer{max-width:460px;width:min(460px,100%);height:100%;max-height:100%;margin:0 0 0 auto;
  border-radius:16px 0 0 16px}
.edit-drawer[open]{display:flex;flex-direction:column}   /* only when open; else UA dialog:not([open]) hides it */
.edit-drawer form{display:flex;flex-direction:column;height:100%;min-height:0}
.se-head{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:.5px solid var(--sep)}
.se-head .ao-name{margin:0}
.se-x{font-size:18px;line-height:1;padding:4px 10px}
.se-body{flex:1;min-height:0;overflow-y:auto;padding:18px 20px;-webkit-overflow-scrolling:touch}
.se-foot{display:flex;gap:10px;justify-content:flex-end;padding:16px 20px;border-top:.5px solid var(--sep)}
.se-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.se-f{display:flex;flex-direction:column;gap:4px;min-width:0}
.se-f>span,fieldset.se-f>legend{font-size:var(--fs-footnote);font-weight:600;color:var(--text2)}
.se-f.full{grid-column:1 / -1}
.se-f input,.se-f textarea{width:100%}
.se-inline{display:flex;gap:6px}
.se-inline input:first-child{flex:1}
.se-inline input:last-child{width:80px;flex:0 0 auto}
fieldset.se-f{border:1px solid var(--sep);border-radius:12px;padding:12px;margin:0}
fieldset.se-f legend{padding:0 6px}
.se-note{margin-top:8px}
.day-presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.btn.xs{padding:3px 9px;font-size:var(--fs-caption)}
.day-chips{display:flex;flex-wrap:wrap;gap:6px}
.day-chip{position:relative;display:inline-flex;align-items:center;gap:4px;min-height:40px;padding:0 12px;
  border:1.5px solid var(--sep);border-radius:10px;cursor:pointer;user-select:none;font-size:var(--fs-footnote);
  font-weight:600;color:var(--text2);background:var(--bg-elev)}
.day-chip input{position:absolute;opacity:0;width:0;height:0}
.day-chip span{opacity:0;width:0;overflow:hidden}                 /* the ✓ glyph, revealed when checked */
.day-chip:has(input:checked){border-color:var(--accent);background:var(--accent-tint);color:var(--accent-text);box-shadow:inset 0 0 0 1px var(--accent)}
.day-chip:has(input:checked) span{opacity:1;width:auto}
.day-chip:has(input:focus-visible){outline:2px solid var(--focus);outline-offset:2px}
.area-seg{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}
.area-opt{flex:1;min-width:150px;display:flex;align-items:center;gap:6px;padding:8px 12px;border:1.5px solid var(--sep);
  border-radius:10px;cursor:pointer;font-size:var(--fs-footnote);background:var(--bg-elev)}
.area-opt input{accent-color:var(--accent)}
.area-opt:has(input:checked){border-color:var(--accent);background:var(--accent-tint)}
.tagwrap{display:flex;flex-direction:column;gap:8px}
.tags{display:flex;flex-wrap:wrap;gap:6px}
.tag{display:inline-flex;align-items:center;gap:6px;padding:4px 6px 4px 10px;border-radius:999px;background:var(--fill-2);font-size:var(--fs-footnote);color:var(--text)}
.tag-x{border:0;background:transparent;color:var(--text2);cursor:pointer;font-size:12px;line-height:1;padding:2px 5px;border-radius:50%}
.tag-x:hover{background:var(--sep);color:var(--text)}
.dow-strip{display:inline-flex;gap:3px}
.dow-cell{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;font-size:10px;font-weight:700;font-style:normal;color:var(--text2);background:var(--fill-2)}
.dow-cell.on{color:#fff;background:var(--accent)}
@media(max-width:560px){
  .edit-drawer{max-width:100%;width:100%;height:90dvh;border-radius:16px 16px 0 0}
  .se-grid{grid-template-columns:1fr}
}
@media(pointer:coarse){.tag-x{min-width:30px;min-height:30px}}

/* ---- Chef Space (chef 2026-07-19) — cross-Site workspace hub. Tabs + unified search + site badges ---- */
.cs-tabs{display:flex;gap:2px;border-bottom:.5px solid var(--sep);margin:18px 0 20px;overflow-x:auto}
.cs-tab{padding:9px 16px;font-weight:600;color:var(--text2);border-bottom:2px solid transparent;white-space:nowrap;text-decoration:none}
.cs-tab:hover{color:var(--text)}
.cs-tab.on{color:var(--accent-text);border-bottom-color:var(--accent)}
.cs-search{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:18px}
.cs-search .sinput{flex:1;min-width:180px}
.cs-search select.sinput{flex:0 0 auto;min-width:150px}
.site-badge{display:inline-block;font-size:var(--fs-caption2);font-weight:600;border-radius:var(--r-sm);padding:3px 9px;background:var(--fill);color:var(--text2);white-space:nowrap}
.site-badge.shared{background:var(--ok-bg);color:var(--ok-fg)}
/* Kept-but-removed dish in Chef Space: soft-deleted from the restaurant but retained in the Cook book. */
.site-badge.removed{background:var(--warn-bg);color:var(--warn-fg)}
.pager{display:flex;gap:12px;align-items:center;justify-content:center;margin:16px 0;flex-wrap:wrap}
/* Top-bar Chef Space workspace switch — sits beside the site switcher (a workspace, not a site scope). */
.cs-switch{display:inline-flex;align-items:center;gap:5px;font-size:13px;font-weight:600;padding:5px 12px;border-radius:999px;border:1px solid var(--sep);color:var(--text2);background:var(--bg-elev);white-space:nowrap;text-decoration:none;line-height:1.4}
.cs-switch:hover{color:var(--text);border-color:var(--accent)}
.cs-switch.on{color:var(--btn-ink);background:var(--btn-fill);border-color:var(--btn-fill)}

/* ---- Recipe line INLINE cell edit (chef 2026-07-21: "I don't like the current edit, I would like the
   inline edit"). Replaces the <details class="rowedit"> whose revealed form widened the cell and shoved
   the table apart — the collision in the chef's screenshot. Read state is a .cellval button; .row-editing
   on the <tr> swaps in the real inputs IN PLACE. The Notes and Unit COLUMNS are gone: the note is a
   sub-line under the name, the unit a suffix in the qty cell. Without .cl-js on <html> the same partial
   shows inputs + Save + Delete permanently, so there is one source of truth. ---- */
.cellval{background:none;border:0;font:inherit;color:inherit;text-align:inherit;cursor:text;
  display:inline-flex;align-items:center;gap:var(--sp-1);padding:var(--sp-1) var(--sp-2);
  margin:calc(var(--sp-1) * -1) calc(var(--sp-2) * -1);border-radius:var(--r-sm);max-width:100%;
  transition:background var(--dur-fast) var(--ease)}
.cellval:hover{background:var(--accent-tint)}
.num .cellval{justify-content:flex-end}
.cellval .qv{font-variant-numeric:tabular-nums;font-weight:600}
/* The pencil is ALWAYS faintly visible, like the market list's .price .pen, so the cell signals it is
   editable at rest rather than only revealing on hover (chef 2026-07-21: "make the same"). It sits
   BEFORE the value in the markup. */
.cellval .pen{opacity:.4;flex:0 0 auto;font-size:var(--fs-caption);
  transition:opacity var(--dur-fast) var(--ease)}
.cellval:hover .pen,.cellval:focus-visible .pen{opacity:.75}
.lnu{color:var(--text2);font-size:var(--fs-caption)}      /* unit suffix — replaces the Unit COLUMN */
.lnname{font-weight:500}
/* SEQUENTIAL NUMBERING for the ingredient rows (chef 2026-08-03: "we need the sequantial numbering for
   the ingridients, but not to overload the interface").

   A CSS COUNTER, so there is no extra column, no per-row markup and nothing for the sort or the
   drag-reorder to keep in step: the numbers are positional by construction and renumber themselves the
   moment a row moves. A real column would have cost width on a table that has already had Notes and
   Unit removed for being too wide, which is exactly the "overload" being warned against.

   ON .lnname::before, NOT td::before. .respcards td::before already renders the field caption on a
   phone (`content:attr(data-label)`), so a counter there would fight the mobile layout for the same
   pseudo-element. Hanging it on the inner name div sidesteps that entirely and still numbers the cards.

   Muted and tabular so it reads as an index rather than as data — a chef scanning for "the third one"
   finds it without the number ever competing with the ingredient itself.

   A FIXED BOX, NOT AN INLINE MARGIN (2026-08-09). The chef reported this on the PDFs — "9 to 10 causes
   the missalignment of the ingredient name" — and the recipe page's counter had exactly the same
   defect: the name began wherever the digits ended, so every row from 10 down sat a digit-width to the
   right of the ones above it. inline-block + a fixed width + text-align:right makes the digits stack
   and gives the name ONE left edge. 3.2em is sized for three digits, so a 100-line recipe does not
   shift either; box-sizing:border-box (line 76) keeps the .55em gap inside that width. */
.ingtbl tbody{counter-reset:ingrow}
.ingtbl tbody tr[data-line-row]{counter-increment:ingrow}
.ingtbl .lnname::before{content:counter(ingrow) ".";color:var(--text3);font-weight:400;
  font-variant-numeric:tabular-nums;display:inline-block;width:3.2em;padding-right:.55em;
  text-align:right;margin-right:0}
/* note as a muted sub-line under the name — replaces the Notes COLUMN and its per-row em-dash */
.lnnote-btn{display:flex;font-size:var(--fs-caption);color:var(--text2);margin-top:2px}
.lnnote-btn.empty{opacity:0}
/* Barely-there (chef 2026-07-26: "note to be barely visible for the best user experience"). The
   empty "+ note" is an affordance, not content: it should whisper on hover and only come fully
   forward on keyboard focus, so a column of note-less rows stays visually quiet. */
tr:hover .lnnote-btn.empty{opacity:.28}
tr:focus-within .lnnote-btn.empty,.lnnote-btn.empty:focus-visible{opacity:.75}
/* ── YIELD LOSS, THE COLUMN (2026-08-11) ─────────────────────────────────────────────────────────
   "40% (200 g)" is one fact in two halves, so the percentage carries the weight of the cell and the
   gross figure trails it as a caption on the same line. Compact on purpose: the chef drew it this way
   and accepted the width cost, which only exists in the costing-on view anyway.

   NOT tinted with .cost-col — a yield loss is a kitchen fact before it is a money fact, and it must
   survive "Hide costing", which is the view a prep list gets printed from. */
.loss-cell{min-width:96px;white-space:nowrap}
.lnloss,.lnloss-btn{font-size:var(--fs-caption);color:var(--text2)}
.lnloss-q{color:var(--text3);margin-left:.35em}
/* The empty "＋" is an AFFORDANCE, not content — the same whisper-then-focus treatment .lnnote-btn
   already uses, so a column of loss-less rows stays visually quiet instead of printing a ladder of
   plus signs down the page. */
/* THE EMPTY STATE IS A DASHED CHIP, NOT A GHOST (2026-08-11, chef: "please make it same style as
   allergens those icons"). It was opacity:0 at rest and .28 on row hover, so the Yield loss column
   read as blank — the chef's screenshot showed an empty column beside an ALLERGENS column full of
   visible ＋ chips, and reasonably asked why.

   The allergen add-chip (.alg-box.add) is the app's established "this is empty and you may fill it"
   affordance: transparent, 1px dashed, .5 at rest, 1 on hover. These declarations MIRROR it rather
   than re-choosing values, and the pairing is asserted in test_yield_loss so the two cannot drift
   into being nearly-the-same-but-not. Same reason this cell already borrows .alg-ro-ic for the
   inherited-loss arrow: one idiom, learned once. */
.lnloss-btn.empty{background:transparent;color:var(--text3);border:1px dashed var(--sep);
  font-weight:400;opacity:.5;padding:3px 8px;border-radius:8px;font-size:11px}
.lnloss-btn.empty:hover,tr:hover .lnloss-btn.empty{opacity:1;border-color:var(--text3)}
.lnloss-btn.empty:focus-visible{opacity:1;border-color:var(--text3)}
/* The ＋ is an invitation on screen and noise on paper — the allergen chip hides itself the same way. */
@media print{ .lnloss-btn.empty{display:none!important} }
/* Read and edit states share a min-width so revealing the input never jumps the column. */
.qty-cell{min-width:104px}
/* An ARMED row carries a number input AND the unit <select> in this cell (2026-08-08); 104px was sized
   for the input plus a two-character caption. Only while editing, so the resting table keeps its
   narrow Qty column. */
.cl-js tr.row-editing .qty-cell{min-width:170px}
.lnedit{display:inline-flex;align-items:center;gap:var(--sp-1)}
.lnqin{width:88px;text-align:right;margin:0;font-variant-numeric:tabular-nums;font-size:var(--fs-callout)}
.lnnin{width:100%;min-width:140px;margin:0;font-size:var(--fs-callout)}   /* 16px: no iOS zoom-on-focus */
/* The inline unit picker (2026-08-08). Sized to its content so "kg" doesn't take a full input's width
   in a cell whose whole job is to stay narrow; .qty-cell's min-width grows to fit the pair. */
.lnuin{width:auto;min-width:56px;margin:0;padding:2px 6px;font-size:var(--fs-callout)}
.rowact{white-space:nowrap}
.rowact form{display:inline-flex;gap:var(--sp-1);margin:0}
/* DRAG TO REORDER (chef 2026-07-28: "I want the rows to be reorderable simply by just dragging").
   The drag starts from this GRIP, never from the <tr>: the row carries the qty and note inputs, and a
   draggable row breaks text selection inside them — the exact reason the event-menu drag uses a grip
   too. touch-action:none lets the pointer fallback drag on a tablet without the page scrolling away
   underneath it. The ▲▼ buttons in the actions cell are NOT a lesser fallback: they are the keyboard
   and scripting-off path, and nothing here is reachable only by dragging. */
.ln-grip{cursor:grab;color:var(--text3);font-size:var(--fs-footnote);letter-spacing:-1px;
  touch-action:none;padding-right:4px;user-select:none;-webkit-user-select:none}
.ln-grip:active{cursor:grabbing}
/* OPEN THE PREP → (chef 2026-07-28: "we'll need the arrow to visit the Preparation from this table").
   A quiet inline link, not a button: it lives in the NAME cell and a third button shape there would
   compete with the row's own ▲▼ / 🗑 controls. The padding IS the tap target — a bare "→" glyph is ~10px
   wide, which a gloved thumb on the pass cannot hit — and it stays within the line box, so row height is
   unchanged. Always visible, never hover-only: this table is worked on a tablet, where hover does not
   exist. It renders at the FAR END of the name line, away from the ⠿ grip, so a mis-grab mid-drag can't
   navigate away and lose the order being dragged. */
.ln-open{display:inline-block;margin-left:2px;padding:2px 8px;border-radius:var(--r-sm);
  color:var(--text3);text-decoration:none;font-weight:600;line-height:1.4}
.ln-open:hover{color:var(--accent-text);background:var(--fill)}
.ln-open:focus-visible{outline:2px solid var(--focus);outline-offset:1px;color:var(--accent-text)}
@media print{.ln-open{display:none!important}}
/* THE PREP BADGE (chef 2026-08-09: "(prep) -> 🥣 Prep / sub-recipe maybe as the badge"). Markup and the
   glyph+word reasoning live in core/_prep_badge.html; this is only how it looks.

   .prep-badge WAS A PHANTOM CLASS until today — recipe_detail.html's page header has said
   `<span class="badge prep-badge">🥣 Preparation</span>` for as long as the term system has existed and
   NOTHING in this file ever defined it, so it rendered as a bare .badge: padding, weight, and no
   background at all (.badge alone sets none — only .ok/.warn/.bad/.neutral/.prep do). Defining it here
   fixes that header AND gives the new line badge the same look, which is the point: the marker on a
   prep's own page and the marker on a line that USES that prep are the same fact and must not be two
   idioms. It is exactly the class of silent failure test_css_utility_classes exists for, missed only
   because that guard scans the numeric utility scale and not semantic names.

   NEUTRAL INK, ON PURPOSE. The .badge family is otherwise a semantic traffic light, and a prep is
   neither a warning nor an achievement: it is a statement about where a cost comes from. --fill /
   --text2 is the exact pair .badge.prep already uses in the Linked column two cells to the right, so
   the row's two markers of the same fact match rather than merely coexist. Both values are redefined in
   the dark block at the top of this file, so OLED dark is covered by the tokens and not by a second
   rule; the hair --sep border is what keeps an 11px --fill pill from dissolving into a near-black
   ground, where the fill alone is barely a shape.

   inline-flex with baseline alignment so the badge sits ON the name's text line instead of lifting it —
   .lnname is in a table cell whose height the whole Ingredients table shares, and that cell already
   carries a ⠿ grip, a ✎ note button and an "open the prep →" link. nowrap so "🥣 Prep" never breaks
   across two lines in the narrow phone-width card.

   GREYSCALE PRINT: the fill goes and the border does the work, exactly as .badge.bad.allergen and
   .badge.warn already do in this file — a tint that survives as a light-grey smudge on a mono laser is
   worse than a plainly outlined word. The 🥣 stays: it costs nothing where the print face lacks it
   (the word carries the meaning) and reads fine where it has it. */
.badge.prep-badge{display:inline-flex;align-items:center;gap:4px;vertical-align:baseline;
  white-space:nowrap;background:var(--fill);color:var(--text2);border:.5px solid var(--sep)}
/* The emoji is drawn by the platform's colour-emoji face, which is metrically unrelated to the UI font;
   1em at line-height:1 stops it adding leading to the badge and, through it, to the whole table row. */
.badge.prep-badge .pb-ic{font-size:1em;line-height:1}
@media print{
  .badge.prep-badge{background:#fff!important;color:#000!important;border:1px solid #000;font-weight:700}
}
tr.ln-dragging>td{opacity:.4}
tbody.ln-over{outline:2px dashed var(--accent);outline-offset:-2px}
@media print{.ln-grip{display:none!important}}
/* COLUMN SORTING, AS A VIEW ONLY (chef 2026-07-28: "can we add the column sorting to the table").
   The visual idiom is the recipes-list header (.rsort + .on + a ▲/▼ suffix) rather than a second
   invented one; .ingsort only adds the button reset .rsort never needed as an <a>. */
.ingsort{background:none;border:0;padding:0;margin:0;font:inherit;cursor:pointer}
.ingsort:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:var(--r-sm)}
/* Scripting off, the header ships `disabled` (there is nothing behind a client-side sort without JS),
   so it has to read as the plain column label it replaced rather than as a broken control. */
.ingsort[disabled]{cursor:default;color:inherit}
.sortcue{font-size:var(--fs-caption)}
/* WHILE SORTED, REORDERING IS OFF. A drag inside a sorted view would save an order the chef never
   chose, and this order is what the spec sheet, service mode and the QR card print. pointer-events
   kills the drag at its source — no dragstart can fire from a grip nothing can grab — the ▲▼ are
   disabled from app.js, and persist() refuses on top of both. */
.ingtbl.ing-sorted .ln-grip{opacity:.25;cursor:not-allowed;pointer-events:none}
.lnmove .btn[disabled]{opacity:.3;cursor:default}
/* The strip that says so on screen, and carries the way back to manual order. The global
   `[hidden]{display:none!important}` normalize rule already beats this display, so app.js toggles the
   attribute and never writes an inline style. */
.ing-sortnote{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;margin:0 0 var(--sp-2);
  padding:var(--sp-2) var(--sp-3);border-radius:var(--r-card);background:var(--accent-tint);
  border:.5px solid var(--sep);font-size:var(--fs-caption);color:var(--text2)}
.ing-sortnote > span{flex:1 1 260px}
/* JS on: the read value leads; the inputs are summoned by .row-editing. JS off: the inputs ARE the UI.
   .rowdel is NOT in this set (chef 2026-07-28: "the remove button is not visible. it's visible only on
   ingredient edit. Please show it always"). Removing a line was reachable only by first ARMING the row —
   i.e. you had to look like you were editing a quantity to be offered a delete, which nothing on screen
   said. It inherits .rowact form's inline-flex, and the coarse-pointer rule below still gives it 44px. */
.cl-js .celled,.cl-js .lnedit,.cl-js .lnsave{display:none}
.cl-js tr.row-editing .cellval{display:none}
.cl-js tr.row-editing .celled{display:inline-block}
.cl-js tr.row-editing .lnedit{display:inline-flex}
tr.row-editing>td{background:var(--fill-2)}
tr.row-editing>td:first-child{box-shadow:inset 3px 0 0 var(--accent)}   /* not colour alone */
/* Saved-row tint so the chef sees WHICH line changed. Kept as a keyframe animation so the global
   prefers-reduced-motion block already stills it — never reimplement as a JS setTimeout style change,
   which would escape that block. */
@keyframes rowflash{from{background:var(--accent-tint)}to{background:transparent}}
tr.row-flash>td{animation:rowflash var(--dur-slow) var(--ease)}
@media (pointer:coarse){
  .cellval{min-height:44px;min-width:44px}
  .cellval .pen{opacity:.5}          /* touch has no hover — the affordance stands permanently */
  .lnnote-btn.empty{opacity:.3}   /* touch has no hover, so it stands — but only just */
  .lnqin,.lnnin{min-height:44px}
  .rowact .btn.sm{min-height:44px;min-width:44px;justify-content:center}
}
@media (max-width:760px){
  .qty-cell{min-width:0}
  .respcards .cellval,.respcards .celled{width:100%;justify-content:flex-start}
  .respcards .rowact{display:flex;gap:var(--sp-2);flex-wrap:wrap}
}
/* Print: the global sheet hides button/.btn/form.formrow but knows nothing about bare inputs in cells,
   so an edited recipe would print input boxes instead of a document. */
@media print{
  .celled{border:0!important;padding:0!important;background:transparent!important;color:#000!important;
    text-align:left!important;width:auto!important;font-size:10pt!important;-webkit-appearance:none;appearance:none}
  .cellval .pen{display:none!important}
}

/* ---- Method steps: INLINE edit (chef 2026-07-21: "Steps/Method have unnecessary buttons. Lets
   implement the inline edit"). The step IS the field — the instruction is a textarea that autosaves and
   the section an inline chip input. What this DELETES: the ✎ disclosure, the "Save step" button, and the
   standing ▲ / ▼ / ✕ Delete wall on every step. The reorder + delete controls now appear only on hover
   or keyboard focus, so at rest a 12-step method shows zero buttons instead of 48. ---- */
.step-row{display:flex;gap:var(--sp-3);align-items:flex-start;position:relative}
.step-body{flex:1;min-width:0;display:block}
.step-block + .step-block{border-top:.5px solid var(--hair)}   /* hairline between steps (was .step-sep) */
/* The instruction reads as text until you touch it: no border, no box, just the words. */
.step-instr.celled-step{width:100%;display:block;resize:none;overflow:hidden;font:inherit;
  line-height:1.5;color:var(--text);background:transparent;border:.5px solid transparent;
  border-radius:var(--r-sm);padding:var(--sp-1) var(--sp-2);margin:calc(var(--sp-1) * -1) calc(var(--sp-2) * -1);
  transition:border-color var(--dur-fast) var(--ease),background var(--dur-fast) var(--ease)}
.step-instr.celled-step:hover{border-color:var(--sep)}
.step-instr.celled-step:focus{background:var(--bg-elev);border-color:var(--accent);outline:none}
.step-chips{display:flex;flex-wrap:wrap;gap:var(--sp-1);align-items:center;margin-top:var(--sp-1)}
/* The section chip is an input that looks like a chip until focused. */
.step-station{font-size:var(--fs-caption);font-weight:500;border-radius:var(--r-sm);padding:3px 9px;
  width:auto;max-width:150px;background:var(--fill);color:var(--text2);border:.5px solid transparent;margin:0}
.step-station:hover{border-color:var(--sep)}
.step-station:focus{background:var(--bg-elev);border-color:var(--accent);color:var(--text);outline:none}
/* An UNSET station collapses to a small hint instead of parking a big empty box on every step — the
   chef asked for fewer things on screen, and a step with no station should cost nothing. It opens to
   full width on hover or focus, and a station that HAS a value always shows at full size. */
.step-station:placeholder-shown{opacity:.5;width:96px;background:transparent}
.step-block:hover .step-station:placeholder-shown,
.step-block:focus-within .step-station:placeholder-shown{opacity:1;width:auto;background:var(--fill)}
.step-station:placeholder-shown:focus{width:auto;opacity:1}
.chip.ccp{background:var(--al-bg);color:var(--al-fg);font-weight:600}   /* food-safety checkpoint */
/* Reorder + delete: absent at rest, revealed on hover or focus-within. Never display:none — that would
   take them out of the tab order and strand keyboard users. */
.step-acts{display:flex;gap:var(--sp-1);flex:0 0 auto;opacity:0;
  transition:opacity var(--dur-fast) var(--ease)}
.step-block:hover .step-acts,.step-block:focus-within .step-acts{opacity:1}
.step-acts .btn:focus-visible{opacity:1}
.step-del:hover{color:var(--red)}
.step-status{flex:0 0 auto;align-self:center}
.cl-js .step-save{display:none}          /* JS on: autosave owns the commit. JS off: this is the commit. */
@media (pointer:coarse){
  /* No hover on touch: the controls must stand permanently, and be thumb-sized. */
  .step-acts{opacity:.75}
  .step-acts .btn.sm{min-height:44px;min-width:44px;justify-content:center}
  .step-station{min-height:36px}
}
@media print{
  .step-acts,.step-status{display:none!important}
  .step-instr.celled-step,.step-station{border:0!important;background:transparent!important;
    color:#000!important;padding:0!important;margin:0!important;-webkit-appearance:none;appearance:none}
}

/* ---- Nutrition panel (chef 2026-07-21: "Nutrition is too big"). It used to render its EDITOR as the
   default state — 16 (EU) to 30 (US) number inputs, a %RI column, two AI buttons, an ingredient
   disclosure, a paste box and an Apply button, all expanded, always. Chefs READ nutrition far more often
   than they edit it, so the default is a four-figure strip and the rest folds away. These rules moved
   here from an inline <style> in recipe_detail.html so the panel is styled from one place. ---- */
.nut-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(84px,1fr));gap:var(--sp-2)}
.nut-fig{text-align:center;background:var(--fill-2);border-radius:var(--r-sm);padding:var(--sp-2)}
.nut-val{font-size:var(--fs-title3);font-weight:600;line-height:1.1;font-variant-numeric:tabular-nums}
.nut-key{font-size:var(--fs-caption);color:var(--text2);margin-top:2px}
.nut-key .nut-u{opacity:.75}
/* The disclosure reads as a quiet control, not a heading. */
.nut-edit > summary{cursor:pointer;font-size:var(--fs-caption);font-weight:600;color:var(--text2);
  display:inline-flex;align-items:center;gap:var(--sp-1);padding:var(--sp-1) var(--sp-2);
  border-radius:var(--r-sm);list-style:none}
.nut-edit > summary::-webkit-details-marker{display:none}
.nut-edit > summary:hover{background:var(--accent-tint);color:var(--accent-text)}
.nut-tbl{width:100%;border-collapse:collapse;font-size:var(--fs-footnote)}
.nut-tbl th,.nut-tbl td{padding:3px 8px;border-bottom:1px solid var(--sep);text-align:left;vertical-align:middle}
.nut-tbl th:not(:first-child),.nut-tbl td:not(:first-child){text-align:right;white-space:nowrap}
.nut-tbl thead th{font-size:var(--fs-caption);text-transform:uppercase;letter-spacing:.3px;color:var(--text2);border-bottom:2px solid var(--sep)}
.nut-in{width:70px;text-align:right;padding:2px 6px}
/* Input + its derived kJ stack right-aligned (chef 2026-07-22): the kJ sits BELOW the input so it can't
   push the energy input out of the column, and the whole editor stays compact. */
.nut-cell{display:inline-flex;flex-direction:column;align-items:flex-end;gap:1px}
.nut-kj{font-size:var(--fs-caption2);color:var(--text3);line-height:1;font-variant-numeric:tabular-nums}
.nut-i1 td:first-child{padding-left:22px;color:var(--text2)}
.nut-i2 td:first-child{padding-left:38px;color:var(--text2);font-style:italic}
.nut-ov{margin:6px 0 0;padding-left:18px;columns:2;font-size:var(--fs-caption);color:var(--text2)}
@media(max-width:640px){.nut-ov{columns:1}}
@media (pointer:coarse){.nut-edit > summary{min-height:44px}.nut-in{min-height:36px}}
/* Print: the label is a document, so open the full table and drop the editing chrome. */
@media print{
  .nut-edit > summary{display:none!important}
  .nut-edit > *{display:revert!important}
  .nut-in{border:0!important;padding:0!important;background:transparent!important;color:#000!important;
    -webkit-appearance:none;appearance:none}
}

/* ---- .joined — a stack of .panel children that reads as ONE card, with every panel after the first
   as a tinted footer attached to it. Generalises the .ing-block treatment the ingredients table already
   uses (chef: "the add section looks like a separate table"), applied to Method for the same reason
   (chef 2026-07-21: "The method and adding instructions are 2 sections").

   .ing-block keeps its own rules because it also styles a combobox popup and a segmented toggle; this
   is the plain version for any list + its add-form. Note overflow:hidden — safe here because nothing
   inside pops out of the box (the ingredients combobox is position:fixed for exactly that reason), so
   do NOT put a dropdown in a .joined footer without checking. ---- */
.joined{border:.5px solid var(--sep);border-radius:var(--r-card);box-shadow:var(--shadow);
  background:var(--bg-elev);overflow:hidden;margin-bottom:22px}
.joined > .panel{border:0;border-radius:0;box-shadow:none;margin:0;background:transparent}
.joined > .panel + .panel{background:var(--fill-2);border-top:1px solid var(--sep);padding:14px 16px}
.joined-ftitle{font-size:var(--fs-caption);font-weight:600;color:var(--text2);text-transform:uppercase;
  letter-spacing:.4px;margin:0 0 8px}

/* ---- PHONE LAYOUT for the recipe page (chef 2026-07-21, on an actual handset).
   Two things broke at 375px, both from flex rows that were fine on a laptop:

   1. A method step's action buttons took 119px of a 301px row — 40% — leaving the instruction 120px,
      about 15 characters, so a sentence wrapped to one or two words per line. The number and the
      controls now share the first line and the instruction gets the full width beneath them.
   2. The add-a-line fields sat 2-up at 127px each, which clips a placeholder mid-word; "Search or add
      an ingi" and "blank = to taste" then read as one run-on string. They stack instead.

   600px, not the 760px used by respcards: above ~600 the controls are a small share of the row and
   two-up fields still fit, so stacking earlier would waste space on a tablet. ---- */
@media (max-width:600px){
  .step-row{flex-wrap:wrap}
  .step-row .stepnum{order:1}
  .step-acts{order:2;margin-left:auto;opacity:1}     /* no hover on touch — always visible */
  .step-body{order:3;flex:1 1 100%;margin-top:var(--sp-1)}
  .step-status{order:4;flex:1 1 100%}
  /* Add-forms: one field per line. Scoped to these two panels rather than every .formrow in the app,
     which would restyle forms nobody has complained about. */
  .add-line-panel .formrow > div,
  .joined .formrow > div{flex:1 1 100%;min-width:0}
}

/* ---- EU-14 ALLERGEN PICKER (chef 2026-07-21: "looks bad Allergens UI").
   The old picker was a grid of 14 tap-to-cycle tiles at repeat(auto-fill,minmax(178px,1fr)). On the
   Add-product .formrow it sat in a minw-200 flex item, so the grid could only resolve to ONE column —
   14 tiles stacked ~900px tall — and .formrow's align-items:end then shoved Name/Category/Supplier/
   Pack/price/submit down to the bottom of that column. Hence the dead page in the screenshot.

   ONE container now, ONE set of rows (2026-08-11): a collapsed control — a chip, or the .alg-boxbtn
   badges under alg_boxes — that opens a <dialog>. The dialog lives in the TOP LAYER, so its content can
   never grow the flex item; the reported bug is structurally unable to recur. It must be a dialog
   rather than an absolutely-positioned panel because main.content is overflow:auto and clips absolute
   panels (the same reason every combobox re-anchors with position:fixed).

   There WAS a second container — the 14 rows in flow, for the roomy ingredient detail page, behind an
   alg_compact flag. That page moved to the chip on 2026-08-11 ("allergens please design it here in the
   form of the bages"), leaving all four call sites compact; the flag, its markup branches and its
   `.alg-body[open]:not(.alg-dialog)` rule are gone. See the tombstone further down.
   ---- */
.algwrap{min-width:0}
/* ALLERGEN BOXES (chef 2026-07-26: "add the allergens in boxes so it would be visually visible").
   One box per recorded allergen: SOLID red = contains, HOLLOW dashed + "~" = may contain — the same
   contains/may vocabulary the allergen matrix uses, so the editor and the inspection sheet teach each
   other. Colour is never load-bearing: each box also carries its glyph, its label text and a title.
   Nothing recorded collapses to a faint ＋, so a table column of mostly-empty rows stays quiet. */
.alg-boxrow{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
/* A PREP LINE'S ALLERGENS, READ-ONLY (chef 2026-07-28: "we should display them, with no ability to
   edit"). Deliberately the SAME .alg-box badges an ingredient row uses — a prep's milk and an
   ingredient's milk are the same fact and must not look like two different ones — with only a ↳ to mark
   it derived. NO hover state, NO pointer, NO focus ring: nothing in here is clickable, and giving it a
   press affordance would promise an editor that cannot exist (a prep's allergens are recomputed from its
   own ingredients). The way to change them is the row's "open the prep →" link. */
.alg-ro{align-items:center}
.alg-ro-ic{color:var(--text3);font-size:var(--fs-caption);flex:0 0 auto}
.alg-boxbtn{display:flex;flex-wrap:wrap;align-items:center;gap:4px;width:100%;background:none;border:0;
  padding:2px 0;cursor:pointer;text-align:left;min-height:30px}
/* CHIPS FIELD — multi-value text edited as removable badges (see _chips_field.html). The box mimics a
   normal input so it sits naturally in a .formrow next to real inputs; the text input inside it is
   borderless and grows to fill whatever the chips leave. */
.chipsel{min-width:0}
.chipsel-box{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;min-height:38px;
  background:var(--fill);border:1px solid var(--sep);border-radius:var(--r-input,10px)}
.chipsel-box:focus-within{border-color:var(--accent);outline:2px solid var(--focus);outline-offset:-1px}
.chipsel-list{display:contents}
.chipsel-chip{display:inline-flex;align-items:center;gap:6px;padding-right:4px}
.chipsel-x{background:none;border:0;padding:0 4px;cursor:pointer;color:inherit;opacity:.6;
  font-size:15px;line-height:1;border-radius:4px}
.chipsel-x:hover{opacity:1;background:var(--fill-2)}
.chipsel-x:focus-visible{outline:2px solid var(--focus);outline-offset:1px;opacity:1}
.chipsel-in{flex:1 1 90px;min-width:90px;border:0;background:transparent;padding:2px 0;color:var(--text);
  font-size:var(--fs-body,14px)}
.chipsel-in:focus{outline:none}

/* A button mid-request: it keeps its own label (swapped to data-busy) and pulses, so a two-second AI
   round-trip visibly reads as "working", not as a dead click. */
.btn.is-busy{opacity:.85;cursor:progress;animation:btnpulse 1.1s ease-in-out infinite}
@keyframes btnpulse{0%,100%{opacity:.85}50%{opacity:.55}}
@media (prefers-reduced-motion:reduce){ .btn.is-busy{animation:none} }

/* SIZE + SHAPE ONLY. Colour, the contains/may distinction and the B&W print rules already live on
   .badge.allergen (see _allergen_badge.html) — duplicating them here is exactly the drift that partial
   was created to stop. .alg-box just makes the same badge read as a chunkier box. */
.alg-box{border-radius:8px;padding:5px 10px;font-size:var(--fs-caption);line-height:1.2}
.alg-box.sm{padding:3px 8px;font-size:11px}
.alg-box.add{background:transparent;color:var(--text3);border:1px dashed var(--sep);font-weight:400;
  opacity:.5;padding:3px 8px;border-radius:8px;font-size:11px}
.alg-boxbtn:hover .alg-box.add{opacity:1;border-color:var(--text3)}
.alg-boxbtn:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:8px}
/* In the ingredients table the boxes must not widen the column. */
.alg-cell{max-width:240px}
@media print{ .alg-box.add{display:none!important} }
.alg-chipwrap{min-width:0}
.alg-chip{display:inline-flex;align-items:center;gap:var(--sp-2);width:100%;min-height:38px;
  padding:var(--sp-2) var(--sp-3);border:.5px solid var(--sep);border-radius:var(--r-md);
  background:var(--bg-elev);color:var(--text);font:inherit;font-size:var(--fs-footnote);
  cursor:pointer;text-align:left}
.alg-chip:hover{border-color:var(--text3)}
.alg-chip-txt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text2)}
.alg-chip-act{flex:0 0 auto;font-size:var(--fs-caption);font-weight:600;color:var(--accent-text)}
/* [open] IS LOAD-BEARING. An unconditional `display:flex` here overrides the UA's
   `dialog:not([open]){display:none}`, so the dialog renders permanently in the page flow, never becomes
   modal, and Done cannot hide it — close() removes the attribute but the display rule keeps it on
   screen. Scope every display rule for a <dialog> to [open]. (chef 2026-07-21: "this window is broken
   it doesnt close".) */
/* 460px was too narrow (chef 2026-07-28: "the window (pop-up spaces are too narrow)"): a 28px glyph +
   the name + the 3-option .alg-seg left ~95px for the label, so "Cereals containing gluten" and
   "Sulphur dioxide / sulphites" wrapped onto three lines each and the 14 rows read as a wall. 680px
   leaves ~340px for the name — one line for every EU-14 label — while the 94vw cap keeps a phone
   unchanged. */
.alg-dialog[open]{max-width:680px;width:min(680px,94vw);
  display:flex;flex-direction:column;max-height:80vh;overflow:hidden;
  border:.5px solid var(--sep);border-radius:18px;padding:0;background:var(--bg-elev);
  box-shadow:var(--shadow-lift)}
/* Header + filter are a STICKY cap; only .alg-rows scrolls (two nested scrollers gave two scrollbars). */
.alg-dialog .ao-head{display:flex;align-items:baseline;gap:var(--sp-2);
  padding:var(--sp-4) var(--sp-4) var(--sp-3);position:sticky;top:0;background:var(--bg-elev);z-index:2}
.alg-dialog .ao-head b{font-size:var(--fs-callout);font-weight:700;letter-spacing:-.01em}
.alg-dialog .alg-filter{margin:0 var(--sp-4) var(--sp-2)}
.alg-dialog .alg-sum{margin:0 var(--sp-4) var(--sp-2)}
.alg-dialog .alg-rows{flex:1;min-height:0;overflow:auto;padding:0 var(--sp-4)}
/* ---- SHELF LIFE dialog polish (chef 2026-08-01: "brainstorm to improve this popup"). ---- */
/* The note sits LEFT and Done right — .ao-actions is flex-end, which would park "Saved as you type"
   against the button and read as its caption rather than as a statement about the sheet. */
[data-shelf-dialog] .ao-actions{justify-content:space-between;align-items:center}
/* Each row becomes two lines: the controls, then the computed use-by under them. */
.sl-row{flex-wrap:wrap}
.sl-useby{flex:0 0 100%;margin-top:2px}
.sl-clear{border:0;background:none;cursor:pointer;color:var(--text3);font-size:var(--fs-caption);
  padding:4px 6px;border-radius:var(--r-sm);line-height:1}
.sl-clear:hover,.sl-clear:focus-visible{background:var(--fill-2);color:var(--text)}
@media(pointer:coarse){.sl-clear{min-height:44px;min-width:44px}}
.alg-dialog .ao-actions{padding:var(--sp-3) var(--sp-4);border-top:.5px solid var(--sep);
  display:flex;gap:var(--sp-2);justify-content:flex-end;background:var(--bg-elev)}
/* NEW-PRODUCT sheet on the recipe page (chef 2026-07-28: the add-ingredient popup must carry the Market
   list's Category / Supplier / Purchase Unit / Purchase Price / Allergens). Same [open]-scoped display
   discipline as .alg-dialog above — read that comment before touching this one. A sticky head and a
   sticky action bar with only the body scrolling, because on a tablet in landscape the sheet is taller
   than the viewport and "Create & add" must never scroll out of reach. */
.np-dialog[open]{max-width:560px;width:min(560px,94vw);
  display:flex;flex-direction:column;max-height:88vh;overflow:hidden;
  border:.5px solid var(--sep);border-radius:18px;padding:0;background:var(--bg-elev);
  box-shadow:var(--shadow-lift)}
.np-dialog .ao-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--sp-2);
  padding:var(--sp-4) var(--sp-4) var(--sp-3);border-bottom:.5px solid var(--sep);background:var(--bg-elev)}
.np-dialog .ao-head b{font-size:var(--fs-callout);font-weight:700;letter-spacing:-.01em}
/* The FORM is the scroller. Deliberately not a sticky action bar: the submit button has to live inside
   the form, so a sticky footer would have to overlay its own scroll container — and a sheet whose only
   commit control can be covered by a soft keyboard is worse than one you scroll. */
.np-body{flex:1;min-height:0;overflow:auto;padding:var(--sp-4);margin:0}
.np-dialog .ao-actions{padding-top:var(--sp-3);border-top:.5px solid var(--sep)}
/* One field per line by default; .np-row pairs two where they read as one decision (size+unit, price+
   readout). It COLLAPSES below 520px rather than shrinking — a 44px tap target beats a tidy grid on a
   line, and this sheet is used with a thumb. */
.np-field{margin-bottom:var(--sp-3);min-width:0}
.np-field>label{display:block;font-size:var(--fs-footnote);font-weight:600;color:var(--text2);
  margin-bottom:6px}
.np-field input[type=text],.np-field input[type=number]{width:100%;margin:0}
.np-row{display:flex;flex-wrap:wrap;gap:var(--sp-3)}
.np-row>.np-field{flex:1 1 220px}
/* The recipe half of the entry, set apart so it is obvious that these two fields describe THIS dish and
   the ones above describe the product everywhere. */
.np-inrecipe{border-top:.5px solid var(--sep);padding-top:var(--sp-3);margin-top:var(--sp-2)}
@media (max-width:520px){ .np-row>.np-field{flex:1 1 100%} }
.alg-filter{width:100%}
.alg-sum{font-size:var(--fs-caption);color:var(--text2);margin:0 0 var(--sp-2)}
.alg-none{font-size:var(--fs-caption);color:var(--text2);margin:var(--sp-2) 0}
/* "I HAVE CHECKED THIS PRODUCT" — the product-level assessment confirmation (2026-08-17).
   A PRODUCT-LEVEL FACT, so it sits OUTSIDE .alg-rows: that list is filterable and scrolls, and a
   declaration about the whole product must not scroll out of sight or disappear because somebody typed
   "gluten" into the filter. The rule above it is what separates "the fourteen" from "about the fourteen".
   The box and its words are ONE target (the .lnedit-check rule states the reason: a 4mm box is not a
   control on a pass), and the coarse-pointer floor is the same 44px the rest of the app holds to. */
.alg-asmt{display:flex;align-items:flex-start;gap:var(--sp-2);cursor:pointer;line-height:1.45;
  font-size:var(--fs-caption);color:var(--text2);
  border-top:.5px solid var(--sep);padding-top:var(--sp-3)}
.alg-asmt input{width:auto;flex:0 0 auto;margin:2px 0 0;cursor:pointer}
.alg-asmt b{color:var(--text);font-weight:600}
.alg-dialog .alg-asmt{margin:0 var(--sp-4) var(--sp-2)}
@media (pointer:coarse){.alg-asmt{min-height:44px}.alg-asmt input{width:22px;height:22px}}
/* One allergen. A <fieldset>'s UA min-inline-size is min-content and WILL refuse to shrink — this whole
   ticket exists because a container refused to shrink, so pin it to 0 explicitly. */
/* ONE LINE per allergen: name left, a compact 3-way control right. It was previously a name line PLUS
   a row of three 161px buttons = 88px per allergen, 1232px of scrolling content for 14 — a wall of 42
   big buttons for a field most products never use. */
/* flex-wrap is the pressure valve for the min-width below: where the name floor plus the (deliberately
   uncrushable) segments no longer fit, the segments drop to their own line rather than squeezing the
   name back into a three-line wrap — which is the bug this whole block exists to fix. */
.alg-row{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--sp-2);
  padding:7px 0;border-top:.5px solid var(--hair);border-radius:10px}
.alg-row:first-child{border-top:0}
.alg-row.alg-hide{display:none}
/* min-width, not min-width:0: the name is the content, the segments are the control. Without a floor the
   segments (flex:0 0 auto) take what they want and the label wraps; 200px is the widest EU-14 name at
   --fs-footnote plus its 28px glyph coin. */
.alg-leg{flex:1 1 200px;min-width:200px;display:flex;align-items:center;gap:10px;
  font-size:var(--fs-footnote);font-weight:600;color:var(--text)}
/* glyph in a soft coin so the pictograms read as deliberate, not clip-art dropped on a row */
.alg-leg .alg-g{flex:0 0 auto;width:28px;height:28px;border-radius:50%;background:var(--fill-2);
  display:inline-flex;align-items:center;justify-content:center;font-size:16px;line-height:1}
/* Three real radios. NOT .seg — its aria-pressed idiom is boolean and wrong for three states, and its
   selected colour is accent green at 2.22:1. */
/* CONNECTED SEGMENTED CONTROL — the house .seg idiom (trough + raised thumb), so three loose outlined
   buttons per row become one unit. Radios stay (accessibility unchanged); only the paint changes. */
.alg-seg{flex:0 0 auto;display:inline-flex;gap:2px;padding:2px;background:var(--fill);
  border-radius:var(--r-md)}
.alg-r{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.alg-opt{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:28px;
  padding:4px 11px;border:0;border-radius:8px;background:transparent;color:var(--text3);
  font-size:var(--fs-caption2);font-weight:600;cursor:pointer;white-space:nowrap;line-height:1.15;
  transition:background var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.alg-opt:hover{color:var(--text)}     /* just the text lifts; the thumb belongs to the selected one */
/* Channel 2 — SHAPE, reusing the inspection sheet's own vocabulary so the two surfaces teach each
   other. currentColor means the mark inherits its label's contrast for free. */
.alg-mk{display:inline-block;flex:0 0 auto}
.alg-mk.c{width:11px;height:11px;border-radius:3px;background:currentColor}      /* filled square */
.alg-mk.m{width:10px;height:10px;border-radius:50%;border:2px solid currentColor}/* hollow ring   */
.alg-mk.n{width:4px;height:4px;border-radius:50%;background:currentColor}        /* dot           */
/* Selected states. Channel 3 = border STYLE: solid for a hard declaration, dashed for traces, so the
   distinction survives greyscale and the B&W print sheet. */
/* The selected segment is a raised thumb (bg + shadow), tinted per state. The SHAPE mark (square /
   ring / dot) and the always-visible WORD keep the state legible without colour, so colour stays the
   third channel, not the only one. */
.alg-r:checked + .alg-opt{font-weight:700;box-shadow:var(--shadow)}
.alg-r:checked + .alg-opt.n{background:var(--bg-elev);color:var(--text)}
.alg-r:checked + .alg-opt.m{background:var(--warn-bg);color:var(--warn-fg)}
.alg-r:checked + .alg-opt.c{background:var(--al-bg);color:var(--al-fg)}
/* --warn-fg, never --orange, for TEXT: #FF9500 on a pale wash is ~2.1:1 and fails 1.4.3. --orange is
   allowed on the ring border only, where the 3:1 non-text threshold applies. */
.alg-r:focus-visible + .alg-opt{outline:2px solid var(--focus);outline-offset:2px}
/* Touch: fall back to the stacked layout so every target keeps its 44px, which a 3-up row of small
   segments cannot give on a phone. */
@media (pointer:coarse){
  .alg-row{gap:var(--sp-1)}
  .alg-leg{flex:1 1 100%;min-width:0}
  .alg-seg{flex:1 1 100%;grid-template-columns:repeat(3,1fr)}
  .alg-opt{flex:1;min-height:44px;font-size:var(--fs-caption)}
  .alg-chip{min-height:44px}
}
/* Narrow VIEWPORT, not just a touch pointer. The rule above keys off the input device, so a 375px
   window on a laptop (and a desktop browser's device toolbar, which is how this gets checked) kept the
   one-line layout and pushed the segments past the 94vw dialog edge. Same stacking, width-driven. */
@media (max-width:560px){
  .alg-leg{flex:1 1 100%;min-width:0}
  .alg-seg{flex:1 1 100%}
  .alg-opt{flex:1;padding:4px 6px}
}
/* JS off: no chip, and the dialog renders as a plain in-flow block on its own full-width row, so the
   picker is still reachable and still posts. Deliberately taller than the enhanced version — on legal
   data, reachable beats compact. */
html:not(.cl-js) .alg-chipwrap{display:none}
html:not(.cl-js) .alg-dialog{display:block!important;position:static;max-width:none;width:auto;border:0;
  box-shadow:none;background:transparent;padding:0}
html:not(.cl-js) .alg-dialog .alg-filter,
html:not(.cl-js) .alg-dialog .ao-actions,
html:not(.cl-js) .alg-dialog .ao-head{display:none}
/* ...EXCEPT the one action row that holds a REAL submit (2026-08-11). Blanket-hiding .ao-actions was
   correct while every alg_save picker sat in a table row the chef could also save another way, and it
   became wrong the moment ingredient_detail moved onto the compact chip: that page's Save allergens IS
   this button — it used to be a <button type="submit"> the page rendered outside the partial — so with
   JS off the fourteen radios would render and nothing could post them. The Cancel beside it stays hidden
   because it is pure DOM restoration and does nothing without app.js. Specificity: the hiding rule is
   (0,3,1) and this is (0,4,1), so it wins on weight and not on source order. */
html:not(.cl-js) .alg-dialog .ao-actions.alg-has-save{display:flex}
html:not(.cl-js) .alg-dialog .ao-actions.alg-has-save [data-alg-cancel]{display:none}
html:not(.cl-js) .algwrap{flex:1 0 100%}
/* THE IN-FLOW PICKER BODY (.alg-body[open]:not(.alg-dialog)) WAS REMOVED 2026-08-11, same day as the
   grid below and for the same reason. It stripped the UA dialog chrome off a picker rendered with
   `open` — the alg_compact=False shape — so one <dialog> could serve both a modal and a plain in-flow
   block. Retiring alg_grid moved ingredient_detail onto the chip, which left all FOUR includes passing
   alg_compact=True; the partial never emitted `open` and never omitted .alg-dialog again, so this
   selector could not match anything. The flag and both its branches went with the rule, and .alg-body
   itself is off the element — a class with no definition is how a dead variant half-survives.
   NOT REMOVED, and not the same thing: `html:not(.cl-js) .alg-dialog{display:block!important;...}`
   above. That un-dialogs the SURVIVING element when scripting is off, and it is the only reason the
   fourteen rows (and, under .alg-has-save, the real submit) stay reachable without app.js. */

/* THE COLUMN-PACKED IN-FLOW PICKER (.alg-rows.alg-grid) WAS REMOVED 2026-08-11. It existed for one
   caller — ingredient_detail.html, which asked on 2026-08-08 for the fourteen rows to be shorter and
   kept them all visible — and that page has since moved to the collapsed chip every other call site
   uses (chef 2026-08-11: "allergens please design it here in the form of the bages"). With no caller
   the grid, its two re-stated touch-target media blocks and allergens.ALLERGEN_SHORT were a second way
   to draw one compliance control that nobody could reach; the rows now have exactly one shape, at full
   legal names, inside the 680px dialog. git show 3c7726f has the measurements if it is ever wanted back. */
/* Stock count sheet: with JS the sheet autosaves per row, so the whole-sheet Save button is the no-JS
   floor only (chef 2026-07-21: "I don't like the save button"). Without .cl-js the note is hidden and
   the button stands, so the sheet is still savable. */
.cl-js .stock-savebar{display:none}
html:not(.cl-js) .stock-autonote{display:none}
/* Product unit, editable after creation. A plain form + Save is the no-JS floor; with JS the select
   saves on pick and the Save button goes (chef 2026-07-21: the unit was write-once, so a kg/L/unit
   mistake could never be corrected). */
.unitrow{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap;margin:0 0 var(--sp-3)}
.unitrow-lab{font-size:var(--fs-caption);font-weight:600;color:var(--text2);margin:0}
.cl-js .unitrow-save{display:none}
@media (pointer:coarse){.unitrow select{min-height:44px}}

/* ---- Rota: add a shift from the cell (chef 2026-07-21: "very hard to add a schedule the current way
   ... addeble from the table Weekly rota. With few clicks under the person").

   The ＋ is a SEPARATE .no-print button, never wrapped around the "OFF" text — so the existing
   .no-print rule does all the work and the PRINTED rota is byte-identical to before. That is the whole
   reason this shape was chosen over wrapping the cell in a link. ---- */
.rota-add{border:0;background:none;cursor:pointer;color:var(--text3);font-size:var(--fs-footnote);
  line-height:1;padding:2px 6px;border-radius:var(--r-sm);opacity:0;
  transition:opacity var(--dur-fast) var(--ease),background var(--dur-fast) var(--ease)}
.grid-cell:hover .rota-add,.grid-cell:focus-within .rota-add,.rota-add:focus-visible{opacity:1}
/* --accent-text, NOT --accent: on light the pair var(--accent) on var(--accent-tint) is ~2.2:1, the same
   failure fixed app-wide for buttons in b73c9c9. It was unreachable here while the button was invisible
   (see the .sf-cell rule below) — revealing the control makes this rule newly visible, so it is corrected
   in the same commit rather than left for the deferred --accent sweep (HANDOFF section 3 item 2). Dark
   mode does not move: --accent-text IS the accent there, by construction. */
.rota-add:hover{background:var(--accent-tint);color:var(--accent-text)}
@media (pointer:coarse){.rota-add{opacity:.6;min-height:44px;min-width:44px}}  /* touch has no hover */
/* The dialog IS the old flat panel. With JS it becomes modal; without <dialog> support it stays exactly
   what it was, so there is one element, one form, no duplicate ids and no divergent no-JS path. */
.cl-js .rota-dialog[open]{position:fixed;inset:0;margin:auto;max-width:640px;width:min(640px,94vw);
  max-height:86vh;overflow:auto;z-index:60}
.cl-js .rota-dialog:not([open]){display:none}
/* NO-JS FLOOR. The dialog no longer ships with `open`: with .cl-js applied in <head>, an open dialog
   would paint a full-screen modal over the rota on EVERY page load until app.js got round to closing it.
   Without JS this rule un-dialogs it back into the flat panel it has always been. */
html:not(.cl-js) .rota-dialog{display:block!important;position:static;max-width:none;width:auto}
html:not(.cl-js) .rota-addsech{display:block}
.cl-js .rota-addsech{display:none}
.rota-dialog::backdrop{background:rgba(0,0,0,.45)}
.rota-days{margin-top:var(--sp-2)}
.rota-chips{display:flex;flex-wrap:wrap;gap:var(--sp-1);align-items:center}
.rota-chip{display:inline-flex;align-items:center;gap:5px;padding:6px 10px;border:1px solid var(--sep);
  border-radius:999px;font-size:var(--fs-caption);font-weight:600;color:var(--text2);cursor:pointer}
.rota-chip:hover{border-color:var(--text3)}
.rota-chip:has(input:checked){background:var(--accent-tint);border-color:var(--accent);color:var(--accent-text)}
.rota-chip input{margin:0}
@media (pointer:coarse){.rota-chip{min-height:44px}}
/* Add/Cancel sit together, sized to content, and align to the bottom of the field row (chef 2026-07-22:
   the old inline flex:0 collapsed the basis to 0 so the two buttons wrapped onto two misaligned lines). */
.rota-actions{flex:0 0 auto;display:flex;gap:8px;align-items:end}
[data-rota-staff]{min-width:200px}   /* the staff combobox needs room to type + show the dropdown */
/* ---- Rota: the wage bill, and the calculation behind each person's share (chef 2026-07-28: "We need
   the Total Week labour / Total Full Timers / Total Part timers / and on hoover the detailed
   calculation per person").

   THE DISCLOSURE IS A <details>, NOT A TOOLTIP. A title= or a :hover popover answers the literal
   request and serves nobody on the tablet propped up on the pass, or anybody driving the page from a
   keyboard. <details> gives tap and Enter/Space natively and survives JS being off; app.js layers
   hover (and keyboard-focus) opening on top for devices that have a fine pointer. So the same markup
   covers all three ways in, and the fallback is the fully-expanded truth rather than nothing.

   NOT the house `.disclose`: that is a section HEADING that folds a panel away (width:max-content,
   uppercase, its own margins). This is a full-width ROW in a list of people, with three columns that
   have to line up down the page, so it shares the idea and none of the geometry.

   The three columns are FIXED-WIDTH, not auto: every row is its own grid, so `auto` columns would
   size per row and the hours/cost figures would stagger down the panel instead of forming a column
   the chef can read down and add up. ---- */
.labgap{align-items:flex-start}
.labgap div+div{margin-top:var(--sp-2)}
.labp-wrap{margin-bottom:var(--sp-5)}
.labp-head,.labp>summary{display:grid;grid-template-columns:minmax(0,1fr) 92px 132px 14px;
  gap:var(--sp-2) var(--sp-3);align-items:center;padding:10px var(--sp-4)}
.labp-head{font-size:var(--fs-caption);font-weight:600;text-transform:uppercase;letter-spacing:.4px;
  color:var(--text2);border-bottom:.5px solid var(--sep);background:var(--fill-2)}
.labp-head span+span{text-align:right}
.labp{border-top:.5px solid var(--hair)}
.labp:first-of-type{border-top:0}
.labp>summary{cursor:pointer;list-style:none}
.labp>summary::-webkit-details-marker{display:none}
/* The chevron is the 4th grid column — a real affordance that the row opens, since the UA marker is
   dropped by `list-style:none` (a grid <summary> would not paint it anyway). */
.labp>summary::after{content:"›";color:var(--text3);font-size:var(--fs-callout);line-height:1;
  text-align:center;transition:transform var(--dur-fast) var(--ease)}
.labp[open]>summary::after{transform:rotate(90deg)}
.labp>summary:hover{background:var(--fill-2)}
.labp[open]>summary{background:var(--fill-2)}
.labp>summary:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
.labp-name{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-weight:600;min-width:0}
.labp-h{color:var(--text2);font-size:var(--fs-footnote);text-align:right;white-space:nowrap;
  font-variant-numeric:tabular-nums}
.labp-c{font-weight:700;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}
.labp-calc{padding:0 var(--sp-4) var(--sp-4);font-size:var(--fs-footnote);color:var(--text2)}
.labp-calc p{margin:0 0 var(--sp-2)}
/* THE ARITHMETIC ITSELF is the point of opening the row, so it is set larger than the prose that
   explains it — the chef is checking a sum, not reading a paragraph. */
.labp-eq{font-size:var(--fs-callout);font-weight:600;color:var(--text);letter-spacing:-.01em;
  font-variant-numeric:tabular-nums}
.labp-lines{width:100%;border-collapse:collapse;margin-top:var(--sp-2)}
.labp-lines th{font-size:var(--fs-caption2);text-transform:uppercase;letter-spacing:.4px;
  color:var(--text3);text-align:left;font-weight:600;padding:4px 8px;border-bottom:.5px solid var(--hair)}
.labp-lines td{padding:4px 8px;border-bottom:.5px solid var(--hair);font-variant-numeric:tabular-nums}
.labp-lines tr:last-child td{border-bottom:0}
.labp-none{padding:var(--sp-4)}
@media (pointer:coarse){.labp>summary{min-height:44px}}
/* Phone: the two number columns shrink rather than squeezing the name to nothing (a name is the only
   thing that identifies the row; a cost that wraps is still readable). */
@media (max-width:520px){
  .labp-head,.labp>summary{grid-template-columns:minmax(0,1fr) 62px 104px 12px;padding:10px var(--sp-3)}
  .labp-h{font-size:var(--fs-caption)}
  .labp-calc{padding:0 var(--sp-3) var(--sp-3)}
}
/* Opening-balance control on a stock sheet. */
.openrow{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap;margin:0 0 var(--sp-3)}
/* Recipe table: sortable headers + the column picker. */
.rsort{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.rsort:hover{color:var(--accent-text)}
.rsort.on{color:var(--accent-text);font-weight:700}
.colpick{position:relative;display:inline-block}
.colpick > summary{cursor:pointer;list-style:none}
.colpick > summary::-webkit-details-marker{display:none}
.colpick-panel{position:absolute;z-index:40;top:calc(100% + 6px);right:0;min-width:220px;
  background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);
  box-shadow:var(--shadow);padding:var(--sp-3);display:flex;flex-direction:column;gap:var(--sp-1)}
.colpick-opt{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-footnote);
  color:var(--text2);cursor:pointer}
.colpick-opt input{width:auto;margin:0}
@media (pointer:coarse){.colpick-opt{min-height:44px}}
/* THE PDF MENU on a spec sheet (chef 2026-08-10: "1 dropdown selection PDF, With option to Preview the
   PDF and Download PDF"). Same .colpick <details> mechanism as the column picker above — this only
   restyles the panel's rows from checkbox labels into menu items, and left-aligns the panel because this
   control sits at the LEFT of its toolbar where a right-anchored panel would hang off the card.
   .pdfmenu-opt is a full-row hit target (44px on touch) so a chef with wet hands does not miss it. */
.pdfmenu > .pdfmenu-panel{left:0;right:auto;min-width:210px;padding:var(--sp-1);gap:0}
.pdfmenu-opt{display:flex;align-items:center;gap:var(--sp-2);padding:9px var(--sp-2);
  border-radius:var(--r-btn,8px);color:var(--text);text-decoration:none;font-size:var(--fs-footnote);
  white-space:nowrap}
.pdfmenu-opt:hover,.pdfmenu-opt:focus-visible{background:var(--fill);color:var(--text)}
/* THE SAME ROW WHEN IT IS A <button> (2026-08-15). Every other PDF ▾ in the app is built from <a>, but
   the prep planner's four options must SUBMIT the form — the document they produce is defined by the
   portions typed into it, and a bare href would carry none of them, exactly as "Export order sheet
   (CSV)" has always had to be a submit button there. A <button> brings its own native chrome that the
   rule above does not reset, and it showed: measured in a real Chrome at 2px border, a grey
   background and centre-aligned text sitting in a panel of flush-left links. These five declarations
   are the whole difference; `width:100%` because a button shrink-wraps where a flex <a> row already
   fills the panel, and `font:inherit` because a button does NOT inherit the page's family. */
button.pdfmenu-opt{border:0;background:none;width:100%;text-align:left;font:inherit;cursor:pointer}
@media (pointer:coarse){.pdfmenu-opt{min-height:44px}}
@media print{.pdfmenu{display:none !important}}
/* Allergen sheet: the printed grid states that screen filters were not applied to it. */
.am-printnote{caption-side:top;text-align:left;font-size:var(--fs-caption);color:var(--text2);
  padding:0 0 6px}
@media screen{.am-printnote{display:none}}
/* Ingredients advanced-search bar: one row, the search box takes the slack. */
.ing-search{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap}
.ing-search .ing-q{flex:1 1 260px;min-width:200px;max-width:420px;margin:0}
.ing-search select{margin:0}
.ing-search .ing-clear{margin-left:auto}
/* The category/supplier multi-selects sit in this same bar (chef 2026-08-02), so they inherit the same
   two rules the native dropdowns beside them already had — no stray margin breaking the flex row, and a
   44px touch target on a phone. Without the second one they would be the only controls in the bar too
   small to hit reliably, on a page a chef opens standing in a delivery bay. */
.ing-search .msel-summary{margin:0}
@media (pointer:coarse){.ing-search select,.ing-search .ing-q,.ing-search .msel-summary{min-height:44px}}

/* ===================================================================================================
   CATERING EVENT SHEET  (/calendar/event/<id>/)  — UX rebuild 2026-07-27
   Chef: "the current interface for adding the dishes and managing the guests with allergens is very
   bad. Brainstorm how to make it smooth with the best user experience on any devices."

   THE DEFECT THIS LAYOUT FIXES is not decoration. The allergens a chef is cooking around used to live
   in a panel ABOVE the menu, so on any screen narrow enough to matter they were off-screen at the exact
   moment a dish was being chosen — the clash could only ever be reported afterwards. Three layouts,
   one job: keep the constraint next to the decision.

     < 760px   phone      one pane at a time (Details | Guests | Menu), pure-CSS segmented control,
                          summary bar sticky above it. 760 is the app's own phone breakpoint — the
                          same one that swaps nav.side for .botnav — so the page never disagrees with
                          the chrome around it.
     760-1079  tablet     every pane stacked, no tab control, summary still sticky.
     >= 1080   desktop    two columns: menu left, guests + allergen tally sticky right.

   Everything below is tokens only — no literal colour anywhere in this block — so it themes in light
   and OLED dark for free. Moved out of the page's old <style> block because this page now ships JS
   too, and app.css/app.js are versioned together in base.html.
   =================================================================================================== */
.evwrap{position:relative}
/* WITHOUT THIS RULE NOTHING ON THIS PAGE IS STICKY — and "sticky" is the whole layout.
   main.content is overflow:auto, but .app is min-height:100vh, so its 1fr row grows to fit the content
   and main.content never actually scrolls: the WINDOW does. It is still a scroll CONTAINER though, and
   a position:sticky descendant sticks to that container's scrollport — which never moves — so it does
   not stick at all. Measured: the summary cap scrolled clean off screen (top -735px at 900px of
   scroll); with overflow:visible it pins at exactly --appbar-h.
   :has() scopes the fix to this page, so no other screen's clipping behaviour changes (the combobox's
   position:fixed re-anchoring, the tablewraps). A browser without :has() ignores the rule and simply
   gets a non-sticky page that still works — the right way round to fail. */
main.content:has(.evwrap){overflow:visible}
.evmeta{display:flex;flex-wrap:wrap;gap:var(--sp-2) var(--sp-5);align-items:center;color:var(--text2);
  font-size:var(--fs-footnote);margin:0 0 var(--sp-3)}
.evmeta .evfact{display:inline-flex;align-items:center;gap:6px}
.evmeta b{color:var(--text)}

/* ---- the sticky cap: phone tabs + the constraint summary ---- */
/* The radios are visually hidden but must stay FOCUSABLE and in the tab order — position:absolute with
   1px/opacity:0 (not display:none, not visibility:hidden) is the only way a keyboard user can still
   reach the segmented control. */
.evtab-r{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
/* NOT top:0. main.content declares overflow:auto but its grid row auto-sizes to the content, so it
   never actually becomes the scroller — the WINDOW scrolls, and a sticky child pins to the viewport,
   i.e. straight under the app bar (.top, sticky top:0 z-index:20). At top:0 the whole cap slid behind
   it and the clash banner was invisible while scrolled.

   --ev-top is the app bar's MEASURED height, published by app.js; --appbar-h is the nominal fallback
   used before any script runs and forever with scripting off. Measured because .top is not a fixed
   height: 57px on a phone, 59px at 1280, and 73px around 1024 where its pills wrap to a second line —
   at a hardcoded 56px the counts row was clipped by 17px at that width. */
.evsticky{position:sticky;top:var(--ev-top,var(--appbar-h));z-index:6;background:var(--bg);
  padding:var(--sp-2) 0}
.evtabs{display:flex;gap:2px;padding:3px;background:var(--fill);border-radius:var(--r-md);
  margin:0 0 var(--sp-2)}
.evtab{flex:1;margin:0;min-height:44px;display:flex;align-items:center;justify-content:center;
  cursor:pointer;padding:var(--sp-2);border-radius:var(--r-sm);
  font-size:var(--fs-footnote);font-weight:600;color:var(--text2);user-select:none;white-space:nowrap;
  transition:background var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
#evtab-details:checked ~ .evsticky .evtab[for="evtab-details"],
#evtab-guests:checked ~ .evsticky .evtab[for="evtab-guests"],
#evtab-menu:checked ~ .evsticky .evtab[for="evtab-menu"]{background:var(--bg-elev);color:var(--accent-text);
  box-shadow:var(--shadow)}
/* Ring only on the segment that actually has focus — outlining all three reads as "everything is
   focused". */
#evtab-details:focus-visible ~ .evsticky .evtab[for="evtab-details"],
#evtab-guests:focus-visible ~ .evsticky .evtab[for="evtab-guests"],
#evtab-menu:focus-visible ~ .evsticky .evtab[for="evtab-menu"]{outline:2px solid var(--focus);
  outline-offset:2px}

/* The counts. Tabular numerals so a 9 -> 10 tick does not shuffle the row sideways. */
.evbar{display:flex;flex-wrap:wrap;gap:var(--sp-2) var(--sp-4);align-items:center;
  background:var(--bg-elev);border:.5px solid var(--sep);border-radius:var(--r-card);
  padding:var(--sp-2) var(--sp-4);box-shadow:var(--shadow)}
.evstat{display:inline-flex;align-items:baseline;gap:6px;font-size:var(--fs-caption);color:var(--text2);
  white-space:nowrap}
.evstat b{font-size:var(--fs-callout);color:var(--text);font-variant-numeric:tabular-nums;
  letter-spacing:-.02em}
.evstat.warn b{color:var(--warn-fg)}
.evstat.bad b{color:var(--al-fg)}
/* The clash banner rides inside the sticky cap: it is the one thing on this page that must never
   scroll away. Compact (it is permanent), never dismissible (see _event_summary.html). */
.evsticky .hint{margin:var(--sp-2) 0 0;padding:9px 14px;align-items:flex-start}
.evclash{align-items:flex-start}

/* ---- panes ---- */
.evpane{min-width:0}
.evdet{margin:0}
.evdet-sum{cursor:pointer;display:flex;align-items:baseline;gap:var(--sp-2);flex-wrap:wrap;
  list-style:none;min-height:44px;padding:var(--sp-1) 0}
.evdet-sum::-webkit-details-marker{display:none}
.evdet-sum::after{content:"\25BE";color:var(--text3);font-size:var(--fs-caption)}
.evdet[open] .evdet-sum::after{content:"\25B4"}

/* ---- guests ---- */
.gadd-wrap{border-top:.5px solid var(--sep);padding-top:var(--sp-3)}
.gadd-sum{cursor:pointer;list-style:none;min-height:44px;display:flex;align-items:center;
  font-weight:600;font-size:var(--fs-footnote);color:var(--ok-fg)}
.gadd-sum::-webkit-details-marker{display:none}
.gadd{background:var(--fill-2);border:.5px solid var(--sep);border-radius:var(--r-md);
  padding:var(--sp-3);margin:0}
.gadd-row{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap}
.gadd-row + .gadd-row{margin-top:var(--sp-2)}
.gadd-f{flex:1 1 160px;min-width:0}
.gadd-f input{margin:0}
.gadd-al{flex:1 1 200px}
.gadd-go{flex:0 0 auto;min-height:44px}
/* "3 of 8 guests have a recorded allergen - gluten x2, nuts x1" — the constraint in one line. */
.gtally{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:var(--fs-footnote);
  color:var(--text2);margin:0 0 var(--sp-3)}
.gtally b{color:var(--text)}
.gtally-x{display:inline-flex;align-items:center;gap:3px}
.gtally-n{font-weight:700;font-size:var(--fs-caption2);color:var(--text2);font-variant-numeric:tabular-nums}
.glist{list-style:none;margin:0;padding:0}
.gitem{border-top:.5px solid var(--sep);padding:var(--sp-3) 0}
.glist>li:first-child{border-top:0;padding-top:0}
.ghead{display:flex;flex-wrap:wrap;gap:6px var(--sp-2);align-items:center}
.gname{font-weight:600}
.gbadges{display:inline-flex;flex-wrap:wrap;gap:4px;align-items:center}
.gnote{color:var(--text2);font-size:var(--fs-footnote);margin:6px 0 0}
.gsum{cursor:pointer;font-size:var(--fs-caption);color:var(--ok-fg);font-weight:600;
  list-style:none;min-height:44px;display:flex;align-items:center}
.gsum::-webkit-details-marker{display:none}
.gform{margin-top:var(--sp-1)}
.gactions{display:flex;gap:var(--sp-2);align-items:center}

/* ---- the guest allergen picker (searchable; a bottom sheet on a phone) ---- */
.galwrap{min-width:0}
.gal-btn{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:44px;
  padding:var(--sp-2) var(--sp-3);border:.5px solid var(--sep);border-radius:var(--r-md);
  background:var(--bg-elev);color:var(--text);font:inherit;font-size:var(--fs-footnote);
  cursor:pointer;text-align:left}
.gal-btn:hover{border-color:var(--text3)}
.gal-chips{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:4px;align-items:center}
.gal-chip{display:inline-flex}
.gal-none{color:var(--text2)}
/* --ok-fg, never --accent, for small green TEXT: #34C759 is ~2.2:1 on --bg-elev and fails WCAG 1.4.3,
   which is the same reason this sheet's --focus ring is #0b6b2c and its warning text is --warn-fg
   rather than --orange. --ok-fg is 5.5:1 on white and 9:1 on OLED black. */
.gal-act{flex:0 0 auto;font-size:var(--fs-caption);font-weight:600;color:var(--ok-fg)}
/* [open] IS LOAD-BEARING: an unconditional display rule here beats the UA's
   `dialog:not([open]){display:none}`, and the dialog then renders permanently in flow and can never be
   closed. That shipped once on the product allergen picker; the template-lint suite pins it. */
.gal-dialog[open]{display:flex;flex-direction:column;max-width:460px;width:min(460px,94vw);
  max-height:80vh;overflow:hidden;border:.5px solid var(--sep);border-radius:18px;padding:0;
  background:var(--bg-elev);color:var(--text);box-shadow:var(--shadow-lift)}
.gal-dialog .ao-head{display:flex;align-items:baseline;gap:var(--sp-2);
  padding:var(--sp-4) var(--sp-4) var(--sp-3);background:var(--bg-elev)}
.gal-dialog .ao-head b{font-size:var(--fs-callout);font-weight:700;letter-spacing:-.01em}
.gal-filter{margin:0 var(--sp-4) var(--sp-2)}
.gal-rows{flex:1;min-height:0;overflow:auto;padding:0 var(--sp-4)}
.gal-dialog .ao-actions{padding:var(--sp-3) var(--sp-4);border-top:.5px solid var(--sep);
  margin:0;background:var(--bg-elev)}
.gal-nomatch{padding:0 var(--sp-4) var(--sp-2)}
/* One allergen per line, the whole line a 48px tap target — a glove on a phone must not have to find a
   14px checkbox. */
.gal-row{display:flex;align-items:center;gap:10px;min-height:48px;margin:0;padding:var(--sp-1) 0;
  border-top:.5px solid var(--hair);font-size:var(--fs-subhead);font-weight:500;color:var(--text);
  cursor:pointer}
.gal-row:first-child{border-top:0}
.gal-row.gal-hide{display:none}
.gal-cb{width:22px;height:22px;flex:0 0 auto;margin:0;accent-color:var(--accent)}
.gal-g{flex:0 0 auto;width:30px;height:30px;border-radius:50%;background:var(--fill-2);
  display:inline-flex;align-items:center;justify-content:center;font-size:16px;line-height:1}
.gal-lab{flex:1;min-width:0}
@media (max-width:560px){
  /* Bottom sheet: a thumb reaches the bottom of a phone, not the middle of it. */
  .gal-dialog[open]{position:fixed;inset:auto 0 0 0;margin:0;width:100%;max-width:100%;
    border-radius:18px 18px 0 0;max-height:86vh}
}
/* Scripting off: no chip button, and the dialog renders as a plain in-flow block so the fourteen
   checkboxes are still reachable and still post. Deliberately taller than the enhanced version — on
   allergen data, reachable beats compact. Same shape as the product picker's no-JS fallback. */
html:not(.cl-js) .gal-btn{display:none}
html:not(.cl-js) .gal-dialog{display:block!important;position:static;max-width:none;width:auto;
  border:0;box-shadow:none;background:transparent;padding:0;max-height:none;overflow:visible}
html:not(.cl-js) .gal-dialog .ao-head,
html:not(.cl-js) .gal-dialog .gal-filter,
html:not(.cl-js) .gal-dialog .ao-actions{display:none}

/* ---- menu: courses + dishes (carried over from the page's old <style> block) ---- */
.mnsec{border-top:.5px solid var(--sep);padding:var(--sp-4) 0 var(--sp-1)}
.mnsec:first-of-type{border-top:0;padding-top:0}
.mnsec-head{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:var(--sp-2)}
.mnsec-form{display:flex;gap:6px;align-items:center;margin:0;flex-wrap:wrap}
h3.mnsec-name.ro{font-size:var(--fs-callout);margin:0}
/* THE COURSE NAME IS NOW .inedit (2026-08-15). The `input.mnsec-name` rules that used to sit here are
   gone with the control: the heading was an <input> dressed as an <h3> beside a button labelled
   "Rename", and a chef pressing a COMMIT button that reads like a START button concluded the feature
   did not exist. .inedit is the app's own inline-title component (see .ie-title above) — a focusable
   heading with a quiet pencil, Enter/blur to save, Esc to revert — and it is fully delegated in
   app.js, which is what lets it work on a course head that arrived in an XHR payload.
   The rename INPUT still needs the course's type scale rather than the page-title scale .ie-input
   carries, or opening the editor doubles the height of the row. */
.mnsec-ie{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
.mnsec-ie .ie-input{font-size:var(--fs-callout);font-weight:600;min-width:150px}
.mnsec-ie .ie-title{font-size:var(--fs-callout);font-weight:600}
/* THE COURSE'S OWN MONEY, pushed to the end of the head row. Quiet by design: it is context for the
   dishes, not the heading. Absent entirely for a reader who may not see costs — the context has no
   `costs` key for a cook, so there is nothing here to style. */
.mnsec-money{margin-left:auto;font-size:var(--fs-footnote);color:var(--text2);white-space:nowrap}
.mnsec-m-k{color:var(--text3)}
.mnsec-m-sep{color:var(--text3);margin:0 4px}
.mnlist{list-style:none;margin:0;padding:0;min-height:38px;border-radius:var(--r-sm)}
.mnlist.mn-over{outline:2px dashed var(--accent);outline-offset:2px;background:var(--accent-tint)}
.mnitem{border:.5px solid var(--sep);border-radius:var(--r-sm);padding:var(--sp-2) 10px;
  margin-bottom:6px;background:var(--bg-elev)}
.mnitem.mn-dragging{opacity:.4}
/* A clashing dish is marked by a THICK LEFT RULE as well as colour, so it survives a colour-blind
   reader and a black-and-white print (the same rule the allergen badges follow). */
.mnitem.clash{border-left:4px solid var(--al-fg);background:var(--al-bg)}
/* A just-added row flashes once so the eye lands on it after a no-reload add. Animation only — no
   layout shift, and no colour that carries meaning on its own. */
@keyframes mn-landed{from{background:var(--accent-tint)}to{background:var(--bg-elev)}}
.mnitem.mn-landed{animation:mn-landed var(--dur-slow) var(--ease)}
@media (prefers-reduced-motion:reduce){.mnitem.mn-landed{animation:none}}
.mnrow{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:baseline}
.mn-grip{cursor:grab;color:var(--text3);font-size:var(--fs-footnote);letter-spacing:-1px;
  touch-action:none;align-self:center}
.mn-grip:active{cursor:grabbing}
.mn-name{font-weight:600}
/* THE GUEST WORDING, BESIDE THE KITCHEN NAME (2026-08-15). The board deliberately leads with the
   KITCHEN name — a chef hunting for "SEA BASS FILLET 220G" has to find it under that — so the card
   name is set quieter and in quotes rather than replacing it. Two facts on one row, and the chef can
   finally SEE that his guest-card edit landed. It wraps rather than truncating: a card name is
   copywriting, and half of it is worse than a second line. */
.mn-guest{color:var(--text3);font-size:var(--fs-footnote);font-style:italic;min-width:0}
.mn-qty{margin-left:auto;white-space:nowrap}
.mn-al{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.mn-note{font-size:var(--fs-footnote);margin-top:6px}
/* THE TWO TYPED FIELDS, ON THE RESTING ROW (2026-08-16). The kitchen note used to render only for a
   reader who could NOT manage the menu (the chef who typed it saw it clipped to 32 chars inside a
   collapsed drawer), and the guest-card description rendered nowhere at rest at all. See rule 4 in
   _event_menu_item.html: anything a chef can type on this row is legible at rest.

   THE COST IS PAID IN WEIGHT, NOT IN LINES. Footnote size, an 11px uppercase label, indented behind a
   2px rule — so the kitchen name still leads the row and a board of forty dishes reads as a list, not
   as a wall. Both fields are empty on most dishes, so most rows are unchanged.

   TWO AUDIENCES, THREE CHANNELS. A chef who types a kitchen note into the description box has written
   it onto a customer's menu, so the two lines must not read as one thing: the WORDS differ, the
   colour differs, and the left rule differs. Three channels, the same standard the clash callout is
   held to, so the distinction survives greyscale and colour-blindness — never colour alone.

   --accent-text, NOT --accent, for the guest-card line: green #34C759 is ~2.2:1 on the row's
   background in light mode and would fail WCAG on an 11px label. --accent-text IS the bright accent
   in dark mode by construction, so this is a light-mode-only correction (see :root, line ~33). */
.mn-typed{display:flex;flex-direction:column;gap:5px;margin-top:6px}
.mn-typed-l{display:flex;flex-wrap:wrap;gap:2px 7px;align-items:baseline;min-width:0;
  font-size:var(--fs-footnote);padding-left:8px;border-left:2px solid var(--sep)}
.mn-typed-w{flex:0 0 auto;font-size:var(--fs-caption2);font-weight:600;text-transform:uppercase;
  letter-spacing:.04em}
/* --text2 AND NOT --text3 FOR THE KITCHEN LABEL, measured in a browser 2026-08-16. --text3 is
   rgba(60,60,67,.3): composited on the white row that is 1.73:1, i.e. the label is not readable at
   all, while the GUEST CARD label beside it sits at 6.66:1. That is worse than merely faint -- the
   label is the channel that carries WHICH AUDIENCE the line is for, so an unreadable one collapses
   the whole distinction, and the mismatch would also have made the internal note read as the less
   important of the two. --text2 measures 4.74:1 light / 5.95:1 dark, both over the 4.5:1 floor.
   The label stays quiet by SIZE, CASE and TRACKING instead of by being nearly invisible. */
.mn-typed-k{color:var(--text2);border-left-color:var(--text2)}
.mn-typed-k .mn-typed-w{color:var(--text2)}
/* The guest-card line is italic as well as accented — it is the same voice as .mn-guest on the row
   above, which is the other half of the same card. */
.mn-typed-g{color:var(--text2);font-style:italic;border-left-color:var(--accent-text)}
.mn-typed-g .mn-typed-w{color:var(--accent-text);font-style:normal}
.mnclash{margin-top:var(--sp-2);padding-top:7px;border-top:.5px solid var(--sep)}
.mnclash-h{font-weight:600;color:var(--al-fg);font-size:var(--fs-footnote)}
.mnclash-l{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:var(--fs-footnote);
  margin-top:5px}
/* THE EDITOR DRAWER (2026-08-15). The board showed eleven controls per dish, forty times, which is
   why the two the chef went looking for were invisible. The resting row is now name, guest wording,
   portions and ALLERGENS; everything that changes the dish is one click down. The summary states the
   drawer's own values, so nobody has to open every dish to find the one they changed.
   The allergen badges and the clash callout are rendered ABOVE this drawer and never inside it — a
   badge behind a disclosure is a badge nobody reads, and allergens are the one surface in this app
   that must never hide. */
.mnedit-dr{margin-top:var(--sp-2)}
.mnedit-sum{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;
  font-size:var(--fs-footnote);color:var(--text2);border:.5px solid var(--sep);
  border-radius:var(--r-sm);padding:3px 9px;max-width:100%}
.mnedit-sum::-webkit-details-marker{display:none}
.mnedit-sum::after{content:"▾";color:var(--text3);font-size:11px}
.mnedit-dr[open] > .mnedit-sum::after{content:"▴"}
.mnedit-sum:hover{border-color:var(--text3);color:var(--text)}
@media (pointer:coarse){.mnedit-sum{min-height:44px}}
.mnedit{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:var(--sp-2)}
.mnedit-form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0}
.mn-portions{width:96px;margin:0}
.mn-inote{width:190px;margin:0}
.mn-sec{width:auto;min-width:120px;margin:0;font-size:var(--fs-caption)}
.mnempty{margin-bottom:var(--sp-2)}
.mnadd{margin:0}
.mnaddsec{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:var(--sp-4);
  border-top:.5px solid var(--sep);padding-top:var(--sp-4)}
.mnaddsec input{width:auto;min-width:180px;margin:0}
/* Touch: the reorder / move-to-course controls are the ones a drag cannot serve, so they get the full
   44px. Never rely on drag alone. */
@media (pointer:coarse){
  .mnmove{min-width:44px;min-height:44px;font-size:var(--fs-footnote)}
  .mn-sec{min-height:44px}
  .mnedit .btn{min-height:44px}
  .mn-grip{font-size:var(--fs-callout)}
}
/* The scripting-off dish <select>: invisible to anyone who has JS, i.e. to everyone who can see the
   search box above it. */
.cl-js .evnojs{display:none}
.evnojs{margin-top:var(--sp-4);border-top:.5px solid var(--sep);padding-top:var(--sp-4)}

/* ---- THE DISH SEARCH — the moment of decision ----
   Inline results, deliberately, not a floating panel: the app's combobox needs position:fixed
   re-anchoring on every scroll to escape main.content's overflow, and a panel that cannot be clipped
   beats one that has to be rescued. It also means the results push the page down instead of covering
   the course you are filling. */
.dsearch{margin-top:var(--sp-2)}
.dsearch-in{margin:0;min-height:44px}
.dsearch-out{margin-top:var(--sp-2);border:.5px solid var(--sep);border-radius:var(--r-md);
  background:var(--bg-elev);max-height:min(52vh,440px);overflow:auto;box-shadow:var(--shadow)}
.dsr-head{display:flex;align-items:center;gap:var(--sp-2);position:sticky;top:0;z-index:1;
  background:var(--bg-elev);padding:var(--sp-2) var(--sp-3);border-bottom:.5px solid var(--hair)}
.dsr-count{flex:1;min-width:0;font-size:var(--fs-caption);font-weight:600;color:var(--text2)}
.dsr-list{list-style:none;margin:0;padding:0}
.dsr{display:flex;gap:var(--sp-2);align-items:flex-start;padding:var(--sp-2) var(--sp-3);
  border-top:.5px solid var(--hair)}
.dsr:first-child{border-top:0}
/* A clashing result: thick left rule AND tint AND the words — three channels, so it survives
   colour-blindness and greyscale. It is NEVER disabled (owner decision B: warn, never block). */
.dsr.clash{border-left:4px solid var(--al-fg);background:var(--al-bg)}
/* The ＋ is the house .btn.primary, sized to a 44x44 square: it introduces no colour of its own (the
   fill and its text colour come from the existing component), and the primary action of the search
   deserves the primary control. Once tapped it flips to a ✓ in the ok tokens — the same row can be
   tapped again, because the same dish legitimately appears in two courses. */
.dsr-add{flex:0 0 auto;width:44px;height:44px;border-radius:var(--r-md);
  font-size:var(--fs-title3);line-height:1;justify-content:center;padding:0}
.dsr-add.added{background:var(--ok-bg);color:var(--ok-fg)}
.dsr-body{flex:1;min-width:0}
.dsr-name{font-weight:600;font-size:var(--fs-subhead);display:flex;align-items:center;gap:6px}
.dsr-often{color:var(--ok-fg);font-size:var(--fs-caption)}
.dsr-al{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
.dsr-clash{margin-top:6px;font-size:var(--fs-caption);color:var(--al-fg);
  display:flex;flex-wrap:wrap;gap:4px var(--sp-2);align-items:center}
.dsr-cl{display:inline-flex;align-items:center;gap:4px;color:var(--text2)}
.dsr-empty{padding:var(--sp-3);font-size:var(--fs-footnote)}
@media (pointer:coarse){.dsr{padding:var(--sp-3)}}

/* ---- print & plan ---- */
.evprints{display:flex;flex-wrap:wrap;gap:var(--sp-2);align-items:center}

/* ---- LAYOUT: phone tabs -> stacked -> two columns ---- */
@media (max-width:759.98px){
  .evpane{display:none}
  #evtab-details:checked ~ .evcols .evpane-details,
  #evtab-guests:checked ~ .evcols .evpane-guests,
  #evtab-menu:checked ~ .evcols .evpane-menu{display:block}
  .mn-qty{margin-left:0}
  .mn-inote{width:100%}
  .mn-portions{width:88px}
  .evstat{font-size:var(--fs-caption2)}
  .evbar{gap:6px var(--sp-3);padding:var(--sp-2) var(--sp-3)}
  /* THE CAP MUST EARN ITS SCREEN. It is permanently on an ~812px phone, so it carries only the
     constraint: covers + dish count go (both are one tap away), and the banner keeps its warning and
     its action but drops the detail the flagged rows repeat anyway. Measured: 248px -> ~145px.
     max-height is a floor-of-last-resort so a fourteen-allergen event can never swallow the page. */
  .evstat-side{display:none}
  .evclash-more{display:none}
  .evsticky{max-height:40vh;overflow:auto}
}
@media (min-width:760px){
  .evtabs{display:none}                 /* every pane is on screen; the control would be a lie */
}
@media (min-width:1080px){
  .evcols{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:var(--sp-5);
    grid-template-areas:"det det" "menu guests";align-items:start}
  .evpane-details{grid-area:det}
  .evpane-menu{grid-area:menu}
  /* THE POINT OF THE WHOLE LAYOUT: the guests and their allergen tally stay on screen while dishes are
     picked. `top` has to clear the app bar AND the sticky cap above it — measured at 123px with the
     clash banner showing, so 136px leaves a margin. Deliberately generous: overshooting costs a strip
     of empty column while scrolled, undershooting means the cap paints over the top of the guest list
     (it is z-index 6), which would hide the very rows this column exists to keep on screen. */
  .evpane-guests{grid-area:guests;position:sticky;
    top:calc(var(--ev-top,var(--appbar-h)) + 136px);align-self:start;
    max-height:calc(100vh - var(--ev-top,var(--appbar-h)) - 160px);overflow:auto}
}
@media print{
  .evsticky,.evtabs,.dsearch,.evnojs,.gadd{display:none!important}
  .evpane{display:block!important}
}

/* ============================================================================================
   STAFF FOOD PROGRAM — the canteen calendar (stage 3, core/stafffood.py).

   A DELIBERATE SECOND GRID, not a reuse of /calendar/'s. That page's `.caltbl` rules still live in a
   <style> block inside core/templates/core/calendar.html, so they are not reachable from here; lifting
   them out would have meant editing a committed template on the way past. The GRID MATHS is shared
   (core/utils.py) — which is the half that can silently disagree — while the paint is local and named
   `sf-*` so the two can be restyled independently. If calendar.html's block ever moves into this file,
   merge them then.

   The state a cell has to carry is not "does it have dishes" but PROJECTED vs MATERIALISED (R1), and
   they must be distinguishable at a glance across a whole month: a projected day is tinted and its
   dishes are outlined (the plan as the rules predict it), a materialised day is plain with solid chips
   (what the kitchen was actually told to cook). Colour is never the only carrier — the outline/fill
   difference survives a colour-blind reader and a black-and-white print of the month.
   ============================================================================================ */
.sf-nav{display:flex;align-items:center;gap:10px;margin:0 0 14px;flex-wrap:wrap}
.sf-nav .sf-label{font-weight:700;font-size:var(--fs-subhead)}
.sf-seg{display:inline-flex;gap:4px;background:var(--fill);border:1px solid var(--sep);
  border-radius:var(--r-md);padding:4px}
.sf-seg a{display:inline-flex;align-items:center;gap:6px;color:var(--text2);font-size:14px;
  font-weight:600;border-radius:9px;padding:7px 14px;transition:background .14s,color .14s}
.sf-seg a:hover{color:var(--text)}
.sf-seg a.on{background:var(--accent);color:#fff}
.sf-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
.sf-tbl th{padding:6px 5px;font-size:var(--fs-caption2);text-transform:uppercase;letter-spacing:.4px;
  color:var(--text3);text-align:left;border-bottom:.5px solid var(--sep)}
.sf-tbl td.sf-cell{border:.5px solid var(--hair);vertical-align:top;padding:5px 6px;height:104px;
  position:relative}
.sf-tbl.sf-week td.sf-cell{height:150px}
/* No hover override here on purpose: the global `tbody tr:hover` tint sits BEHIND these cells (it paints
   the row, they paint themselves), so it reads as a gentle row highlight exactly as it does on
   /calendar/. An earlier `td.sf-cell:hover{background:transparent}` was worse than useless — being more
   specific than `.sf-cell.sf-today`, it stripped today's accent tint the moment you moved the mouse. */
.sf-tbl td.sf-cell.sf-out{background:var(--fill-2);opacity:.5}
.sf-tbl td.sf-cell.sf-today{background:var(--accent-tint)}
/* PROJECTED = the rules' prediction. Tinted so a month of untouched days reads as one block. */
.sf-tbl td.sf-cell.sf-proj{background:var(--fill-2)}
.sf-tbl td.sf-cell.sf-proj.sf-today{background:var(--accent-tint)}
.sf-date{font-size:var(--fs-caption);font-weight:700;color:var(--text2);margin-bottom:3px;
  display:flex;align-items:center;gap:4px}
.sf-date a{color:inherit}
/* The ＋ sits at the far RIGHT of the date row (chef 2026-07-30: tick dishes onto a day from the grid).
   margin-left:auto rather than a fixed gap: the day number and the ~ projected flag keep the left, so the
   button lands in the same place in every cell whether or not that flag is there — a target that shifts by
   a few pixels between cells is one a chef mis-taps on a tablet. Reuses .rota-add so it is the same control
   the rota already taught him. */
.sf-date .rota-add{margin-left:auto}
/* THE ＋ WAS INVISIBLE ON THIS PAGE FOR TWO DAYS (chef 2026-08-01: "add the Plus icon in the days which do
   not have the food programm" — it was already in every cell). .rota-add ships opacity:0 and the ONLY rule
   that raises it keys on `.grid-cell`, which is the ROTA's cell class (schedule.html). Staff-food cells are
   `.sf-cell`, so with a mouse the button never appeared here at all; it surfaced only via :focus-visible on
   Tab, or under (pointer:coarse) on a tablet, which is why it read as "some days have no ＋".
   Scoped to .sf-tbl rather than added to the .grid-cell rule so the rota's behaviour cannot move —
   test_rota_cell_add.py pins it. Every markup assertion in test_staff_food_day_picker.py passed throughout,
   which is the lesson: a control can be present, correct, tested and still unusable. */
.sf-tbl td.sf-cell:hover .rota-add,.sf-tbl td.sf-cell:focus-within .rota-add{opacity:1}
/* THE EMPTY-DAY ＋, and it is deliberately NOT hover-gated. A day with no food programme is the day the
   chef came here to act on, so its control is the one that must be findable without knowing to hover —
   that is what he actually asked for. A cell renders EITHER this or the date-row ＋, never both (see
   staff_food.html): two buttons carrying the same aria-label in one 104px cell is a screen-reader trap and
   a mis-tap target on a tablet. Block, so it sits under "nothing cooked" rather than beside it in a cell
   that is only ~104px tall. --accent-text for the same 2.2:1 reason as above. */
.sf-add{display:block;margin-top:3px;border:0;background:none;cursor:pointer;text-align:left;
  color:var(--accent-text);font-size:var(--fs-caption2);line-height:1;padding:3px 5px;
  border-radius:var(--r-sm);opacity:.9;
  transition:background var(--dur-fast) var(--ease),opacity var(--dur-fast) var(--ease)}
.sf-add:hover,.sf-add:focus-visible{background:var(--accent-tint);opacity:1}
@media (pointer:coarse){.sf-add{min-height:44px;padding:6px 8px}}   /* touch target, same as .rota-add */
/* The tick list. A fixed max-height with its own scroll, so a kitchen with forty staff dishes gets a
   scrollable list inside the sheet instead of a sheet taller than the viewport with its Add button off the
   bottom edge — the failure the allergen picker hit when 14 tiles grew a flex item ~900px tall. */
.sfp-list{max-height:min(46vh,340px);overflow-y:auto;border:1px solid var(--sep);
  border-radius:var(--r-ctrl);padding:4px;margin-top:6px}
.sfp-row{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--r-xs);cursor:pointer}
.sfp-row:hover{background:var(--fill-2)}
/* 44px min so the whole row is the tap target on a tablet, not just the 16px box. */
.sfp-row input{width:18px;height:18px;flex:0 0 auto}
.sfp-row span{min-height:26px;display:flex;align-items:center}
/* THE DISH CHIP IS NOW A TWO-COLUMN FLEX ROW: name on the left, portion count hard right (chef
   2026-08-13: "I want in the calendar to be visible the number of portions alligned right"). It used to be
   display:block and owned the ellipsis itself; the ellipsis moves down to .sf-dish-n with the shrinking.
   min-width:0 ON .sf-dish-n IS LOAD-BEARING, NOT TIDINESS. A flex item defaults to min-width:auto and
   refuses to shrink below its min-content width. The chip sits in a table-layout:fixed column that cannot
   grow to absorb the difference, so without it a long dish name pushes the count past the chip's own
   overflow:hidden and THE COUNT is what gets clipped away — the one thing that was asked for, invisible,
   with every HTML assertion still green. That is the same failure mode as the ＋ that shipped opacity:0 for
   two days (see above), so it is pinned from the stylesheet source in test_staff_food_calendar_chips.py. */
.sf-dish{display:flex;align-items:baseline;gap:4px;font-size:var(--fs-caption2);
  border-radius:var(--r-xs);padding:2px 6px;margin:2px 0;overflow:hidden;
  background:var(--ok-bg);color:var(--ok-fg)}
.sf-dish-n{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* margin-left:auto as well as flex:0 0 auto, so the count still sits on the right edge in a cell whose
   name is short — flex-end alignment of the row would move the NAME instead. tabular-nums matches the
   house .num rule so a column of counts down a week lines up digit for digit. */
.sf-dish-p{flex:0 0 auto;margin-left:auto;font-variant-numeric:tabular-nums;font-weight:600;opacity:.8}
/* Outlined = projected. Shape, not just colour, so the distinction survives a mono printout. */
.sf-dish.sf-dish-proj{background:transparent;color:var(--text2);border:1px dashed var(--text3)}
/* A dish is a LINK to its kitchen spec sheet (chef 2026-08-13: "Clicable items in Calendar"). Copied from
   a.cev on /calendar/, which is the same control in the same shaped cell: the chip keeps its token
   colours, only the UA underline is dropped, and the affordance is the hover. */
a.sf-dish{text-decoration:none;cursor:pointer}
a.sf-dish:hover,a.sf-dish:focus-visible{filter:brightness(1.06);text-decoration:underline}
/* THE CATEGORY HEADING (chef 2026-08-13: "the items per day shall be shown by Category of the Recipy").
   Same treatment as .sf-tbl th, so a heading inside a cell reads as a smaller instance of the heading
   above the column rather than as a new kind of thing. Ellipsised: "Cold Sauces & Dressings" must not
   widen a cell in a table-layout:fixed grid. It never falls first in a cell — .sf-date does — so the
   top margin is cancelled on the one that follows the date row. */
.sf-grp{font-size:var(--fs-caption2);text-transform:uppercase;letter-spacing:.4px;color:var(--text3);
  font-weight:700;line-height:1.25;margin:5px 0 1px;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sf-date + .sf-grp{margin-top:0}
.sf-none{font-size:var(--fs-caption2);color:var(--text3)}
.sf-flag{font-size:var(--fs-caption2);color:var(--text3);font-weight:600}
.sf-rulerow{display:flex;gap:var(--sp-3);align-items:flex-end;flex-wrap:wrap;margin-top:var(--sp-2)}
.sf-rulerow label{display:block;font-size:var(--fs-caption);color:var(--text2);margin-bottom:3px}
.sf-wd{font-weight:700;font-size:var(--fs-subhead);margin:var(--sp-4) 0 var(--sp-1)}
@media(max-width:760px){
  .sf-tbl td.sf-cell{height:auto;min-height:60px;padding:3px}
  .sf-tbl.sf-week td.sf-cell{height:auto;min-height:70px}
  .sf-tbl th,.sf-date{font-size:var(--fs-caption2)}
}
/* THE COUNT TAKES ITS OWN LINE BELOW A SMALL LAPTOP, still right-aligned. 1023px and NOT the 760px
   breakpoint above, because the tablet is the worst case here rather than the phone: both grids are seven
   fixed columns at EVERY width, so the cell narrows continuously while the count stays ~18px wide.
   Measured in Chrome 2026-08-13, name width left for "Slow-Roasted Lamb Shoulder..." beside a 3-digit
   count, with the count inline:
       1280px -> 89px (~15 chars)      1024px -> 52px (~9)
       1152px -> 70px (~12)             900px -> 34px (~6)
       1100px -> 63px (~11)             820px -> 23px (~4)
                                        768px -> 16px (~2 chars — an iPad in portrait)
   Two characters of a dish name is not a dish name, and 768 is a real device, so the wrap starts at the
   first width where the inline layout stops paying: below 1024 the name gets the whole chip instead
   (~15 chars at 900px) and the count drops to a second line. It costs a line of cell height; the
   alternative costs the thing you open the calendar to read. margin-left:auto already right-aligns it
   once it is alone on that line — width:100% is belt and braces for a 4-digit count. */
@media(max-width:1023px){
  .sf-dish{flex-wrap:wrap}
  .sf-dish-p{width:100%;text-align:right}
}
/* TOUCH: a 32px FLOOR, not the 44px the ＋ buttons in the same cell use — and measurement is why, not
   taste. On a phone the chip is already two lines (the count wrapped above), so it measures 43.9px in
   Chrome at 375px and 768px with touch emulation on: the house 44px is what a thumb actually gets there,
   and this floor never binds. It binds only on a LARGE touchscreen (>=1024px, where the chip is still one
   line), and forcing 44px there would take a four-dish cell from ~104px to ~190px and turn the month
   grid — the view whose whole purpose is a month at a glance — into six tall rows. 32px clears the WCAG
   2.2 AA target-size minimum of 24px comfortably and is nearly twice the ~17px the equivalent control on
   /calendar/ (a.cev) has shipped with for months. The ＋ buttons keep 44px: those WRITE. (2026-08-13) */
@media (pointer:coarse){a.sf-dish{min-height:32px;align-items:center;padding:4px 6px}}


/* ==================================================================================================
   THE SHORT-LIST MENU. Replaces the OS-drawn open list of a <select data-selectmenu> with one of ours. See
   the block of the same name in app.js for why this is opt-in and not applied to every select: on iPad
   the OS list is the iOS wheel picker, and it beats anything we would build with a wet finger.

   The native control is kept in the DOM as the form field, so it is hidden the way a control that must
   stay functional is hidden (clipped, not display:none) rather than removed.
   ================================================================================================== */
.mnu{position:relative;display:inline-flex;max-width:100%}
.mnu-native{position:absolute;width:1px;height:1px;min-height:0;padding:0;margin:0;border:0;
  opacity:0;overflow:hidden;pointer-events:none}
.mnu-btn{display:inline-flex;align-items:center;gap:8px;max-width:220px;min-height:36px;
  padding:7px 12px;border:1px solid var(--sep);border-radius:var(--r-pill);background:var(--bg-elev);
  color:var(--text);font:inherit;font-size:13px;font-weight:600;line-height:1.2;cursor:pointer;
  transition:background var(--dur-fast) linear}
.mnu-btn:hover,.mnu-btn[aria-expanded="true"]{background:var(--fill)}
.mnu-btn:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.mnu-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
/* A drawn chevron rather than a glyph: a text arrow is at the mercy of whatever font the platform
   substitutes, which is how you end up with three different arrows on three machines. */
.mnu-caret{width:7px;height:7px;flex:none;border-right:2px solid currentColor;
  border-bottom:2px solid currentColor;transform:rotate(45deg) translate(-2px,-2px);opacity:.5}
.mnu-list{position:absolute;top:calc(100% + 6px);left:0;z-index:60;min-width:100%;
  max-width:min(320px,90vw);max-height:min(60vh,380px);overflow-y:auto;padding:6px;
  background:var(--bg-elev);border:1px solid var(--sep);border-radius:14px;box-shadow:var(--shadow-lift)}
.mnu-opt{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;
  min-height:44px;border-radius:10px;cursor:pointer;font-size:14px;color:var(--text)}
.mnu-optname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
/* .on is the keyboard cursor, :hover the pointer one. They are the same colour on purpose — there is
   only ever one "where am I" in this list. */
.mnu-opt:hover,.mnu-opt.on{background:var(--fill)}
.mnu-opt[aria-selected="true"]{font-weight:600;color:var(--accent-text)}
.mnu-tick{flex:none;font-weight:700;opacity:0}
.mnu-opt[aria-selected="true"] .mnu-tick{opacity:1}
.mnu-opt[aria-disabled="true"]{opacity:.45;cursor:default}
@media (max-width:768px){ .mnu-opt{min-height:48px;font-size:15px} }
