/*
 * navshell.css — pill-based primary nav, lifted from
 * Mirepoix_Design_System-2026-05-27/ui_kits/mirepoix/styles.css (nav sections).
 *
 * Adaptations from the source:
 *   - All hardcoded colors replaced with token references (--ground,
 *     --red, --red-soft, --red-deep, --red-glow, --pill-bg, etc.).
 *     PR 1 added these tokens to base.css as aliases or new entries.
 *   - Drops anything that touches .appwrap or .paper (page chrome
 *     is out of scope for this port).
 *   - data-cramped is read from .navshell itself (no .appwrap host).
 *   - .navshell uses position: sticky; top: 0; z-index: 30; instead of
 *     the design system's absolute-inside-appwrap positioning.
 *
 * Collaborators:
 *   - base.css (token vocabulary)
 *   - app/views/shared/_navshell.html.erb (markup)
 *   - app/views/shared/_brand_menu.html.erb (popover)
 *   - app/javascript/controllers/nav_shell_controller.js (width measurement)
 *   - app/javascript/controllers/brand_menu_controller.js (popover)
 *
 * Reference (read-only): the design-system styles.css uses comment-divider
 * markers (── pill primitive ──, ── pill variant: brand ──, etc.) to signpost
 * each section; this file preserves them. Do not write `*` `/` next to each
 * other inside this comment — it terminates the outer block early and the
 * parser eats the next rule.
 */

/* ============================================================================
 * .pill — the one chrome primitive every nav surface inherits.
 * Opaque paper capsule with a top-edge highlight, paper-noise overlay,
 * and the system's offset-SE shadow.
 * ========================================================================= */

/* .brand-host wraps the brand pill and its popover so the brand-menu
 * Stimulus controller has both as descendants. display: contents keeps
 * the wrapper layout-transparent — the surrounding slot sees the button
 * and popover as direct children, so positioning isn't affected. */
.brand-host {
  display: contents;
}

.pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  box-sizing: border-box;
  background: var(--pill-bg);
  border: 1px solid var(--pill-border);
  border-radius: var(--radius-pill);
  box-shadow: var(--pill-shadow);
  isolation: isolate;
  color: var(--text);
  font: inherit;
  -webkit-tap-highlight-color: transparent;
}
.pill::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("/assets/paper-noise-4ecbafa7.svg");
  background-size: var(--noise-tile) var(--noise-tile);
  border-radius: inherit;
  opacity: var(--paper-opacity);
  pointer-events: none;
}

/* =============================================================================
 * NAV BUTTON STATE SYSTEM
 *
 * Four button types share one interactive vocabulary:
 *   .pill--brand       — the Mirepoix wordmark
 *   .destination       — destinations inside the host destinations pill
 *   .pill--icon        — the search and help icon buttons
 *   .brandmenu__item   — rows inside the brand-menu popover
 *
 * Shared treatments (this block):
 *   :hover  → layer a red-soft background tint as a gradient image, so it
 *             stacks on top of the existing paper bg-color on free-standing
 *             pills, and stands on its own on transparent nested buttons.
 *             Skipped on the current destination (its inset-card chrome
 *             would fight the hover bg).
 *
 * Per-variant treatments (later blocks):
 *   inner halo glows  — each button has its own anatomy of icons/text
 *   focus indicator   — outline ring on outer pills, emboss on menu rows
 *   press deboss      — same recipe (--shadow-deboss / --shadow-deboss-deep)
 *   inner content shift on press — translate or padding shift
 *   open / current / modal-active — each is structurally different
 *
 * Tokens carry the shadow + halo recipes; dark-mode overrides flow through
 * the tokens, so the per-variant rules never need their own dark fork. */

/* :hover — shared background tint.
 *
 * Two members opt out (#3680). The brand pill drops out while its menu is
 * open: the open state already carries the red + halo, so a hover tint on top
 * only adds a second lit element competing with the menu's active row. And
 * brand-menu rows aren't here at all — their active row is focus-driven, not
 * hover-driven; see the .brandmenu__item state block. */
@media (any-hover: hover) {
  :is(.pill--brand:not([aria-expanded="true"]),
      .destination:not([aria-current="page"]),
      .pill--icon):hover {
    background-image: linear-gradient(var(--red-soft), var(--red-soft));
  }
}
.pill--brand.is-hover,
.destination.is-hover,
.pill--icon.is-hover,
.brandmenu__item.is-hover {
  background-image: linear-gradient(var(--red-soft), var(--red-soft));
}

/* ─── Pill variant: brand ───────────────────────────────────── */

/* Base */
.pill--brand {
  height: 48px;
  padding: 0 14px 0 10px;
  gap: 8px;
  cursor: pointer;
  transition:
    color var(--duration-fast),
    background var(--duration-fast),
    box-shadow var(--duration-fast);
}
.brand__mark {
  width: 20px; height: 20px;
  display: block;
  /* Inner radius rhymes with the pill's full-round outer edge — a soft
   * concentric corner (outer 24px radius − 14px mark inset ≈ 10px, eased
   * to 5px so the mark still reads as a square color-field, just softened
   * at the corners). */
  border-radius: 5px;
  overflow: hidden;
  /* Keep the mark above .pill::before's paper-noise overlay at all times. The
   * wordmark and caret carry permanent transforms, which already promote them
   * over the (absolutely positioned) noise; the mark had neither transform nor
   * filter at rest, so it alone rendered UNDER the noise — then any state that
   * adds a filter (hover, the #2629 pin flash) promoted it for the duration and
   * it visibly snapped opaque and back. Pinning it above makes its paint order
   * state-independent, and the texture stopping at the logo reads crisper. */
  position: relative;
  transition: transform var(--duration-fast), filter var(--duration-fast);
}
/* Mark internals are opaque quadrant fills baked into the SVG assets.
 * iOS Safari flattens semi-transparent overlapping SVG bars inside this
 * pill chrome, even when the SVG is loaded through <img>. */
/* Wordmark glyph — the cursive "mirepoix" mask, filled by currentColor. Sized
 * by height so it tracks the 20px coral mark; width derives from
 * --wordmark-aspect. Like the mark + caret it keeps its ink tone on hover/open
 * and gains the backlit-paper halo — the prior live-text wordmark behaved the
 * same way (a --text-soft body with a red glow, never a red fill). */
.pill--brand .wordmark__glyph {
  height: 1.7rem;
  color: var(--text-soft);
  /* Optically centre the wordmark on the coral mark. The flex row centres the
   * glyph BOX, but the eye tracks the ink centre of mass, which sits above the
   * box centre — so nudge the glyph down by the generator-derived shift. This
   * lands the mark's centreline through the wordmark's visual middle with the
   * 'p' descender hanging below. Mathematical, not measured — see
   * --wordmark-optical-shift in base.css. */
  transform: translateY(var(--wordmark-optical-shift));
  transition: transform var(--duration-fast), filter var(--duration-fast);
}
.brand__caret {
  /* Composed transform: rotate is set by [aria-expanded="true"] in the
   * open state below, translateY is set by :active in the press state.
   * Each modifies one custom property, so press + open stack instead of
   * clobbering each other.
   *
   * The glyph is a downward chevron at rest, morphing to × on open via
   * two motions running together on the inner <g>:
   *   1. The two strokes' bottom endpoints slide apart horizontally to
   *      cross at the centre (positions change, orientation doesn't).
   *   2. The group spins 90° clockwise — the rotation is purely a
   *      flourish (the strokes are already at ±45° so a 90° turn leaves
   *      them at ±45°, still forming an ×), but with --ease-bounce it
   *      overshoots ~10°, rubber-bands back, and gives the morph its
   *      physical, momentum-laden feel. Lifted from LoveFrom's info
   *      button — a one-stroke i→× spin with the same overshoot.
   * overflow: visible so the spring's overshoot doesn't clip strokes
   * against the SVG viewport edge. */
  --caret-y: 0px;
  color: var(--text-light);
  /* Optically centre the wordmark between the mark and the caret. The chevron's
   * viewBox pads its strokes with empty box on the leading edge, and the cursive
   * mask sits tight to its ink — so the +2px this carried (tuned for the old
   * live-text wordmark) over-spaced the caret. A small negative margin pulls the
   * caret's ink in to mirror the mark→wordmark gap, fixing the right-side bias
   * from #2142. */
  margin-left: -2px;
  overflow: visible;
  transform: translateY(var(--caret-y));
  transition:
    transform var(--duration-fast),
    color var(--duration-fast),
    filter var(--duration-fast);
}
.brand__caret-g {
  transform-box: fill-box;
  transform-origin: center;
  transform: rotate(0deg);
  transition: transform 0.22s var(--ease-bounce);
}
.brand__caret-l,
.brand__caret-r {
  transition: transform 0.22s var(--ease-bounce);
}

/* States — brand pill.
 *   :hover         → wordmark, mark, and caret turn red with a backlit-paper
 *                    halo. Background tint is handled by the shared :hover
 *                    rule above.
 *   :focus-visible → red outline, offset so the pill silhouette stays
 *   :active        → red-soft-2 tint over opaque pill paper + deboss-deep
 *                    shadow. Inner contents (mark, name, caret) translate
 *                    down 1 px so they settle into the recess. The caret uses
 *                    custom-prop composition so the press translate stacks
 *                    with the open rotate.
 *   [aria-expanded="true"] → same red + halo as hover, plus the caret flips.
 *                    The caret rotation is the differentiator from hover.
 */

/* :hover — halos on inner elements (background tint is shared above) */
@media (any-hover: hover) {
  .pill--brand:hover { color: var(--red); }
  .pill--brand:hover .brand__mark { filter: var(--halo-icon); }
  .pill--brand:hover .wordmark__glyph { filter: var(--halo-icon); }
  .pill--brand:hover .brand__caret {
    color: var(--red);
    filter: var(--halo-icon);
  }
}
.pill--brand.is-hover { color: var(--red); }
.pill--brand.is-hover .brand__mark { filter: var(--halo-icon); }
.pill--brand.is-hover .wordmark__glyph { filter: var(--halo-icon); }
.pill--brand.is-hover .brand__caret {
  color: var(--red);
  filter: var(--halo-icon);
}

/* :focus-visible */
.pill--brand:focus-visible,
.pill--brand.is-focus {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* :active — deboss-deep + inner content settles 1 px down */
.pill--brand:active,
.pill--brand.is-pressed {
  background: var(--pill-bg);
  background-image: linear-gradient(var(--red-soft-2), var(--red-soft-2));
  box-shadow: var(--shadow-deboss-deep);
}
.pill--brand:active .brand__mark,
.pill--brand.is-pressed .brand__mark {
  transform: translateY(1px);
}
.pill--brand:active .wordmark__glyph,
.pill--brand.is-pressed .wordmark__glyph {
  /* Compose with the resting optical shift so press still settles 1px down. */
  transform: translateY(calc(var(--wordmark-optical-shift) + 1px));
}
.pill--brand:active .brand__caret,
.pill--brand.is-pressed .brand__caret { --caret-y: 1px; }

/* [aria-expanded="true"] — menu open */
.pill--brand[aria-expanded="true"],
.pill--brand.is-open { color: var(--red); }
.pill--brand[aria-expanded="true"] .brand__mark,
.pill--brand.is-open .brand__mark { filter: var(--halo-icon); }
.pill--brand[aria-expanded="true"] .wordmark__glyph,
.pill--brand.is-open .wordmark__glyph { filter: var(--halo-icon); }
.pill--brand[aria-expanded="true"] .brand__caret,
.pill--brand.is-open .brand__caret {
  color: var(--red);
  filter: var(--halo-icon);
}
.pill--brand[aria-expanded="true"] .brand__caret-g,
.pill--brand.is-open .brand__caret-g { transform: rotate(90deg); }
.pill--brand[aria-expanded="true"] .brand__caret-l,
.pill--brand.is-open .brand__caret-l { transform: translateX(1.5px); }
.pill--brand[aria-expanded="true"] .brand__caret-r,
.pill--brand.is-open .brand__caret-r { transform: translateX(-1.5px); }

/* Pin/unpin attention cue (#2629, reworked #3109). Pinning or unpinning a recipe
 * changes the pinned list inside the (closed) Mirepoix menu with no on-screen hint;
 * this briefly pulses the brand pill so the eye lands on the menu. Applied in the
 * acting tab only — brand_menu_controller#flash toggles the class off a client-side
 * recipe-pin:toggled; observing tabs converge through the broadcast morph without
 * theatre.
 *
 * Two tracks, transform + opacity only, NO animated filter on the SVG. The prior
 * cue animated filter: drop-shadow on the mark/wordmark/caret inside .pill's
 * isolated stacking context; on iOS Safari that misfired as a "backfire" glow that
 * popped AFTER animationend — a compositing-teardown paint artifact below the CSS
 * animation lifecycle that no JS gating could catch (#3109, the fourth iOS defect
 * on this element). The replacement is a red-soft tint wash (::after opacity) plus
 * a gentle whole-pill scale pop; both are GPU-cheap and free of the
 * filter-inside-isolation hazard. The caret still does NOT spin — that reads as the
 * menu opening.
 *
 * The scale is real motion, unlike the old colour-only cue. It needs no bespoke
 * fork: the global prefers-reduced-motion rule in base.css collapses every
 * animation to 0.01ms, so the pop (and the tint) are neutralised for users who ask
 * for less motion.
 *
 * Shaped like one incandescent-bulb pulse: peak at ~28% for a quick ramp up, then a
 * longer decay. --ease-curtain (ease-out) governs both rise and fall. It must play
 * once, uninterrupted — the pin's originator morph is held off the pill's class by
 * brand_menu_controller#guardFlashMorph so idiomorph can't restart it into a
 * flicker. Both tracks share this 0.7s timing so the pill pulses as one;
 * brand-pill-flash (on the ::after tint) is what endFlash keys on. */
/* The ::after carries an explicit base opacity: 0 so its RESTING state is invisible
 * no matter the animation-fill-mode or whether endFlash's class-removal fires. The
 * keyframe's 100%-opacity-0 holds only during the run — fill-mode: none reverts to
 * the base afterward, so without this the tint would stick fully painted (#3109
 * investigation). The tint rides opacity, not an animated background-image: a
 * linear-gradient between transparent and --red-soft steps at its midpoint instead
 * of interpolating, which used to snap the wash off partway through the decay.
 * z-index: -1 (contained by .pill's isolation) sits it above the pill paper but
 * below the mark/wordmark, matching the menu-open wash. */
.pill--brand-flash {
  animation: brand-pill-pop 0.7s var(--ease-curtain);
}
.pill--brand-flash::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: var(--red-soft);
  opacity: 0;
  pointer-events: none;
  animation: brand-pill-flash 0.7s var(--ease-curtain);
}
@keyframes brand-pill-flash {
  0%, 100% { opacity: 0; }
  28%      { opacity: 1; }
}
@keyframes brand-pill-pop {
  0%, 100% { transform: scale(1); }
  28%      { transform: scale(1.05); }
}

/* ─── Pill variant: destinations (the floating menu plate) ─── */

/* Base */
.pill--destinations {
  height: 48px;
  /* padding(3) + border(1) = 4px ring on every side, matching the inter-item gap.
   * Inner padding-box is exactly 40px, the destination button's height. */
  padding: 3px;
  gap: 4px;
  /* override .pill's inline-flex with proper flex so children layout cleanly */
  display: flex;
  /* Native tab-bar semantics (#4542 round 5): touch-down activation commits a
   * navigation the moment a finger lands, so a page-scroll gesture must not be
   * able to BEGIN on this pill — same trade native tab bars make. Pinch/pan
   * starting anywhere else is unaffected. */
  touch-action: none;
}

.destination {
  /* Native tab-bar input semantics (#4542 round 5): taps commit at
   * touch-down, so the mouse-era link affordances come off — no long-press
   * "Open in New Tab" callout, no text selection. Applied to ALL
   * destinations (the controller's preventDefault only reaches non-current
   * ones) so the current pill behaves consistently with its neighbors. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  background: none;
  border: none;
  border-radius: var(--radius-pill);
  padding: 2px 14px 3px;
  min-width: 60px;
  color: var(--text-soft);
  text-shadow: 0 0 0 transparent;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 0.62rem;
  letter-spacing: 0.04em;
  -webkit-tap-highlight-color: transparent;
  transition:
    background var(--duration-fast),
    color var(--duration-fast),
    box-shadow var(--duration-fast),
    text-shadow var(--duration-fast);
}
.destination__icon {
  display: flex;
  filter: drop-shadow(0 0 0 transparent);
  transition: filter var(--duration-fast), transform var(--duration-fast);
}
.destination__icon > svg { display: block; }
.destination__label {
  line-height: 1;
  transition: transform var(--duration-fast);
}

/* States — destination.
 *   :hover         → red text/icon + backlit-paper halo. Background tint is
 *                    handled by the shared :hover rule above (which skips
 *                    the current page so its inset-card chrome isn't fought
 *                    by the hover bg).
 *   :focus-visible → red outline + red text, offset 1 px because the parent
 *                    pill is tight (2 px offset would clip the next sibling)
 *   :active        → red-soft-2 tint over opaque paper + red-deep text +
 *                    deboss. Inner icon + label translate down 1 px. Wins
 *                    over [aria-current] so the current button still gives
 *                    press feedback.
 *   [aria-current="page"] → emboss — a recessed paper plate inside the host
 *                    pill.
 */

/* :hover — halos on text and icon. Applies even when the destination is
 * the current page — the halo glow is a touch of life on top of the emboss
 * (and matches every other interactive button in the system). Only the bg
 * tint above stays gated off, because it would flatten the inset card. */
@media (any-hover: hover) {
  .destination:hover {
    color: var(--red);
    text-shadow: var(--halo-text);
  }
  .destination:hover .destination__icon {
    filter: var(--halo-icon);
  }
}
.destination.is-hover {
  color: var(--red);
  text-shadow: var(--halo-text);
}
.destination.is-hover .destination__icon { filter: var(--halo-icon); }

/* :focus-visible */
.destination:focus-visible,
.destination.is-focus {
  outline: var(--focus-ring);
  outline-offset: 1px;
  color: var(--red);
}

/* :active — deboss + inner content settles 1 px down. The
 * [aria-current="page"]:active selector is at specificity (0,2,1) so it
 * beats the [aria-current] (0,2,0) emboss below — clicking the current page
 * (a no-op) still gives press feedback. */
.destination:active,
.destination[aria-current="page"]:active,
.destination.is-pressed {
  background: var(--content-card-bg);
  background-image: linear-gradient(var(--red-soft-2), var(--red-soft-2));
  color: var(--red-deep);
  box-shadow: var(--shadow-deboss);
}
.destination:active .destination__icon,
.destination:active .destination__label,
.destination[aria-current="page"]:active .destination__icon,
.destination[aria-current="page"]:active .destination__label,
.destination.is-pressed .destination__icon,
.destination.is-pressed .destination__label {
  transform: translateY(1px);
}

/* [aria-current="page"] — the current page */
.destination[aria-current="page"],
.destination.is-current {
  color: var(--red);
  background: var(--content-card-bg);
  box-shadow: var(--shadow-emboss);
}

/* ─── Pill variant: icon-only square (search, help) ─────────── */

/* Base */
.pill--icon {
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: var(--text-soft);
  padding: 0;
  transition:
    color var(--duration-fast),
    background var(--duration-fast),
    box-shadow var(--duration-fast);
}
.pill--icon > svg,
.pill--icon > [data-icon] > svg { display: block; }
.pill--icon > [data-icon],
.pill--icon > svg {
  transition: filter var(--duration-fast), transform var(--duration-fast);
}

/* Label modifier — pill--icon with a visible text label alongside its icon.
 * Used for the Sign-in pill in the right cluster (icon-only is harder to
 * discover for non-recurring controls). Overrides the fixed 48 px width and
 * zero padding so the label and icon both have room. */
.pill--icon.pill--with-label {
  width: auto;
  padding: 0 14px;
  gap: 8px;
}
.pill__label {
  font-family: var(--font-body);
  font-size: var(--type-sm);
  font-weight: 500;
  letter-spacing: 0.02em;
  color: inherit;
}

/* States — icon pill (search, help).
 *   :hover         → icon turns red with a backlit-paper halo. Background
 *                    tint is shared above.
 *   :focus-visible → red outline + red icon, offset 2 px
 *   :active        → red-soft-2 tint over opaque pill paper + deboss-deep.
 *                    Icon translates down 1 px into the recess. Wins over
 *                    [aria-expanded] so press feedback fires even when the
 *                    overlay is open.
 *   [aria-expanded="true"] → "modal open" state. A persistent lighter deboss
 *                    with red-soft tint over opaque pill paper so the pill
 *                    reads as a held-down toggle while its overlay is showing.
 *                    navshell.js mirrors aria-expanded onto the trigger. (Only
 *                    the search button uses this; help has no overlay.)
 */

/* :hover — icon halo (bg tint shared above) */
@media (any-hover: hover) {
  .pill--icon:hover { color: var(--red); }
  .pill--icon:hover > [data-icon] > svg,
  .pill--icon:hover > svg { filter: var(--halo-icon); }
}
.pill--icon.is-hover { color: var(--red); }
.pill--icon.is-hover > [data-icon] > svg,
.pill--icon.is-hover > svg { filter: var(--halo-icon); }

/* :focus-visible */
.pill--icon:focus-visible,
.pill--icon.is-focus {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
  color: var(--red);
}

/* :active — full pill deboss + inner icon settles down */
.pill--icon:active,
.pill--icon[aria-expanded="true"]:active,
.pill--icon.is-pressed {
  color: var(--red-deep);
  background: var(--pill-bg);
  background-image: linear-gradient(var(--red-soft-2), var(--red-soft-2));
  box-shadow: var(--shadow-deboss-deep);
}
.pill--icon:active > [data-icon],
.pill--icon:active > svg,
.pill--icon[aria-expanded="true"]:active > [data-icon],
.pill--icon[aria-expanded="true"]:active > svg,
.pill--icon.is-pressed > [data-icon],
.pill--icon.is-pressed > svg {
  transform: translateY(1px);
}

/* [aria-expanded="true"] — modal open (held-down toggle) */
.pill--icon[aria-expanded="true"],
.pill--icon.is-active {
  color: var(--red);
  background: var(--pill-bg);
  background-image: linear-gradient(var(--red-soft), var(--red-soft));
  box-shadow: var(--shadow-deboss-modal);
}

/* ============================================================================
 * .navshell — the nav layout shell.
 *
 * Two slot containers:
 *   .navshell__top   — top strip (brand; a top-level help icon only when there's
 *                      no Mirepoix menu to hold it — logged-out / no kitchen, #2361;
 *                      search + Mirabel's trigger on kitchen pages; destinations
 *                      join it when not cramped)
 *   .navshell__dock  — bottom floating dock (holds the destinations pill and
 *                      the search pill when cramped, #2353)
 *
 * JS toggles `data-cramped` on .navshell and moves pills between slots.
 * CSS nav layout keys off the .navshell attribute.
 * ========================================================================= */

/* overflow-x: the strip's horizontal overflow must never reach the DOCUMENT
 * (#4441). The nav passes through the DESKTOP layout at phone width twice per
 * broadcast morph that carries a real nav change — since #4869 an unchanged
 * morph skips the nav subtree entirely and never opens these windows, but a
 * changed morph still opens both, so these rules stay load-bearing:
 *
 *   1. idiomorph re-imposes the SERVER's markup — which ships
 *      data-measuring="true" and the desktop pill placement — and that stands
 *      until relayout runs on turbo:morph.
 *   2. relayout itself: measureThreshold clears data-measuring BEFORE update
 *      moves the pills back, so the desktop strip is briefly uncloaked.
 *
 * In both, that strip is wider than a phone viewport, so the document gains
 * horizontal overflow; under width=device-width the engine shrink-to-fits,
 * which makes the VISUAL viewport TALLER in layout pixels and drops the scroll
 * ceiling out from under a reader parked at the bottom. Measured on a 30-row
 * pantry at 390x844: scrollWidth 390 to 523, innerHeight 844 to 1132, ceiling
 * 3260 to 2956 — and the clamp is never given back, because restoring the width
 * restores the ceiling but not the scroll position.
 *
 * Unconditional rather than scoped to data-measuring, because window (2) is
 * uncloaked by construction. Clip, not hidden, so the axes stay independent:
 * the brand menu overflows this box DOWNWARD and must keep doing so
 * (overflow-y stays visible — verified by hit test, since a clipped element
 * still reports a rect). The nav's own clientWidth, which update reads to pick
 * cramped vs desktop, is unchanged at 366; navshell__top still reports its
 * intrinsic scrollWidth, so neither measurement moves. The dock is
 * position: fixed against the viewport and this box is not its containing
 * block, so it is not clipped either.
 *
 * The axes stay independent for LAYOUT overflow only. For INK — a box-shadow,
 * which is painted decoration and never scrollable — WebKit clips a clip
 * container on BOTH axes regardless of overflow-y, and Chromium clips only the
 * named one. Measured #4522, iPhone 17 / iOS 26 Safari vs Chromium on identical
 * bytes: a 60px debug shadow on the brand pill ended at CSS y 73.7 in Safari
 * (this box's bottom edge is 72) and ran its full 119.7 in Chromium; the same
 * shadow was cut at x 389.7 (this box's right edge) in Safari alone. That is
 * the "shadow chopped off by a hard line under the nav" Chris saw on the phone
 * and could not see anywhere else.
 *
 * So inflate the overflow clip edge — the PADDING box — outward, and cancel the
 * inflation in layout with matching negative margins. The CONTENT box is
 * unchanged, which is the property that matters: navshell__top still resolves
 * against the same box, so the #2144 alignment with the card's edges is
 * bit-for-bit what it was. Inline inflation is exactly body's own inline
 * padding, so this border box lands ON the viewport edge and never past it —
 * the document cannot gain the horizontal overflow the whole rule exists to
 * prevent. Verified: main's top, the footer's top, scrollHeight, scrollWidth
 * and the dock's top are all identical with and without these four lines.
 * Both banners rendered between this and <main> carry margin-top: 0, so the
 * negative bottom margin collapses to the same value with or without one. */
.navshell {
  position: sticky;
  top: 0;
  z-index: var(--z-nav);
  pointer-events: none;
  overflow-x: clip;
  margin-inline: calc(-1 * var(--gingham-gap));
  padding-inline: var(--gingham-gap);
  margin-block-end: calc(-1 * var(--pill-shadow-bleed));
  padding-block-end: var(--pill-shadow-bleed);
}
.navshell * { pointer-events: auto; }

/* Remove the default anchor underline on nav links (destinations,
 * brand link, sign-in pill, help, brandmenu items). The brand menu
 * popover lives inside .navshell via the .brand-host display:contents
 * wrapper, so this selector covers it too. Hover/focus states paint
 * their own affordance — halo, emboss, deboss — instead. */
.navshell a { text-decoration: none; }

/* While JS is measuring on first paint, keep things invisible so the user
 * doesn't see a flash of the wrong layout. The JS clears this on the next
 * frame after applying the right layout. */
.navshell[data-measuring="true"] { visibility: hidden; }
.navshell[data-measuring="true"] .navshell__top { width: max-content; }

/* The dock clearance, held across window (1) — the same #4441 bug on the other
 * axis. data-cramped is client-only state idiomorph strips, taking body's dock
 * padding with it, so the document briefly shortens and the scroll clamps
 * again. Matching the cramped rule below keeps the document's height flat
 * across the swap. Purely additive, and growing a document never clamps. */
body:has(.navshell[data-measuring="true"]) {
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5rem);
}

/* Top strip — three columns: brand | (destinations centered) | (search + help).
 *
 * Shares the content card's EXACT box: max-width 41rem + margin-inline: auto,
 * identical to main (base.css). Because both resolve to the same centered 41rem
 * box at every width, the strip never has to "reach" a wider cap to line up — so
 * the pills sit a constant inset inside the card edges on wide screens, narrow
 * screens, and phones alike. (#2144: the drift came from the strip capping
 * WIDER than the card — 41rem + 2·gap — and only meeting the card edges once it
 * actually hit that wider max-width; in the band where it couldn't, the gap
 * ramped from flush to a full gingham inset.)
 *
 * The inset is pure padding, split by axis:
 *   - inline (--space-2, 0.5rem): the small, consistent nudge inside the card
 *     edges. This is THE alignment value — retune the inset here.
 *   - block (--gingham-gap): breathing room above the strip and between it and
 *     the card, on the same spacing rhythm as the card's outer margin. Shrinks
 *     at max-width: 720px (base.css) with the token; the inline inset stays a
 *     fixed token, deliberately constant across widths. */
.navshell__top {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--gingham-gap);
  padding: var(--gingham-gap) var(--space-2);
  max-width: 41rem;
  margin-inline: auto;
  box-sizing: border-box;
  pointer-events: none;        /* the strip itself doesn't catch clicks */
}
.navshell__top > * { pointer-events: auto; }

.navshell__slot--brand   { justify-self: start;  position: relative; }
.navshell__slot--center  { justify-self: center; }
.navshell__slot--right   {
  justify-self: end;
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Note: the nav icon pills (search, help, brand) are a uniform 48px. The one
 * exception is the mobile dock's destinations pill, which grows taller than
 * 48px so its destination touch targets meet the ~48px iOS/Material norm (#1569). */

/* ─── Brand menu popover ────────────────────────────────────── */

.brandmenu {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: var(--z-nav-overflow);
  width: 268px;
  background: var(--content-card-bg);
  border: 1px solid var(--rule);
  border-radius: 8px;
  box-shadow: var(--shadow-popover);
  padding: 6px;
  animation: brandmenu-in 0.18s var(--ease-curtain);
}
@keyframes brandmenu-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0);    }
}
.brandmenu__kitchen {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.15rem;
  color: var(--text);
  line-height: 1;
}
/* --text-soft, not --text-light, for the same reason as
 * .search-result-category and .filter-suggestion-label below: --text-light
 * measures 2.82:1 light / 3.17:1 dark against the brand menu's
 * --content-card-bg, and 0.72rem is nowhere near the large-text threshold
 * that would excuse 3:1. --text-soft is 5.30 / 5.94.
 * This is the signed-in address — quiet by role, but it is the only thing on
 * the panel telling a cook WHICH account they are in, so it is text a user
 * reads rather than decoration. */
.brandmenu__user {
  font-family: var(--font-body);
  font-size: 0.72rem;
  color: var(--text-soft);
  letter-spacing: 0.04em;
  margin-top: 4px;
}
.brandmenu__list { list-style: none; padding: 0; margin: 0; }

/* Base */
.brandmenu__item {
  display: grid;
  grid-template-columns: 24px 1fr;
  column-gap: 12px;
  align-items: center;
  width: 100%;
  background: none;
  border: none;
  text-align: left;
  padding: 8px 10px;
  border-radius: 5px;
  font: inherit;
  color: var(--text);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    background var(--duration-fast),
    box-shadow var(--duration-fast),
    color var(--duration-fast),
    text-shadow var(--duration-fast),
    padding var(--duration-fast);
}
.brandmenu__icon {
  display: flex; align-items: center; justify-content: center;
  color: var(--text-soft);
  width: 24px; height: 24px;
  transition: color var(--duration-fast), filter var(--duration-fast);
}
.brandmenu__icon > svg { display: block; }
.brandmenu__label {
  font-family: var(--font-body);
  font-size: 0.92rem;
  font-weight: 500;
  color: var(--text);
  grid-column: 2;
  transition: color var(--duration-fast), text-shadow var(--duration-fast);
}

/* Quiet variant base — Sign out is visually demoted from the regular items. */
.brandmenu__item--quiet .brandmenu__label { color: var(--text-soft); }

/* States — brand menu item.
 *
 * Unlike the other three nav button types, a menu row has no independent
 * hover rung: an open menu has exactly ONE active row and it is always the
 * focused one, because the controller moves focus to the row under the
 * pointer (#3680). Pointing at a row still lights it — that path just runs
 * through :focus now, which is also what keeps the highlight honest about
 * which row Enter will activate.
 *
 *   :focus         → red-soft tint + label/icon red with backlit halo. This
 *                    is what a pointed-at row looks like: a pointer-driven
 *                    .focus() doesn't match :focus-visible.
 *   :focus-visible → embossed paper card — raised box-shadow + 1 px inset
 *                    border + red text/icon. Rhymes with the destination
 *                    [aria-current="page"] card-on-pill look. No left bar,
 *                    no halo — the raised treatment is the affordance.
 *   :active        → red-soft-2 + deboss. Inner contents settle 1 px down
 *                    via a padding shift (top +1, bottom -1) so the whole
 *                    row moves uniformly as a single grid reflow. No
 *                    per-child transforms (those drifted at different rates
 *                    because of how filters and text-shadows rasterize).
 *
 * The quiet variant (Sign out) keeps a neutral vocabulary: warm-gray hover
 * (via its own override, since the shared red-soft hover would pull brand
 * red onto a destructive action), neutral embossed focus, rule-faint press;
 * no red glow anywhere.
 */

/* :focus — the active row (tint + halos; this is the pointer treatment too).
 *
 * The row under the pointer is the FOCUSED row: brand_menu_controller moves
 * focus to it on pointermove, so this one rule serves both modalities and the
 * popover can never light two rows at once (#3680). Keying the pointer off
 * :hover as a separate channel meant a cursor parked over one row while
 * another held focus lit both — and the hovered one wasn't what Enter would
 * activate. Outline is suppressed here for the same reason :focus-visible
 * suppresses it below: the tint (pointer) and the embossed card (keyboard)
 * ARE the affordance, so a UA ring on top would be a third indicator. */
.brandmenu__item:focus,
.brandmenu__item.is-hover {
  outline: none;
  background-image: linear-gradient(var(--red-soft), var(--red-soft));
}
.brandmenu__item:focus .brandmenu__label,
.brandmenu__item:focus .brandmenu__arrow,
.brandmenu__item.is-hover .brandmenu__label,
.brandmenu__item.is-hover .brandmenu__arrow {
  color: var(--red);
  text-shadow: var(--halo-text);
}
.brandmenu__item:focus .brandmenu__icon,
.brandmenu__item.is-hover .brandmenu__icon {
  color: var(--red);
  filter: var(--halo-icon);
}
/* Quiet variant swaps the red-soft tint for a neutral warm-gray and skips the
 * halo — brand red shouldn't land on a destructive action. */
.brandmenu__item--quiet:focus,
.brandmenu__item--quiet.is-hover {
  background-image: linear-gradient(var(--surface-alt), var(--surface-alt));
}
.brandmenu__item--quiet:focus .brandmenu__label,
.brandmenu__item--quiet:focus .brandmenu__icon,
.brandmenu__item--quiet.is-hover .brandmenu__label,
.brandmenu__item--quiet.is-hover .brandmenu__icon {
  color: var(--text);
  text-shadow: none;
  filter: none;
}

/* :focus-visible — embossed paper card. Uses --surface-alt (slightly
 * different from the popover's --content-card-bg in both light and dark
 * modes) so the raised card actually reads against the popover. The
 * bordered emboss adds a subtle 1 px inset edge so the silhouette is
 * legible even when the bg contrast is low. */
.brandmenu__item:focus-visible,
.brandmenu__item.is-focus,
.brandmenu__item--quiet:focus-visible,
.brandmenu__item--quiet.is-focus {
  outline: none;
  background: var(--surface-alt);
  box-shadow: var(--shadow-emboss-bordered);
}
.brandmenu__item:focus-visible,
.brandmenu__item.is-focus { color: var(--red); }
.brandmenu__item:focus-visible .brandmenu__label,
.brandmenu__item:focus-visible .brandmenu__icon,
.brandmenu__item:focus-visible .brandmenu__arrow,
.brandmenu__item.is-focus .brandmenu__label,
.brandmenu__item.is-focus .brandmenu__icon,
.brandmenu__item.is-focus .brandmenu__arrow {
  color: var(--red);
  /* The raised card is the whole keyboard affordance — clear the pointer
   * halo the :focus rule above laid down, which every keyboard-focused row
   * now also matches. */
  text-shadow: none;
  filter: none;
}
.brandmenu__item--quiet:focus-visible .brandmenu__label,
.brandmenu__item--quiet:focus-visible .brandmenu__icon,
.brandmenu__item--quiet.is-focus .brandmenu__label,
.brandmenu__item--quiet.is-focus .brandmenu__icon {
  color: var(--text);
  text-shadow: none;
  filter: none;
}

/* :active — deboss + padding shift (top +1, bottom -1) so the whole grid
 * content reflows down 1 px as one unit. */
.brandmenu__item:active,
.brandmenu__item.is-pressed {
  padding: 9px 10px 7px;
  background: var(--red-soft-2);
  box-shadow: var(--shadow-deboss);
}
.brandmenu__item:active .brandmenu__label,
.brandmenu__item:active .brandmenu__icon,
.brandmenu__item:active .brandmenu__arrow,
.brandmenu__item.is-pressed .brandmenu__label,
.brandmenu__item.is-pressed .brandmenu__icon,
.brandmenu__item.is-pressed .brandmenu__arrow { color: var(--red-deep); }

.brandmenu__item--quiet:active,
.brandmenu__item--quiet.is-pressed {
  padding: 9px 10px 7px;
  background: var(--rule-faint);
  box-shadow: var(--shadow-deboss);
}
.brandmenu__item--quiet:active .brandmenu__label,
.brandmenu__item--quiet:active .brandmenu__icon,
.brandmenu__item--quiet.is-pressed .brandmenu__label,
.brandmenu__item--quiet.is-pressed .brandmenu__icon { color: var(--text); }

/* Identity header — non-interactive (the root page is retired, #4618): the
 * serif kitchen name + user line, with the round Account affordance as the
 * row's only interactive element. Padding matches .brandmenu__item so the
 * text column aligns with the labels below. */
.brandmenu__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 10px;
}
.brandmenu__home-text { display: flex; flex-direction: column; }

.brandmenu__account {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  flex: none;
  border-radius: 50%;
  color: var(--text);
  background: none;
  box-shadow: inset 0 0 0 1.5px currentcolor;
  transition:
    background var(--duration-fast),
    color var(--duration-fast);
}
.brandmenu__account-initials {
  font-family: var(--font-body);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  line-height: 1;
}
@media (any-hover: hover) {
  .brandmenu__account:hover {
    color: var(--red);
    background: var(--red-soft);
  }
}
.brandmenu__account:focus {
  color: var(--red);
  background: var(--red-soft);
}
.brandmenu__account:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}
.brandmenu__account:active {
  color: var(--red-deep);
  background: var(--red-soft);
}

.brandmenu__divider {
  height: 1px;
  background: var(--rule-faint);
  margin: 6px 0;
}

/* Install-nudge gesture hint (#2584). Surface/backdrop/entrance come from the
   dialog shell component (components/dialog.css); this keeps width + padding. */
.installnudge-dialog {
  max-width: 22rem;
  --dialog-padding: 1.25rem 1.5rem;
}
.installnudge-dialog__heading {
  margin-top: 0;
  font-size: 1.15rem;
}
.installnudge-dialog__benefit {
  font-size: 0.9rem;
  opacity: 0.8;
}

/* ─── Search overlay ──────────────────────────────────────────
 *
 * The markup uses native <dialog class="search-overlay"> opened via
 * showModal() (search_overlay_controller.js). The browser places the
 * dialog in the top layer and emits a ::backdrop pseudo-element for
 * the dim layer. We unset the dialog's native chrome and style the
 * inner .search-panel as the floating paper card.
 *
 * Class names match the existing ERB partial and the dynamic class
 * names emitted by the controller (.search-result, .search-result-title,
 * .search-result-category, .search-no-results, .selected).
 * ─────────────────────────────────────────────────────────── */

dialog.search-overlay {
  position: fixed;
  inset: 0;
  /* 100vw, not 100% — same reserved scrollbar gutter as the editor dialogs
     (#3396, editor.css's mobile block carries the full explanation). Here the
     shortfall was invisible rather than a visible strip: the container is
     transparent, so it only pushed the centered search panel half a gutter
     off-center. */
  width: 100vw;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  border: none;
  background: transparent;
  padding: 14% 16px 24px;
  box-sizing: border-box;
  z-index: var(--z-overlay);
  overflow: visible;
  display: flex;
  align-items: flex-start;
  justify-content: center;
}
dialog.search-overlay:not([open]) { display: none; }
/* Tint only — no backdrop-filter, and that is a performance decision, not a
 * taste one. A backdrop filter re-samples whatever is painted behind it, so it
 * is recomputed every frame the region it covers repaints, and the paper slide
 * repaints it every frame. Measured over expand+collapse (frames longer than
 * 20ms): blur(3px) dropped 74% of frames in Firefox and 29% in Chromium; with
 * no filter, 0% in both. Radius is irrelevant (blur(1px): 80%) and so is the
 * filter kind (brightness(): 78%) — and no layer hint on the backdrop
 * (will-change, translateZ) recovers any of it, because the dependency is on
 * the backdrop's INPUT, not its own layer. Before putting a filter back,
 * measure: log requestAnimationFrame intervals through an expand and a
 * collapse in headless Firefox and count the ones over 20ms. */
dialog.search-overlay::backdrop {
  background: rgba(40, 24, 22, 0.42);
  animation: fade-in var(--duration-normal) var(--ease-curtain);
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

.search-panel {
  position: relative;
  width: 100%;
  max-width: 32rem;
  background: var(--content-card-bg);
  border: 1px solid var(--rule);
  /* Spotlight-style rounded container (#4661 preview round, judged). overflow:
     hidden on THIS element is what clips the tinted input row below to these
     corners — that relationship is load-bearing and non-obvious: change the
     radius here and the clip follows for free, but move the radius without
     the overflow (or vice versa) and the input row's corners go square again
     against a rounded panel. */
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-popover-lg);
  overflow: hidden;
  animation: bloop var(--duration-bloop) var(--ease-bloop);
}
.search-panel::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("/assets/paper-noise-4ecbafa7.svg");
  background-size: var(--noise-tile) var(--noise-tile);
  opacity: var(--paper-opacity);
  pointer-events: none;
}
.search-panel > * { position: relative; }

.search-input-wrapper {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0.9rem 1rem;              /* was 16px 18px — rem for text-zoom (WCAG 1.4.4) */
  /* border-bottom removed — divider moves to .search-results */
  background: var(--input-bg);       /* #4661 preview round, judged */
  /* Rounded at the bottom too, so the input reads as a bar LYING ON the paper
   * with its top edge flush to the sheet's, rather than a band the sheet
   * starts beneath. The panel's overflow:hidden already rounds the top pair to
   * match; these two are what open the paper notches at the bottom corners.
   * Same radius as the panel so the bar's curve and the sheet's are the same
   * curve, which is the point — a smaller value here reads as a second,
   * competing corner. */
  border-bottom-left-radius: var(--radius-xl);
  border-bottom-right-radius: var(--radius-xl);
  /* The clear button only exists while there is text, and it is the tallest item
   * in this row: a 2.75rem box pulled back in by its -0.5rem margins, so 1.75rem
   * in flow — taller than the input's own 1.15rem/1.2 line box. Reserve that
   * height unconditionally, or the icon and input jump down 3.33px the moment
   * the user types (#4661). box-sizing is border-box here, so this wrapper's own
   * 0.9rem vertical padding is part of the figure. */
  min-height: calc(1.75rem + 0.9rem * 2);
  /* The bar's frame, uniform the whole way round, and never animated.
   *
   * `0 0 0 1px` is a SPREAD ring, not a cast shadow: zero offset, zero blur,
   * the element's own silhouette expanded by one pixel. That is what makes it
   * hold one weight around the corner arc. Two other spellings were tried and
   * both taper toward the vertical, which is what made the bottom corner read
   * as passing UNDERNEATH the side — one frame at two depths:
   *
   *   - `0 1px 0` offset shadow: it is the silhouette moved DOWN, so it shows
   *     full weight only where the edge runs horizontal and converges to
   *     nothing as the curve turns vertical.
   *   - `border-bottom` with zero-width sides: a corner arc splits between its
   *     two borders, so the width ramps 1px -> 0 across it rather than the
   *     bottom border claiming the whole sweep.
   *
   * Measured, not eyeballed — brightest pixel along the arc at 8x, near-vertical
   * / 45deg / near-horizontal: border-bottom gave 31.1 / 53.7 / 53.7, this ring
   * gives 53.7 / 53.7 / 53.7.
   *
   * Nothing doubles the panel's own border. The ring is drawn entirely OUTSIDE
   * the bar's box, and the bar is flush to the panel's padding box on three
   * sides, so overflow:hidden clips those away; only the bottom, where there is
   * paper below, survives. Which is also why the visible run meets the panel's
   * left and right borders exactly instead of stopping short of them.
   *
   * The float rides in the same declaration, and is never animated either.
   * --shadow-popover is deliberately the shallower sibling of the
   * --shadow-popover-lg the panel wears: the bar lifts off the sheet, it does
   * not float as far as the sheet floats off the page.
   *
   * It is unconditional on purpose. An earlier cut faded it in and out with
   * the paper, on the bloop clock while the paper slid on the curtain clock,
   * and the two never agreed: at both ends of the trip there was a beat of
   * visible paper with no shadow on it, reading as a flash of lighter colour
   * under the bar. Measured mid-collapse at 30.2 luma against 26.0 settled.
   * Like the ring, the float is cast outside the bar's box, so with the sheet
   * shut it falls past the panel's edge and overflow:hidden clips it; the
   * sliding paper edge then REVEALS a shadow that was there all along, which
   * is the physical model — the bar never changes, only the paper moves — and
   * one less thing to repaint per frame. */
  box-shadow: 0 0 0 1px var(--rule), var(--shadow-popover);
}
/* The glyph is IconHelper's :search SVG since #3856 — one icon source of
   truth with the search_field component — so it sizes off the svg's own
   width/height attributes, not a font-size. */
.search-icon {
  color: var(--text-placeholder);    /* was --text-light; #4477 contrast rationale */
  display: flex;
  align-items: center;
}
.search-icon > svg { display: block; }

/* Container for grocery quick-add chips and similar context pills.
 * Empty by default; populated by search_overlay_controller. */
.search-pill-area {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
}
.search-pill-area:empty { display: none; }

.search-input {
  all: unset;
  flex: 1;
  /* The overlay input is a control, not a document heading, so it wears the
     body face rather than the display serif (#4661 preview round, judged). */
  font-family: var(--font-body);
  font-style: normal;
  font-size: 1.15rem;
  color: var(--text);
  min-width: 0;
  line-height: 1.2;
}
.search-input::placeholder { color: var(--text-placeholder); }

/* Clear (×) button — hidden via the `hidden` attribute (toggled by the
 * controller) rather than display:none in CSS, so no selector duplication
 * between markup default and script state. The negative margin buys the
 * WCAG 2.5.8 44px coarse-pointer floor without growing the bar's height. */
.search-clear {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 2.75rem;
  min-height: 2.75rem;
  margin: -0.5rem;
  border: none;
  background: none;
  color: var(--text-placeholder);
  cursor: pointer;
  border-radius: var(--radius-pill);
  transition: color var(--duration-fast);
}
@media (any-hover: hover) {
  .search-clear:hover { color: var(--red); }
}
.search-clear:active { color: var(--red); }
.search-clear:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* No border-top any more: the input row is now a rounded bar sitting ON this
 * paper rather than a panel-width band above it, so it separates itself. The
 * rule also had to go — it drew straight across the notches the bar's rounded
 * bottom corners open up, putting a hairline through the paper exactly where
 * the paper is supposed to read as continuous. */
/* The sheet slides out from under the bar rather than appearing at full size.
 * This is the .collapse-body / .scale-controls idiom from base.css: a grid
 * shell whose single row goes 0fr -> 1fr, which resolves to the CONTENT's own
 * height, so the reveal takes the same beat whether two recipes matched or
 * twenty.
 *
 * The obvious cheaper version — animating the list's own max-height from 0 to
 * its 60vh cap — was tried and is wrong. The cap is ~1232px on a tall window
 * while a two-result list is ~160px, so the growth passes the content height in
 * the first few milliseconds and a short list, which is the common case, shows
 * no slide at all. Measured: the panel was at its final height by t=25ms of a
 * 300ms animation.
 *
 * It has to be a transition on a WRAPPER rather than a keyframe on the list: an
 * empty list is display:none, so animating the panel's own grid would change
 * the track count from one to two, and `auto` -> `auto 1fr` does not
 * interpolate — it snaps. The shell's track count never changes.
 *
 * prefers-reduced-motion is handled globally in base.css, which collapses every
 * transition-duration to 0.01ms. */
.search-results-shell {
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  transition: grid-template-rows var(--duration-curtain) var(--ease-curtain);
}
/* [data-collapsing] is set by the controller for the trip back UP. Emptying the
 * list is what would normally end the expanded state, but that leaves the shell
 * nothing to slide — so the rows stay put, this attribute drops the rule, and
 * the controller clears them when the transition ends. */
.search-panel:has(.search-results:not(:empty)):not([data-collapsing]) .search-results-shell {
  grid-template-rows: 1fr;
}

.search-results {
  list-style: none;
  margin: 0;
  /* Uniform, and the last-child radius rule below depends on it: that rule
   * derives its 16.5px from "the panel's radius minus this list's 6px side
   * padding" so the two curves stay parallel. A thicker bottom pads the
   * trimmed corner away from the edge it is supposed to hug, which reads as
   * the corner being wrong rather than the padding. */
  padding: 6px;
  max-height: 60vh;
  overflow-y: auto;
  /* Grid items floor at min-content by default, which would keep the shell's
   * 0fr row from ever reaching zero. */
  min-height: 0;
}
.search-results:empty { display: none; }

.search-result {
  display: flex;
  flex-direction: column;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 10px 14px;
  border-radius: 6px;
  cursor: pointer;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
  transition: background var(--duration-fast);
}

/* :hover — gated, and excluding .selected. Only this rung moves into the
 * gate: :focus-visible and .selected are the keyboard affordances and must
 * keep painting on touch. The tint is byte-identical to .selected today, so a
 * sticky hover left by a tap is indistinguishable from the row Enter would
 * activate; :not(.selected) also keeps hover from ever repainting the
 * selection indicator if the two tints diverge, whatever the source order.
 * `outline: none` is not carried over — it suppresses the UA ring for
 * :focus-visible and has never done anything on hover. */
@media (any-hover: hover) {
  .search-result:not(.selected):hover {
    background: var(--red-soft);
  }
}
.search-result:focus-visible,
.search-result.selected {
  background: var(--red-soft);
  outline: none;
}

/* Press rung — this is the one control where gating hover removes the only
 * acknowledgement a touch user had: the base rule zeroes the platform tap
 * highlight, and a grocery quick-add row awaits a POST before it rewrites
 * itself, so without this the row sits inert for the whole round trip. */
.search-result:active {
  background: var(--red-soft-2);
}

/* The highlight on the LAST row sits inside the panel's own 22.5px corner, and
 * a 6px corner against that curve reads as a mistake. 16.5px is concentric:
 * the panel's radius minus this list's 6px side padding, so the two curves stay
 * parallel. It needs BOTH selectors because the list's last child is not one
 * shape — a recipe row IS the <li>, while a grocery or suggestion section is an
 * <li> WRAPPING its rows. Styling only `li:last-child` would silently miss
 * every case where the list ends in a section, which is most of them for a
 * signed-in member.
 *
 * Sits after the whole :hover/.selected/:active ladder deliberately: it wins on
 * specificity wherever it goes, but placed earlier it makes every later rung
 * trip stylelint's no-descending-specificity. Last is the position that costs
 * no warnings. */
.search-results > li:last-child.search-result,
.search-results > li:last-child > .search-result:last-child {
  border-bottom-left-radius: calc(var(--radius-xl) - 6px);
  border-bottom-right-radius: calc(var(--radius-xl) - 6px);
}
.search-result-title {
  font-family: var(--font-display);
  font-size: 1.05rem;
  color: var(--red);
  line-height: 1.2;
}
/* --text-soft, not --text-light: measured 2.82:1 light / 3.17:1 dark against
 * --content-card-bg, and 0.74rem is far under the large-text threshold that
 * would excuse 3:1. --text-soft lands at 5.30 / 5.94. This is the identical
 * rule to .filter-suggestion-label below, which already made the call, and the
 * same ruling is recorded in ingredient_review.css.
 * #4683 filed this alongside the red serif .search-result-title as one "brand
 * voice" question. It is not one: this label is --font-body and its failure is
 * an ordinary token misuse. Only the title is a design call. */
.search-result-category {
  font-family: var(--font-body);
  font-size: 0.74rem;
  color: var(--text-soft);
  letter-spacing: 0.04em;
  margin-top: 2px;
}
/* --text-soft for the reason above: 0.95rem is still under the large-text
 * threshold, so --text-light's 2.82 / 3.17 on the card fails 1.4.3 here too.
 * The display face and italic change the voice, not the floor. */
.search-no-results {
  padding: 16px;
  font-family: var(--font-display);
  font-style: italic;
  font-size: 0.95rem;
  color: var(--text-soft);
  text-align: center;
}

/* Filter-suggestion section (#4663) — pickable "Filter by tag: x" rows the
 * controller emits above the results when a typed word names a real tag or
 * category. The base .search-result is a flex COLUMN; these read as one line. */
.filter-suggestion-section {
  list-style: none;
  margin: 0 0 6px;
  padding: 0 0 6px;
  border-bottom: 1px solid var(--rule-faint);
}

/* Nothing to divide: suggestions can be the list's only content when the query
 * names a real tag but matches no recipe, and the separator would then just
 * underline the last row. Same shape and same reason as
 * .grocery-section--above:last-child below. */
.filter-suggestion-section:last-child {
  border-bottom: none;
  padding-bottom: 0;
  margin-bottom: 0;
}
/* Height is matched to the grocery rows below, not left to the content: a
 * suggestion row is one pill, and a pill's box follows its typeface, so the
 * tag row and the category row came out 45.6px and 49.1px against the grocery
 * row's 40.5px — three different heights in one list. Pinning the pill box
 * gives 6 + 28.44 + 6 = 40.44px for both. Measured, not estimated; re-measure
 * rather than nudge if the pill's font or padding moves. */
.filter-suggestion-row {
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
  padding-top: 6px;
  padding-bottom: 6px;
}
.filter-suggestion-row .tag-pill {
  line-height: 1.1;
  min-height: 1.58rem;
}
/* --text-soft, not --text-light: measured on the real overlay, --text-light
 * lands at 3.17:1 against this ground and --text-soft at 5.94:1, and 0.74rem is
 * well under the large-text threshold that would excuse 3:1. The same in-repo
 * ruling is recorded at ingredient_review.css.
 * Its neighbour .search-result-category carries --text-soft for exactly this
 * reason too — it was filed under #4683's red-serif question, but it is
 * --font-body, so it was only ever a mis-bucketed instance of this ruling, and
 * #4757 lifted it.
 * Note the Tier-3 axe pass cannot see any of them — these rows exist only
 * after someone types. */
.filter-suggestion-label {
  font-family: var(--font-body);
  font-size: 0.74rem;
  color: var(--text-soft);
  letter-spacing: 0.04em;
}

/* Grocery quick-add section — emitted by the controller above or below
 * the recipe results depending on tier of match. */
.grocery-section--above,
.grocery-section--below {
  list-style: none;
  margin: 0;
  padding: 0;
}
.grocery-section--above {
  border-bottom: 1px solid var(--rule-faint);
  padding-bottom: 6px;
  margin-bottom: 6px;
}
.grocery-section--below {
  border-top: 1px solid var(--rule-faint);
  padding-top: 6px;
  margin-top: 6px;
}

/* Nothing to divide: a query that matches no recipes renders the quick-add
 * section alone, so the separator would just underline the only row, and the
 * 6+6px it reserves for the results beneath it becomes dead space (#4661). */
.grocery-section--above:last-child {
  border-bottom: none;
  padding-bottom: 0;
  margin-bottom: 0;
}

/* Grocery quick-add voice (#4661): a row that WRITES must not wear the
 * red-serif recipe-title voice a row that NAVIGATES wears. */
.grocery-section-header,
.search-section-label {
  display: block;
  padding: 8px 14px 2px;
  font-family: var(--font-body);
  font-size: 0.68rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  /* Quiet chrome, but --text-faint fails 4.5:1 against the panel in both
     schemes (1.78:1) — uses the same --text-placeholder the search icon
     uses (#4477). Don't "quiet" this back down to --text-faint. */
  color: var(--text-placeholder);
}
.grocery-item-row .search-result-title {
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text);
}
.grocery-item-row .search-result-title::before {
  content: "+";
  margin-right: 0.45rem;
  font-weight: 700;
  color: var(--red);
}
.grocery-action-flash {
  font-family: var(--font-body);
  font-weight: 600;
  color: var(--text);
  animation: pulse var(--duration-bloop) var(--ease-bounce);  /* sanctioned pairing (style.md §Bloops) */
}
/* flashAndClose (grocery_action.js) swaps the row's textContent, removing
 * the title span — the rule above styles the row itself, which is why it
 * doesn't inherit .search-result-title's ::before "+" glyph. */

/* Cramped layout — when nav_shell_controller flips data-cramped="true",
 * the dock becomes visible and the body needs bottom padding to clear it.
 * (In the design system, .appwrap[data-cramped="true"] handled this; we
 * apply it to body via :has() since the navshell sits at body level.)
 */
body:has(.navshell[data-cramped="true"]) {
  /* rem so the reserved space grows with text-zoom when the dock wraps to two
     rows; 5rem == today's 80px at the 16px mobile root. */
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5rem);

  /* The toast stack must clear the fixed mobile dock too (#4404
     de-confliction; completes the design spec's "mobile lifts it above the
     navshell edge" — only the groceries-banner half of that sentence
     shipped when the round first landed). Reuses this same
     cramped-detection selector rather than `:has(.navshell__dock)` — the
     dock <div> is ALWAYS in the DOM (empty until cramped; see
     _navshell.html.erb), so a bare `:has()` on it would misfire on desktop
     too.
     4.5rem measured (not guessed), at 393px and 320px cramped widths: the
     dock sits `position: fixed; bottom: calc(14px + safe-area)`, and at the
     18px root its one-row rendered height put its TOP edge 75.25px above
     the viewport bottom — needing >=4.18rem in a REM-valued custom property
     (this property scales with root font-size exactly like the dock's own
     rem-sized padding, so the comparison must be done in rem, not px). At
     200% text zoom the dock wraps to two rows (needs 104.5px), but by then
     4.5rem is already 162px — the rem scaling outruns the wrap. 1x (no
     zoom) is the tightest case; 4.5rem leaves ~6px of headroom there.
     Composition with groceries.css's own --toast-inset: 5.25rem (banner
     clearance) is numerically larger than this 4.5rem (dock clearance), and
     `body:has(#offline-banner)` (an ID selector inside :has()) outranks
     this selector (class+attribute) on specificity — so on a cramped
     groceries page the banner's larger value wins BOTH by value and by
     cascade, and it already clears the dock too (94.5px > 75.25px needed).
     That's the deliberate winner; if either number shrinks in a way that
     flips this, re-check the interaction. */
  --toast-inset: 4.5rem;
}

/* Dock spans the viewport (left:0/right:0) and centers its contents via
 * justify-content. The destinations pill shrink-wraps (max-width + flex-wrap),
 * so with the full viewport available it wraps only when its content exceeds
 * the viewport — not prematurely at ~50%, as left:50% shrink-to-fit forced
 * before. Search docks here beside the destinations pill when cramped (#2353,
 * reversing #1569); Mirabel's trigger took the freed top-right spot. */
.navshell[data-cramped="true"] .navshell__dock {
  position: fixed;
  bottom: calc(14px + env(safe-area-inset-bottom, 0px));
  left: 0;
  right: 0;
  display: flex;
  /* Wrap so search drops to its own row at full 48px when the destinations pill
     and search can't share one line under text zoom — instead of flex-shrinking
     search below the 48dp touch-target norm (#2353). */
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 10px; /* destinations pill + search pill (nav_shell_controller moves both here) */
  z-index: var(--z-nav);
}

/* The dock floats just above iOS Safari's bar-fill boundary, which is not
   page canvas: any ink crossing it is guillotined flat (#5034). The default
   28px-bleed --pill-shadow got visibly chopped there; the tight recipe's
   downward reach (~7px) dies before the boundary in every bar state. */
.navshell[data-cramped="true"] .navshell__dock .pill {
  box-shadow: var(--pill-shadow-tight);
}

/* Dock-mode destinations. Sized in rem calibrated to the 16px mobile root
 * (base.css drops the root to 16px at <=720px + coarse pointer), so the base
 * render is unchanged while the label now scales with text-zoom (WCAG 1.4.4).
 * ~48px tall meets the iOS 44pt / Material 48dp touch-target norm. The pill
 * wraps to two rows rather than clipping destinations off-screen under large
 * text (WCAG 1.4.10). (#1569) */
.navshell[data-cramped="true"] .destination {
  flex: 1 1 0;
  padding: 0.5rem 0.625rem;
  min-width: 0;
}
.navshell[data-cramped="true"] .destination__label {
  font-size: 0.625rem;
}
.navshell[data-cramped="true"] .pill--destinations {
  flex-wrap: wrap;
  justify-content: center;
  width: min(21rem, calc(100vw - 86px));
  max-width: calc(100vw - 28px);
  height: auto;
}

.navshell:not([data-cramped="true"]) .navshell__dock {
  display: none;
}

/* Whisk brand-menu motion. These source-order overrides keep the existing
 * Stimulus/ARIA contract: aria-expanded spins the caret, and removing [hidden]
 * restarts the popover entrance animation. */
.brandmenu__list li:nth-child(1) .brandmenu__item { --row: 0; }
.brandmenu__list li:nth-child(2) .brandmenu__item { --row: 1; }
.brandmenu__list li:nth-child(3) .brandmenu__item { --row: 2; }
.brandmenu__list li:nth-child(4) .brandmenu__item { --row: 3; }
.brandmenu__item--quiet { --row: 4; }

/* Help group — its own list so the destinations stagger above (and its tested
 * nth-child rules) stay untouched. Continue the row sequence for a coherent
 * rise; the contextual row gets a trailing column for its → affordance. */
.brandmenu__pins { list-style: none; padding: 0; margin: 0; }
.brandmenu__section-label {
  margin: 6px 10px 2px;
  font-family: var(--font-body);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-soft);
}
.brandmenu__help { list-style: none; padding: 0; margin: 0; }
.brandmenu__help li:nth-child(1) .brandmenu__item { --row: 4; }
.brandmenu__help li:nth-child(2) .brandmenu__item { --row: 5; }
.brandmenu__help .brandmenu__item { grid-template-columns: 24px 1fr auto; }
/* The arrow sits dim at rest and joins the label's red/halo on hover/focus/press
 * (state rules above add .brandmenu__arrow alongside .brandmenu__label); the
 * transition matches the label so the glow eases in, not snaps. */
.brandmenu__arrow {
  color: var(--text-soft);
  font-size: 0.85em;
  transition: color var(--duration-fast), text-shadow var(--duration-fast);
}

@keyframes ma-row-rise {
  from { opacity: 0; transform: translateY(7px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* stylelint-disable-next-line no-duplicate-selectors -- whisk-override-layer: re-states the structural caret defaults (line ~191) to supersede the bounce transition via source order; merging up would scatter the Whisk motion identity */
.brand__caret-g {
  transition: transform 0.35s var(--ease-whisk);
  will-change: transform;
}
/* stylelint-disable-next-line no-duplicate-selectors -- whisk-override-layer: open-state caret rotation override (90deg→450deg) layered after the structural block via source order */
.pill--brand[aria-expanded="true"] .brand__caret-g,
.pill--brand.is-open .brand__caret-g {
  transform: rotate(450deg);
}
/* stylelint-disable-next-line no-duplicate-selectors -- whisk-override-layer: source-order override of the bounce transition (line ~197) with the Whisk easing */
.brand__caret-l,
.brand__caret-r {
  transition: transform 0.35s var(--ease-whisk);
}

/* stylelint-disable-next-line no-duplicate-selectors -- whisk-override-layer: adds the menu entrance animation to .brandmenu defined as a structural container earlier (line ~567) */
.brandmenu {
  animation: wh-menu 0.3s var(--ease-whisk);
  transform-origin: top left;
}
@keyframes wh-menu {
  0%   { opacity: 0; transform: rotate(-16deg) translateY(-6px) scale(0.88); }
  100% { opacity: 1; transform: rotate(0deg) translateY(0) scale(1); }
}
/* stylelint-disable-next-line no-duplicate-selectors -- whisk-override-layer: adds the per-row rise animation to .brandmenu__item defined for layout earlier (line ~606) */
.brandmenu__item {
  animation: ma-row-rise 0.34s var(--ease-bounce) both;
  animation-delay: calc(var(--row) * 0.05s + 0.06s);
}

@media (prefers-reduced-motion: reduce) {
  /* Flatten the spring and whirl; keep color, focus, and press feedback. */
  .brand__caret-g,
  .brand__caret-l,
  .brand__caret-r {
    transition-duration: var(--duration-fast) !important;
    transition-timing-function: ease-out;
  }

  .pill--brand[aria-expanded="true"] .brand__caret-g,
  .pill--brand.is-open .brand__caret-g {
    transform: rotate(90deg);
  }

  .brandmenu {
    animation: none;
    transform-origin: center;
  }

  .brandmenu__item {
    animation: none;
  }
}
