/*
 * Global design tokens, typography, and shared UI patterns.
 *
 * Propshaft serves each stylesheet individually — no bundling. This file
 * is loaded globally in the application layout alongside ingredient_row.css,
 * navshell.css and print.css (plus mirabel.css when AI is configured). Page sheets — editor,
 * settings, menu, groceries, ingredients, nutrition, recipe — load per-page
 * via content_for(:head).
 *
 * Conventions:
 * - Color tokens: all in :root below — check before inventing new names
 * - Icons: IconHelper#icon(name, size:) / JS buildIcon(name, size) — never raw SVG
 * - Inputs: .input-base + modifiers
 * - Buttons: .btn + modifiers
 * - Page titles: <header class="page-header"> on non-document pages — never
 *   style bare header/section/footer; document typography (header/section/
 *   footer/ingredients/instructions families) is :where()-scoped to its
 *   document roots — per-rule root lists vary by consumer (#1876, complete)
 * - Collapse: <details class="collapse-header"> pattern
 * - Editor classes: editor- prefix
 * - [hidden]: a global `[hidden] { display: none !important }` (below the
 *   box-sizing reset) guarantees the attribute always hides, overriding any
 *   author `display`. An element that must stay in layout while inert uses a
 *   visibility class instead of the attribute (see `.scale-reset.is-hidden`).
 *
 * Responsive: page chrome uses @media queries (pantry.css's
 * @media (max-width: 640px) is the worked example).
 *
 * Collaborators: fonts.css (@font-face faces — linked before this sheet),
 *   ingredient_row.css (global peer, linked immediately after this sheet — it
 *   carries the ingredient-row zone geometry moved out of here in #4292, and
 *   the print layout links it too), navshell.css + print.css (global peers),
 *   the per-page sheets
 *   (editor, settings, menu, ...), IconHelper, base layout (application.html.erb)
 */

/* Universal border-box — every element sizes width/height inclusive of
   padding + border. Modern default; makes design-tool-generated components
   predictable. The per-element `box-sizing: border-box` patches elsewhere
   (.input-base, .pill, .editor-textarea, …) are now redundant but harmless. */
*,
*::before,
*::after {
  box-sizing: border-box;
}

/* The hidden attribute MEANS display:none — but a UA stylesheet rule is the
   lowest cascade origin, so any author `display:` on a class (e.g. .pantry-row
   { display:flex }) silently outranks it and the element stays visible (#1737).
   Forcing it !important here makes `hidden` reliably hide app-wide, so JS that
   toggles the attribute never needs a per-selector escape hatch. An element that
   must stay in layout while inert must NOT use the attribute — toggle a
   visibility class instead (see .scale-reset.is-hidden). */
[hidden] {
  display: none !important;
}

:root {
  --font-display: "Instrument Serif", "Instrument Serif Fallback", Georgia, serif;
  --font-body: "Outfit", "Outfit Fallback", system-ui, sans-serif;
  --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;

  /* Canonical color tokens */
  --ground: #faf8f5;
  --text: #2d2a26;
  --text-soft: #706960;
  --text-light: #a09788;
  --text-faint: #c8bfb2; /* faintest text rung — struck-off groceries, meta counts */
  /* Placeholder rung (#4477): --text-light fails WCAG 1.4.3 against --input-bg
   * in both themes. Points at --text-soft's *value*, not a copy of it, so it
   * keeps tracking --text-soft's dark-mode redefinition below without a
   * second declaration here. */
  --text-placeholder: var(--text-soft);
  --red: #b33a3a;
  /* Same hue as --red, held at light mode's value here. Small white text on
   * a --red fill measures 3.79:1 in dark mode (below WCAG 1.4.3's 4.5:1) —
   * --red itself can't be darkened to fix it because it's deliberately
   * lightened for dark-mode text/border legibility (#4513, and lightened
   * further by #4678, which is why that figure was 4.45 before). Use this only
   * for a solid fill carrying small white text (and its matching border, if
   * the border is meant to read as the same color as the fill) — never for
   * text or border-only uses, which want --red. */
  --red-fill: #b33a3a;
  --red-light: rgba(179, 58, 58, 0.08);
  --tag-filter-active-bg: rgba(179, 58, 58, 0.15);
  --rule: #e4dfd8;
  --rule-faint: #eee9e3;

  --surface-alt: #f7f5f1;
  --hover-bg: var(--red-light);
  --hover-bg-strong: rgba(179, 58, 58, 0.15);

  --input-bg: white;
  --accent-hover: #993030;
  --scaled-highlight: rgba(255, 243, 205, 0.6);
  /* Convergence pulse (#4269) — WAAPI flash on rows another tab just changed
   * (converge_highlight_controller). Warm amber: informational, not the
   * action red. Sibling of --snooze-flash (groceries.css), which stays
   * page-scoped; this one is cross-page. */
  --converge-flash: rgba(217, 168, 71, 0.22);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.06);
  --shadow-dialog: 0 4px 24px rgba(0, 0, 0, 0.15);
  --dialog-backdrop: rgba(0, 0, 0, 0.5);
  --aisle-renamed-bg: #fff8e1;
  --aisle-renamed-border: #ffe082;
  --aisle-new-bg: #e8f5e9;
  --aisle-new-border: #a5d6a7;
  --broken-reference-bg: #fdf6f0;
  --aisle-row-border: #e0e0e0;
  --danger-hover-bg: rgba(204, 0, 0, 0.08);

  /* Notification bar — upward-facing nav shadow */
  --shadow-nav-up: 0 -2px 4px rgba(0, 0, 0, 0.08), 0 -1px 2px rgba(0, 0, 0, 0.04);

  /* Tag pill colors. Category pills share this neutral ground and are told
   * apart by shape and typeface instead — see .tag-pill--category. */
  --tag-bg: #e8e3db;
  --tag-text: #4a4540;

  /* Smart tag color groups */
  --smart-green-bg: #d4edda;    --smart-green-text: #1b5e20;
  --smart-amber-bg: #fff3cd;    --smart-amber-text: #7a5d00;
  --smart-blue-bg: #d6eaf8;     --smart-blue-text: #1a4a6e;
  --smart-rose-bg: #f5e0d0;     --smart-rose-text: #6e3222;
  --smart-purple-bg: #e8daf5;   --smart-purple-text: #4a2080;
  --smart-cuisine-bg: #f5ddd0;  --smart-cuisine-text: #6e3a22;

  /* Design-system semantic roles — kept neutral so the brand red carries
   * focus. --danger is the canonical danger role. */
  --positive: #4a7a4a;
  --warning:  #b8862a;
  --danger:   #c00;
  --info:     #4a6e8a;

  /* "Her" jewel-tone pigments — sparing use for tag color + illustration. */
  --pigment-rose:  #e6a896;
  --pigment-clay:  #c87a5a;
  --pigment-amber: #d9a847;
  --pigment-leaf:  #6b8a5a;
  --pigment-mint:  #a8c5a8;
  --pigment-sky:   #8aa7c0;
  --pigment-plum:  #a87c8c;
  --pigment-stone: #b8a995;

  /* Transition timing — use these instead of bare durations */
  --duration-fast: 0.12s;
  --duration-normal: 0.2s;
  --duration-slow: 0.35s;
  --duration-bloop: 0.25s;
  --duration-busy: 1.3s; /* continuous-loop cadence for busy indicators — outside the four one-shot bloop categories (#4270) */

  /* Page gutter — sole survivor of the gingham-tablecloth tokens (the
     tablecloth gradient is inlined in html{}, not token-driven). */
  --gingham-gap: 1.5rem;
  --content-card-bg: rgb(255, 252, 249);
  --paper-opacity: 0.06;
  --noise-tile: 200px;   /* paper-noise overlay tile size */

  /* Pantry v2 design tokens — see docs/superpowers/specs/2026-05-03-ph2-background-tokens-design.md */

  /* Spacing scale */
  --space-0: 0;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 3rem;
  --space-8: 4rem;
  --space-9: 6rem;
  --gutter: 1.5rem;   /* design-system page gutter */

  /* Radius scale */
  --radius-xs: 2px;
  --radius-sm: 3px;
  --radius-md: 0.4rem;
  --radius-lg: 0.75rem;
  --radius-xl: 1.25rem;
  --radius-pill: 999px;

  /* Keyboard focus ring — one shorthand for the hand-copied `2px solid var(--red)`
     outline. Value is identical in dark mode, so it lives in the light :root only. */
  --focus-ring: 2px solid var(--red);
  --focus-ring-offset: 2px;

  /* Stacking order, low -> high. Global chrome only; page-local stacks stay literal.
     A page-local literal that happens to EQUAL one of these ties, and a tie is
     settled by document order — which is how the tag listbox lost to the recipe
     editor's sticky action bar and showed the user nothing (#3674). A popover
     that opens off a field outranks the raised chrome it opens over. */
  --z-raised: 10;
  --z-popover: 20;
  --z-nav: 30;
  --z-nav-overflow: 80;
  --z-overlay: 90;
  --z-toast: 100;

  /* Type scale (root font-size = 18px) */
  --type-xs: 0.72rem;
  --type-sm: 0.85rem;
  --type-base: 1rem;
  --type-lg: 1.15rem;
  --type-xl: 1.3rem;
  --type-2xl: 1.4rem;
  --type-3xl: 2.6rem;
  --type-4xl: 3.8rem;
  --type-5xl: 5.6rem;       /* curtain-rise hero (rare) */

  /* Line-height scale — typographic leading tokens */
  --leading-flat: 1;        /* UI resets, single-line chrome */
  --leading-tight: 1.2;     /* large/display headings */
  --leading-snug: 1.35;     /* section headings */
  --leading-body: 1.4;      /* body default */
  --leading-relaxed: 1.5;   /* long-form copy */
  --leading-prose: 1.75;    /* recipe instructions / long prose */

  /* Letter-spacing scale */
  --tracking-normal: 0;
  --tracking-tight: -0.01em;
  --tracking-wide: 0.12em;  /* uppercase eyebrows */

  /* Easing curves */
  --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-bloop: cubic-bezier(0.16, 0.75, 0.40, 1);
  --ease-whisk: cubic-bezier(0.5, 1.35, 0.4, 1);

  /* Paper shadow — 3-layer with directional offset */
  --shadow-paper:
    0 1px 3px rgba(0, 0, 0, 0.07),
    0 4px 8px rgba(0, 0, 0, 0.05),
    3px 8px 14px rgba(0, 0, 0, 0.08);

  /* Hover lift for the paper cluster (#4192) — measurably heavier than the
   * --shadow-paper resting state, not the pre-#1385 --shadow-sm this rung
   * used to reuse back when paper had no shadow at rest. */
  --shadow-paper-lift:
    0 2px 4px rgba(0, 0, 0, 0.09),
    0 6px 12px rgba(0, 0, 0, 0.07),
    4px 12px 20px rgba(0, 0, 0, 0.11);

  /* Pressed-state inset — recipe cards / pressable surfaces */
  --shadow-press: inset 0 1px 2px rgba(0, 0, 0, 0.06);

  /* ── Pantry surfaces (Ph3) ─────────────────────────────────── */
  --editor-panel-bg: #faf6ef;

  /* Nav button vocabulary — lifted from Mirepoix_Design_System-2026-05-27/colors_and_type.css.
   * Aliases first: design-system names mapped to existing tokens of the same value.
   * The originals stay where they are; aliases let nav CSS use design-system names.
   */
  --red-deep:    var(--accent-hover);             /* #993030 */
  --red-soft:    var(--red-light);                /* rgba(179, 58, 58, 0.08) */
  --red-soft-2:  var(--hover-bg-strong);          /* rgba(179, 58, 58, 0.15) */

  /* Genuinely new colors */
  --ground-deep: #f3ede4;
  --red-glow:    rgba(179, 58, 58, 0.42);

  /* The RENDERED mean of the page background: --ground-deep with both
     crosshatch gradients (α 0.05) over it. iOS Safari paints its URL-bar
     bands with a flat fill sampled from body's declared background-color —
     never any background-image — so the declared color must equal what the
     textured page actually averages, or every bar state shows a full-width
     seam (#5034). DERIVED CONSTANT: re-measure if the ground, thread color,
     thread alpha, or period ever changes. Measured 2026-08-30, iPhone 17
     simulator (iOS 26.5), dpr 3: mean RGB over a gutter strip adjacent to the
     bar boundary, clear of card and dock shadows — a shadowed stretch reads a
     few units darker and gave the first, wrong calibration. Dark twin below. */
  --ground-mean: rgb(240 228 219);

  /* Button vocabulary — interactive shadow recipes */
  --shadow-deboss:
    inset 0 1px 3px rgba(0, 0, 0, 0.12),
    inset 0 -1px 0 rgba(255, 255, 255, 0.30);
  --shadow-deboss-deep:
    inset 0 2px 5px rgba(0, 0, 0, 0.12),
    inset 0 1px 0 rgba(0, 0, 0, 0.04),
    inset 0 -1px 0 rgba(255, 255, 255, 0.40);
  --shadow-deboss-modal:
    inset 0 1px 3px rgba(0, 0, 0, 0.10),
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.02),
    0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-emboss:
    inset 0 1px 0 rgba(255, 255, 255, 0.9),
    0 1px 2px rgba(0, 0, 0, 0.06),
    0 2px 4px rgba(0, 0, 0, 0.04);
  --shadow-emboss-bordered:
    inset 0 0 0 1px rgba(0, 0, 0, 0.05),
    inset 0 1px 0 rgba(255, 255, 255, 0.9),
    0 1px 2px rgba(0, 0, 0, 0.06),
    0 2px 4px rgba(0, 0, 0, 0.04);

  /* Halos — backlit-paper hover glow */
  --halo-text: 0 0 5px var(--red-glow);
  --halo-icon: drop-shadow(0 0 5px var(--red-glow));
  /* Padded-master ratio of the committed wordmark SVG (docs/brand/README.md).
   * Regenerating the wordmark (docs/brand/build_wordmark_svg.py) prints the
   * replacement value for this constant. */
  --wordmark-aspect: 2632 / 993;
  /* Optical-centring nudge: how far DOWN to translate the wordmark glyph so its
   * ink centre of mass — not its geometric box centre — lands on a flexbox
   * centre line beside the coral mark. Derived from the glyph's area-weighted
   * centroid and printed by build_wordmark_svg.py. Applied only where the
   * wordmark sits next to the mark (nav pill, help topbar); a standalone
   * heading needs none. */
  --wordmark-optical-shift: 7.50%;

  /* Pill chrome — opaque paper capsule */
  --pill-bg:     rgb(255, 252, 249);
  --pill-border: rgba(0, 0, 0, 0.07);
  --pill-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.65),
    inset 0 -1px 0 rgba(0, 0, 0, 0.04),
    0 1px 3px rgba(0, 0, 0, 0.08),
    0 6px 14px rgba(0, 0, 0, 0.10),
    3px 12px 28px rgba(0, 0, 0, 0.14);
  /* How far --pill-shadow's widest layer paints outside the pill's own border
     box: `3px 12px 28px` reaches offset + blur/2 = 26px below and 17px to the
     right. navshell.css inflates the nav's overflow clip edge by this, because
     WebKit clips a clip container's INK on both axes (#5008) — so if the widest
     layer here grows, that inflation has to grow with it. The dark recipe below
     restates the same geometry, so one value covers both. */
  --pill-shadow-bleed: 28px;

  /* Tighter recipe for the mobile dock's pills (#5034): max downward reach
     3px offset + 8px blur/2 = 7px, so the shadow dies above iOS Safari's
     bar-fill boundary instead of being visibly guillotined by it (Safari's
     fill is not page canvas — no ink of ours crosses into it). */
  --pill-shadow-tight:
    inset 0 1px 0 rgba(255, 255, 255, 0.65),
    inset 0 -1px 0 rgba(0, 0, 0, 0.04),
    0 1px 2px rgba(0, 0, 0, 0.09),
    0 3px 8px rgba(0, 0, 0, 0.11);

  /* Popovers — brand menu, search sheet */
  --shadow-popover:
    0 1px 3px rgba(0, 0, 0, 0.08),
    0 4px 10px rgba(0, 0, 0, 0.06),
    3px 8px 22px rgba(0, 0, 0, 0.14);
  --shadow-popover-lg:
    0 1px 3px rgba(0, 0, 0, 0.08),
    0 8px 24px rgba(0, 0, 0, 0.14),
    3px 14px 36px rgba(0, 0, 0, 0.16);

  /* Additional motion */
  --ease-curtain:     cubic-bezier(0.22, 0.61, 0.36, 1);
  --duration-curtain: 0.3s;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Canonical dark-mode color tokens */
    --ground: #1e1b18;
    --text: #dcd7d0;
    --text-soft: #a09788;
    --text-light: #706960;
    --text-faint: #4a443d;
    /* Lightened from #c85050 (#4678, #4683). The old value measured 3.85:1 as
     * text on --ground — below WCAG 1.4.3's 4.5:1 — which made every small red
     * label and every --red hover state a failure in dark mode. Same hue and
     * saturation, lightness raised only: this is the `lift` candidate of the
     * 2026-08-25 preview round, judged on real pages against three
     * desaturated alternatives. Now 4.53:1.
     *
     * Only the --ground FAMILY is safe. 4.53 holds against --ground,
     * --content-card-bg, --input-bg, --ground-deep and --editor-panel-bg
     * (all #1a1816–#1e1b18). It does NOT hold on raised surfaces:
     * --surface-alt is 3.92:1 and --tag-bg 3.20:1, and no lightening of --red
     * reaches 4.5 there without leaving the brand — the desaturated
     * candidates all failed those two as well. Nothing renders red small text
     * on those surfaces today; if you are about to, that is the constraint. */
    --red: #ce6262;
    /* Deliberately NOT lifted with --red. White text sits on this fill, so
     * raising lightness lowers white's contrast against it — white on --red's
     * #ce6262 is only 3.79:1. Held where it was, at 4.66:1 vs white; see the
     * light-mode definition's comment for why this can't just be --red. */
    --red-fill: #c64b4b;
    --red-light: rgba(206, 98, 98, 0.12);
    --tag-filter-active-bg: rgba(206, 98, 98, 0.2);
    --rule: #3a3530;
    --rule-faint: #2e2a26;

    --surface-alt: #2a2724;
    --danger: #d05050;
    --hover-bg: rgba(255, 255, 255, 0.04);
    --hover-bg-strong: rgba(206, 98, 98, 0.22);

    --input-bg: #1a1816;
    --accent-hover: #b73939;
    --scaled-highlight: rgba(180, 140, 60, 0.2);
    --converge-flash: rgba(180, 140, 60, 0.26);

    /* Shadows — reduced opacity for dark backgrounds */
    --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.12);
    --shadow-dialog: 0 4px 24px rgba(0, 0, 0, 0.3);
    --dialog-backdrop: rgba(0, 0, 0, 0.65);
    --shadow-nav-up: 0 -2px 4px rgba(0, 0, 0, 0.16), 0 -1px 2px rgba(0, 0, 0, 0.08);

    /* Status indicators */
    --aisle-renamed-bg: rgba(180, 140, 40, 0.15);
    --aisle-renamed-border: rgba(180, 140, 40, 0.3);
    --aisle-new-bg: rgba(60, 140, 70, 0.15);
    --aisle-new-border: rgba(60, 140, 70, 0.3);
    --broken-reference-bg: rgb(42, 36, 32);
    --aisle-row-border: rgb(55, 52, 48);
    --danger-hover-bg: rgba(220, 60, 50, 0.12);

    /* Tag pill colors (category pills share them — see .tag-pill--category) */
    --tag-bg: #3a3530;
    --tag-text: #c8c0b5;

    /* Smart tag color groups */
    --smart-green-bg: #1a3a1e;    --smart-green-text: #a8d8a0;
    --smart-amber-bg: #3a3018;    --smart-amber-text: #d8c070;
    --smart-blue-bg: #1a2e3e;     --smart-blue-text: #90c0e0;
    --smart-rose-bg: #3a2420;     --smart-rose-text: #d8a890;
    --smart-purple-bg: #2a1e3a;   --smart-purple-text: #c0a8e0;
    --smart-cuisine-bg: #3a2820;  --smart-cuisine-text: #d8b0a0;

    --content-card-bg: rgb(30, 27, 24);
    --paper-opacity: 0.03;

    /* ── Pantry surfaces (Ph3) ─────────────────────────────────── */
    --editor-panel-bg: #1d1a14;

    /* Nav button vocabulary — dark counterparts (verbatim from
     * Mirepoix_Design_System-2026-05-27/colors_and_type.css:224-298). */
    --ground-deep: #181512;

    /* Dark twin of the light block's --ground-mean: --ground (#1e1b18) with
       both dark crosshatch gradients (α 0.06) over it. Same derivation and
       same re-measure rule — see the light definition's comment. */
    --ground-mean: rgb(39 31 28);
    --red-glow:    rgba(206, 98, 98, 0.55);

    /* Aliases — point at the dark variants of existing tokens */
    --red-deep:    var(--accent-hover);              /* dark: #b73939 */
    --red-soft:    var(--red-light);                 /* dark: rgba(206, 98, 98, 0.12) */
    --red-soft-2:  var(--hover-bg-strong);           /* dark: rgba(206, 98, 98, 0.22) */

    /* Button vocabulary — white counter-highlights drop to warm-white at
     * lower opacity; black inset shadows deepen for paper depth. */
    --shadow-deboss:
      inset 0 1px 3px rgba(0, 0, 0, 0.45),
      inset 0 -1px 0 rgba(255, 252, 249, 0.04);
    --shadow-deboss-deep:
      inset 0 2px 5px rgba(0, 0, 0, 0.50),
      inset 0 1px 0 rgba(0, 0, 0, 0.30),
      inset 0 -1px 0 rgba(255, 252, 249, 0.06);
    --shadow-deboss-modal:
      inset 0 1px 3px rgba(0, 0, 0, 0.45),
      inset 0 1px 0 rgba(255, 252, 249, 0.04),
      inset 0 -1px 0 rgba(255, 252, 249, 0.02),
      0 1px 2px rgba(0, 0, 0, 0.25);
    --shadow-emboss:
      inset 0 1px 0 rgba(255, 252, 249, 0.08),
      0 1px 2px rgba(0, 0, 0, 0.35),
      0 2px 4px rgba(0, 0, 0, 0.25);
    --shadow-emboss-bordered:
      inset 0 0 0 1px rgba(255, 252, 249, 0.06),
      inset 0 1px 0 rgba(255, 252, 249, 0.08),
      0 1px 2px rgba(0, 0, 0, 0.35),
      0 2px 4px rgba(0, 0, 0, 0.25);

    /* Halos — the light values (0 0 5px var(--red-glow)) inherit via the
     * cascade; --red-glow's dark value gives a brighter halo against dark. */

    /* Pill chrome — warm-dark fill + low-opacity warm-white top highlight
     * (much subtler than the light recipe's 0.65 white). */
    --pill-bg:     rgb(34, 31, 28);
    --pill-border: rgba(255, 252, 249, 0.08);
    --pill-shadow:
      inset 0 1px 0 rgba(255, 252, 249, 0.06),
      inset 0 -1px 0 rgba(0, 0, 0, 0.30),
      0 1px 3px rgba(0, 0, 0, 0.30),
      0 6px 14px rgba(0, 0, 0, 0.35),
      3px 12px 28px rgba(0, 0, 0, 0.45);

    /* Dark twin of the tight dock recipe — see the light definition (#5034). */
    --pill-shadow-tight:
      inset 0 1px 0 rgba(255, 252, 249, 0.06),
      inset 0 -1px 0 rgba(0, 0, 0, 0.30),
      0 1px 2px rgba(0, 0, 0, 0.32),
      0 3px 8px rgba(0, 0, 0, 0.38);

    /* Popovers — deeper shadows against the darker tablecloth. */
    --shadow-popover:
      0 1px 3px rgba(0, 0, 0, 0.40),
      0 4px 10px rgba(0, 0, 0, 0.30),
      3px 8px 22px rgba(0, 0, 0, 0.45);
    --shadow-popover-lg:
      0 1px 3px rgba(0, 0, 0, 0.40),
      0 8px 24px rgba(0, 0, 0, 0.40),
      3px 14px 36px rgba(0, 0, 0, 0.50);

    /* Paper/press shadows need more opacity to read on the dark surface */
    --shadow-paper:
      0 1px 3px rgba(0, 0, 0, 0.22),
      0 4px 8px rgba(0, 0, 0, 0.18),
      3px 8px 14px rgba(0, 0, 0, 0.26);
    --shadow-paper-lift:
      0 2px 4px rgba(0, 0, 0, 0.28),
      0 6px 12px rgba(0, 0, 0, 0.22),
      4px 12px 20px rgba(0, 0, 0, 0.32);
    --shadow-press: inset 0 1px 2px rgba(0, 0, 0, 0.22);
  }
}

/* Page background — the color is --ground-mean here AND on body, and the
   crosshatch texture lives on body::before (see its comment for the full
   physics). The split exists because iOS Safari's URL-bar bands are not page
   canvas: Safari fills them with a flat color sampled from body's declared
   background-color when body has one (verified with a discriminator color —
   the fill tracked it to the unit), falling back to the root's otherwise, and
   it ignores background-images on every element. Declaring the bare ground
   while rendering ground + threads produced a full-width seam in every bar
   state (#5034); declaring the rendered mean makes Safari's fill equal the
   page's average by construction. The root carries the same mean so overscroll
   and beyond-body regions match the bar fill. */
html {
  color-scheme: light dark;
  font-size: 18px;
  /* Keep focused or anchored elements clear of the sticky .navshell (top:0)
     when scrolled into view — WCAG 2.4.11 Focus Not Obscured (AA).
     Must be the nav's REAL height: the strip measures 102px on desktop and
     81px cramped, so any literal is wrong for one of them. --nav-top-height is
     published by nav_shell_controller on every layout (connect/resize/morph);
     the 3.5rem fallback only covers pre-boot and navless pages. A literal
     3.5rem (63px) here left focus landing 39px UNDER the desktop nav (#3718).
     --space-2 keeps the control off the nav's bottom edge rather than flush. */
  scroll-padding-top: calc(var(--nav-top-height, 3.5rem) + var(--space-2));
  touch-action: manipulation;
  scrollbar-gutter: stable;
  background-color: var(--ground-mean);
}

/* Crosshatch texture. An ABSOLUTE full-height layer, and specifically NOT any
   of the three homes it has previously lived in — each exclusion paid for:

   NOT `background-attachment: fixed` (#2117). iOS WebKit caches a
   fixed-attachment gradient as a composited GPU texture and, on a
   zoom/viewport-resize event (pinch, dynamic-toolbar show/hide, rotation),
   scales the stale texture instead of repainting it — the fine 3px crosshatch
   blows up into a blurry smear that clears only on the next repaint (WebKit
   #125118). That incident stands; do not reach for it again. The default
   scrolling attachment used here is a different mechanism and does not cache
   that way.

   NOT a FIXED pseudo-element (#5018). A fixed box is clipped to the LAYOUT
   viewport, which on iOS Safari stops ~60 CSS px above the screen while the
   bottom bar is minimised — so the band under the bar got page content and no
   crosshatch. An absolute box is document paint, like content: it reaches
   everything content reaches. (Overhanging the fixed layer — `inset: 0 0
   -100px` — does NOT escape the clip; measured.)

   NOT the root's own background-image (the brief #5018 fix). That reaches the
   minimised band, but it renders the threads over whatever color html
   declares — and Safari's bar fill samples the declared color while ignoring
   the image (#5034), so the declared color must be the RENDERED MEAN, and
   painting threads over the mean re-raises the page above it. Circular. The
   only stable arrangement: body declares the mean (which Safari copies), and
   this layer renders the true ground + threads, whose average IS that mean.

   body's position: relative makes body (not the viewport-sized initial
   containing block) this layer's containing block, so inset: 0 spans the full
   document; body's isolation: isolate makes z-index: -1 paint above body's
   own background instead of behind it. The pattern scrolls with the document —
   phase drift is invisible at a 3px period. */
body::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-color: var(--ground-deep);
  background-image:
    repeating-linear-gradient(135deg,
      rgba(179, 58, 58, 0.05) 0,
      rgba(179, 58, 58, 0.05) 1.5px,
      transparent 1.5px,
      transparent 3px),
    repeating-linear-gradient(45deg,
      rgba(179, 58, 58, 0.05) 0,
      rgba(179, 58, 58, 0.05) 1.5px,
      transparent 1.5px,
      transparent 3px);
}

/* Dark-mode overrides — the gradient colours are inlined (Track B owns those
   values); the base colour uses --ground. html and body need no override here:
   they read var(--ground-mean), which the dark token block redefines. */
@media (prefers-color-scheme: dark) {
  body::before {
    background-color: var(--ground);
    background-image:
      repeating-linear-gradient(135deg,
        rgba(206, 98, 98, 0.06) 0,
        rgba(206, 98, 98, 0.06) 1.5px,
        transparent 1.5px,
        transparent 3px),
      repeating-linear-gradient(45deg,
        rgba(206, 98, 98, 0.06) 0,
        rgba(206, 98, 98, 0.06) 1.5px,
        transparent 1.5px,
        transparent 3px);
  }
}



body,
html {
  margin: 0;
  padding: 0;
}

body {
  padding: env(safe-area-inset-top, 0px) var(--gingham-gap) 0;
  font-family: var(--font-body);
  color: var(--text);
  line-height: var(--leading-relaxed);  /* was 1.65 — fold per fonts audit */
  /* --ground-mean is what iOS Safari samples for its bar fills; position and
     isolation serve the body::before crosshatch layer — its comment (above)
     is the spec for all three. */
  background-color: var(--ground-mean);
  position: relative;
  isolation: isolate;
}


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

.wordmark { display: inline-block; line-height: 0; }
.wordmark__glyph {
  display: block;
  aspect-ratio: var(--wordmark-aspect);
  background-color: currentColor;
  -webkit-mask-image: url("/assets/wordmark-e4669a4f.svg"); mask-image: url("/assets/wordmark-e4669a4f.svg");
  -webkit-mask-repeat: no-repeat;          mask-repeat: no-repeat;
  -webkit-mask-position: center;           mask-position: center;
  -webkit-mask-size: contain;              mask-size: contain;
}

/* Skip link: the first focusable element in the body, letting keyboard users
   jump past the nav shell straight to <main> (WCAG 2.4.1 Bypass Blocks).
   Off-screen until focused, then slides in above the sticky nav. <main> carries
   tabindex="-1" so activating the link moves focus there, not just the scroll. */
.skip-link {
  position: fixed;
  top: 0.5rem;
  left: 0.5rem;
  z-index: var(--z-overlay);
  padding: 0.5rem 0.875rem;
  background: var(--ground);
  color: var(--text);
  border: 1.5px solid var(--rule);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
  transform: translateY(calc(-100% - 0.75rem));
  transition: transform 120ms ease-out;
}
.skip-link:focus {
  transform: translateY(0);
}

/* Grandfather policy banner — non-modal consent prompt rendered in the app shell
   (layouts/application) for users whose terms_accepted_at is still NULL. Sized to
   the content card: 41rem outer width (matches main), centered, full border so it
   reads as a sibling notice card, with a bottom gap equal to the navshell→banner
   gap (the navshell's gingham-gap padding). The button_to renders a <form>, so
   it's the second flex child. */
.policy-banner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  max-width: 41rem;
  margin: 0 auto var(--gingham-gap);
  padding: var(--space-3) var(--space-4);
  color: var(--text);
  background: var(--surface-alt);
  border: 1px solid var(--rule);
}
.policy-banner p {
  margin: 0;
  flex: 1 1 20rem;
}

/* Same defect .app-version a fixes further down, on the surface where it matters
   most: both banners sit outside <main>, so `main a` never colours them and the
   UA `a:link` rule rendered these links bright blue — rgb(0,0,238) light,
   rgb(158,158,255) dark, measured on the consent banner (#4560). Inheriting the
   banner's own colour keeps the UA underline, which is what makes the link
   findable once the blue is gone. The consent banner is where a user is asked to
   accept Terms and Privacy, so its two links being the one unstyled thing on the
   page was the worst instance of this class, not a cosmetic one. */
.policy-banner a,
.read-only-banner a {
  color: inherit;
}

/* Read-only lapse banner — same app-shell placement/sizing contract as
   .policy-banner (41rem card, centered, gingham-gap bottom margin) but with a
   warning-toned left accent since a lapsed plan is a caution, not routine
   chrome. Static markup keyed off current_kitchen.read_only? — no client
   state, so it survives a broadcast layout morph unattended. */
.read-only-banner {
  max-width: 41rem;
  margin: 0 auto var(--gingham-gap);
  padding: var(--space-3) var(--space-4);
  color: var(--text);
  background: var(--surface-alt);
  border: 1px solid var(--rule);
  border-left: 3px solid var(--warning);
}
.read-only-banner p {
  margin: 0;
}

/* Custom checkbox — shared base for menu and grocery pages */
.custom-checkbox {
  appearance: none;
  -webkit-appearance: none;
  width: 1rem;
  height: 1rem;
  border: 1.5px solid var(--rule);
  border-radius: var(--radius-xs);
  cursor: pointer;
  flex-shrink: 0;
  position: relative;
}

.custom-checkbox:checked {
  background-color: var(--red);
  border-color: var(--red);
}

.custom-checkbox:checked::after {
  content: '\2713';
  color: white;
  font-size: 0.75rem;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* Buttons — the one shared interactive style across the site */
.btn {
  appearance: none;
  -webkit-appearance: none;
  font-family: var(--font-body);
  font-size: 0.85rem;
  background: var(--ground);
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  padding: 0.3rem 0.75rem;
  color: var(--text);
  cursor: pointer;
  text-decoration: none;
  line-height: var(--leading-relaxed);
  transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease, color var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
}

@media (any-hover: hover) {
  .btn:hover {
    color: var(--text);
    border-color: var(--text);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
}

/* Press rung (HIG ladder 3, #4268): touch users never see hover, so every
   button variant acknowledges touch-down — deboss inset plus the variant's
   hover coloring. Variant :active blocks below out-cascade this via source
   order at equal specificity. */
.btn:active {
  border-color: var(--text);
  box-shadow: var(--shadow-press);
}

/* Shared focus ring — one source of truth for .btn, .btn-ghost, .btn-link,
   bare <button>, and bare <a>. .btn-link is listed explicitly: its `all: unset`
   strips the outline, so neither of the element selectors can be relied on to
   reach every call site.

   `a` is unscoped ON PURPOSE, and this is the #4560 fix. The ring used to be
   `main a:focus-visible`, which cannot reach a link the app renders outside
   <main> — the Mirabel panel (it yields after </main>) and .skip-link both got
   the UA ring instead: `1px auto rgb(16,16,16)` against the app's 2px red,
   measured by keyboard Tab. Buttons already had the unscoped treatment; links
   being scoped was the asymmetry, not a decision. Anything wanting a different
   keyboard affordance already says `outline: none` at its own specificity
   (.brandmenu__item's embossed card, .search-result's tint) and still wins. */
.btn:focus-visible,
.btn-ghost:focus-visible,
.btn-link:focus-visible,
button:focus-visible,
a:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

.btn-primary {
  background: var(--red-fill);
  color: white;
  border-color: var(--red-fill);
}

@media (any-hover: hover) {
  .btn-primary:hover {
    background: var(--accent-hover);
    border-color: var(--accent-hover);
  }
}

.btn-primary:active {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  box-shadow: var(--shadow-press);
}

.btn-danger {
  color: var(--danger);
  border-color: var(--danger);
}

@media (any-hover: hover) {
  .btn-danger:hover {
    background: var(--danger-hover-bg);
    color: var(--danger);
    border-color: var(--danger);
  }
}

.btn-danger:active {
  background: var(--danger-hover-bg);
  color: var(--danger);
  border-color: var(--danger);
  box-shadow: var(--shadow-press);
}

.btn-sm {
  font-size: 0.8rem;
  padding: 0.25rem 0.5rem;
  border-radius: 0.25rem;
}

.btn-link {
  all: unset;
  cursor: pointer;
  color: var(--red);
  text-decoration: underline dotted;
  font-family: var(--font-body);
}

@media (any-hover: hover) {
  .btn-link:hover {
    text-decoration-style: solid;
  }
}

/* Text control — press reads as color+underline shift, not a deboss. */
.btn-link:active {
  color: var(--accent-hover);
  text-decoration-style: solid;
}

.btn-ghost {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 0.3rem 0.6rem;
  border: none;
  border-radius: var(--radius-md);
  background: none;
  color: var(--text-soft);
  cursor: pointer;
  text-decoration: none;
  transition: color var(--duration-fast) ease, background var(--duration-fast) ease;
}

@media (any-hover: hover) {
  .btn-ghost:hover {
    color: var(--red);
    background: var(--hover-bg);
  }
}

.btn-ghost:active {
  color: var(--red);
  background: var(--hover-bg);
}

.btn-icon-round {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: 50%;
  background: none;
  color: var(--text-light);
  cursor: pointer;
  font-size: 1rem;
  line-height: var(--leading-flat);
  flex-shrink: 0;
  transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}

@media (any-hover: hover) {
  .btn-icon-round:hover {
    color: var(--text-soft);
    border-color: var(--rule);
  }
}

.btn-icon-round:active {
  color: var(--text-soft);
  box-shadow: var(--shadow-press);
}

.btn-icon-round:disabled {
  opacity: 0.3;
  cursor: default;
}

@media (any-hover: hover) {
  .btn-icon-round.btn-danger:hover {
    color: var(--danger);
    border-color: var(--danger);
  }
}

.btn-icon-round.btn-danger:active {
  color: var(--danger);
  border-color: var(--danger);
}

@media (any-hover: hover) {
  .btn-icon-round.btn-primary:hover {
    color: var(--red);
    border-color: var(--red);
  }
}

.btn-icon-round.btn-primary:active {
  color: var(--red);
  border-color: var(--red);
}

.btn-icon-round-lg {
  width: 2.25rem;
  height: 2.25rem;
  flex-shrink: 0;
}

.btn-pill {
  padding: 0.25rem 0.75rem;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  background: transparent;
  font-size: 0.85rem;
  font-family: var(--font-body);
  color: var(--text);
  cursor: pointer;
  transition: background var(--duration-fast) ease, border-color var(--duration-fast) ease;
}

@media (any-hover: hover) {
  .btn-pill:hover {
    background: var(--hover-bg);
    border-color: var(--red);
  }
}

.btn-pill:active {
  background: var(--hover-bg);
  border-color: var(--red);
}

.btn-pill.active,
.btn-pill[data-active] {
  background: var(--red-fill);
  color: white;
  border-color: var(--red-fill);
}

/* Selected pills need their own press rung: the selected rule above shares
   .btn-pill:active's specificity and later source order, so without this a
   pressed selected pill shows nothing (review on #4271). */
.btn-pill.active:active,
.btn-pill[data-active]:active {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  box-shadow: var(--shadow-press);
}

/* ── Input base ────────────────────────────────────────────── */

.input-base {
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--rule-faint);
  border-radius: var(--radius-sm);
  background: var(--input-bg);
  color: var(--text);
  box-sizing: border-box;
  outline: none;
}

.input-base:focus {
  outline: var(--focus-ring);
  outline-offset: -1px;
  border-color: var(--red);
}

.input-base::placeholder {
  color: var(--text-placeholder);
}

/* Persistent hint text between a label and its input (#3677). Deliberately
 * on --text-soft, not --text-light: this is real prose a user must read, so
 * it clears WCAG 1.4.3 on --ground (5.11:1 light, 5.94:1 dark) where
 * --text-light does not (2.72:1) — the same rung placeholder text now avoids
 * too, via --text-placeholder (#4477). This selector is 0-1-0 and loses to
 * document-editor's doc-crossref-field > span, which paints --text-light —
 * check the computed colour, not the token, when adding a hint inside the
 * recipe editor. Wire it to its input with aria-describedby — the visual
 * adjacency is not the association. Near-neighbours that stay page-scoped:
 * editor-help (block prose above a group, editor.css) and
 * settings-field-hint (checkbox-row indent, components/preferences_fields.css). */
.field-hint {
  display: block;
  font-size: 0.78rem;
  color: var(--text-soft);
  line-height: var(--leading-body);
  margin-bottom: 0.15rem;
}

.input-lg {
  font-size: 1rem;
  padding: 0.5rem 0.75rem;
}

.input-sm {
  width: 4.5rem;
  text-align: right;
}

.input-inline {
  padding: 0.25rem 0.4rem;
  font-size: 0.85rem;
}

.form-helper {
  color: var(--text-soft);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* iOS/iPadOS Safari auto-zooms — and never zooms back out — when a focused
   text control computes below 16px. Floor text-entry controls at 16px on touch
   devices so focus never triggers a zoom; desktop keeps the compact type scale.
   Scoped by pointer (not a width breakpoint) so it also covers landscape phones
   and iPad, which the old max-width patch missed. :not(.input-lg) keeps the
   deliberately larger onboarding inputs (login, invite code) at their size, and
   its 0-2-0 specificity lets the floor win regardless of source order. The
   bare-element rule nets unclassed controls (e.g. category <select>). */
@media (pointer: coarse) {
  input,
  select,
  textarea,
  .input-base:not(.input-lg),
  .input-inline:not(.input-lg) {
    font-size: 16px;
  }
}

/* ── Collapse ──────────────────────────────────────────────── */

.collapse-header {
  margin: 0;
  padding: 0;
  list-style: none;
}

.collapse-header summary {
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  list-style: none;
  user-select: none;
}

/* --text-soft, not --text-light (#4679): measured against --ground,
 * --text-light is 2.72:1 light / 3.17:1 dark — under WCAG 1.4.11's 3:1 floor
 * for a meaningful non-text indicator (this triangle IS the disclosure
 * affordance) in light mode. --text-soft clears it: 5.11:1 light, 5.94:1
 * dark — re-measured after this change, see the category-fold comment below
 * for the second consumer's numbers. */
.collapse-header summary::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 0.3rem 0 0.3rem 0.45rem;
  border-color: transparent transparent transparent var(--text-soft);
  transition: transform var(--duration-fast) ease;
  flex-shrink: 0;
}

.collapse-header[open] summary::before {
  transform: rotate(90deg);
}

.collapse-header summary::-webkit-details-marker {
  display: none;
}

.collapse-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--duration-fast) ease;
}

.collapse-header[open] + .collapse-body,
.collapse-header[open] ~ .collapse-body {
  grid-template-rows: 1fr;
}

.collapse-inner {
  min-height: 0;
  overflow: hidden;
}

/* Disclosure controllers (grocery aisles, pantry Well-stocked band) toggle this
   to suppress the .collapse-body grid-rows transition while restoring a saved
   open state on load/morph — otherwise the band animates on every paint. */
.no-transition { transition: none !important; }
main a {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: var(--rule-faint);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color var(--duration-normal) ease, text-decoration-color var(--duration-normal) ease;
}

@media (any-hover: hover) {
  main a:hover {
    color: var(--red);
    text-decoration-color: var(--red);
  }
}

/* The ring softener, at ZERO specificity on purpose (#4561). An outline follows
   its element's border-radius, so a bare inline link — which has none — gets a
   hard-cornered ring without this. But the same declaration at normal
   specificity CLOBBERED every <a> that gives itself a shape: .home-tile and
   .btn-ghost snapped 7.2px -> 2px while focused, .index-nav-link's anchor
   5.4px -> 2px, .editor-help-link 4px -> 2px, .btn 3px -> 2px — five components,
   the card visibly changing shape for as long as focus rested on it.
   `:where()` scores (0,0,0), so ANY rule a component writes for itself wins and
   the ring follows the real shape; a link that declares no radius still gets the
   2px. Zero specificity also makes the outcome independent of source order,
   unlike raising the components instead. */
:where(a:focus-visible) {
  border-radius: var(--radius-xs);
}

main {
  position: relative;
  /* Under the universal border-box reset, max-width is the outer extent. 41rem
     keeps main's visible card edges where the nav strip is calibrated to them
     (navshell.css .navshell__top); content area is ~35rem after 3rem padding. */
  max-width: 41rem;
  margin: 0 auto var(--gingham-gap);
  padding: 3rem 3rem 5rem;
  background: var(--content-card-bg);
  border: 1px solid var(--rule-faint);
  box-shadow:
    0 1px 3px rgba(0, 0, 0, 0.07),
    0 4px 8px rgba(0, 0, 0, 0.05),
    3px 8px 14px rgba(0, 0, 0, 0.08);
  /* dvh, not vh (#4268): vh is the LARGE viewport on iOS, so a short page's
     card overruns the visible area whenever the Safari bar is showing. */
  min-height: calc(100dvh - 2 * var(--gingham-gap) - 3rem);
}

main::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;
}

/* Lifts content children above main's ::before paper-noise overlay. <dialog>
   is excluded so the UA-default `:modal { position: fixed }` keeps applying —
   otherwise the modal's layout box stays anchored inside <main> (top-layer
   only relocates the paint), and scroll-into-view-on-focus scrolls the page
   to wherever that layout box sits. */
main > *:not(dialog) {
  position: relative;
}

/* Page-title chrome: recipe / recipe-listing document headers, plus any page opting in
   via .page-header. Scoped per #1876 — bare `header` leaked this app-wide. */
:where(.recipe, .recipe-listing) > header,
.page-header {
  text-align: center;
  margin-bottom: 1.5rem;
}

:where(.recipe, .recipe-listing) > header h1,
.page-header h1 {
  font-family: var(--font-display);
  font-size: 3.8rem;
  font-weight: 400;
  line-height: 1.05; /* named exception: display heading at 3.8rem needs tight leading */
  letter-spacing: -0.01em;
  margin: 0 0 0.5rem 0;
}

:where(.recipe, .recipe-listing) > header p,
.page-header p {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.3rem;
  margin-top: 0;
  color: var(--text-soft);
}

:where(.recipe) > header .recipe-meta {
  font-style: normal;
  font-family: var(--font-body);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  margin-top: 1rem;
  color: var(--text-soft);
  text-align: center;
}

:where(.recipe) > header .recipe-yield {
  font-family: var(--font-body);
  font-style: normal;
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-soft);
  text-align: center;
  margin-top: 0.25rem;
}

:where(.recipe) > header .recipe-meta a {
  color: inherit;
  text-decoration: none;
}

@media (any-hover: hover) {
  :where(.recipe) > header .recipe-meta a:hover {
    text-decoration: underline;
  }
}

.recipe-tags {
  margin-top: 0.25rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  justify-content: center;
}

.recipe-tag-pill {
  border: none;
  cursor: pointer;
  font-size: 0.68rem;
  letter-spacing: 0.05em;
}

@media (any-hover: hover) {
  .recipe-tag-pill:hover { opacity: 0.8; }
}

/* Scale bar — centered disclosure toggle + vertical reveal */
.scale-bar {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 0.6rem;
}

.scale-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  color: var(--text-soft);
  text-decoration: none;
  /* <button> (disclosure, #1545) reset — keep the plain-link appearance */
  background: none;
  border: 0;
  font-family: inherit;
  cursor: pointer;
  font-size: 0.85rem;
  padding: 0.3rem 0.6rem;
  border-radius: var(--radius-md);
  user-select: none;
  transition: color var(--duration-normal) ease, background-color var(--duration-slow) ease-out;
}

@media (any-hover: hover) {
  .scale-toggle:hover {
    color: var(--red);
    background-color: var(--hover-bg);
  }
}

.scale-toggle::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 0.3rem 0 0.3rem 0.45rem;
  border-color: transparent transparent transparent currentColor;
  transition: transform var(--duration-fast) ease;
}

.scale-bar.open .scale-toggle::before {
  transform: rotate(90deg);
}

/* Recipe actions — variable-count ghost-button rows with middot dividers.
   Standalone .recipe-actions (listing/page headers) is a single centered row.
   The recipe page stacks two rows in .recipe-actions-stack (#3105): a primary
   row (Print · Share · Edit · Scale) and a quieter member-only secondary row
   (History · Pin · Add photo). Buttons drop out conditionally; middots render
   only between rendered neighbors, so rows never strand a divider. */
.recipe-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 0.5rem;
}

:where(.recipe-listing) > header > .recipe-actions,
.page-header > .recipe-actions {
  margin-top: 0.6rem;
}

.recipe-actions-dot {
  color: var(--text-light);
  font-size: 0.85rem;
  line-height: var(--leading-flat);
}

.recipe-actions-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
}

/* Continue Draft popover row age (#4178). The kebab menu's action row is a
   flex row, so this rides that layout as a right-aligned trailing span
   rather than stacking under the title — blockifying a flex item doesn't
   stack it. */
.draft-menu-age {
  margin-left: auto;
  padding-left: 0.75rem;
  color: var(--text-soft);
  font-size: 0.8rem;
  white-space: nowrap;
}

/* .btn-ghost and .recipe-actions-dot both hardcode their own font-size
   (0.85rem) and color (--text-light already), so neither inherits an
   em-scaled size from an ancestor — override both directly here, at the
   same calc, so the buttons and their dot dividers shrink together rather
   than setting a no-op font-size (or redundant color) on
   .recipe-actions--secondary itself. */
.recipe-actions--secondary :is(.btn-ghost, .recipe-actions-dot, .menu-toggle) {
  font-size: calc(0.85rem * 0.85);
}

/* Unify the row's resting text on --text-soft, the AA-passing rung (~945):
   .btn-ghost and .recipe-actions-dot rest on --text-light, which fails WCAG
   1.4.3 at this row's ~13px size, while .menu-toggle already rests on
   --text-soft — so the row levels up to the readable token rather than down
   to the faint one. :where() keeps this at (0,1,0): it beats the earlier
   same-specificity base colors on source order but still loses to every
   (0,2,0) state rule — .btn-ghost:hover/:active's --red and .menu-toggle's
   .is-on --text all survive. */
.recipe-actions--secondary :where(.btn-ghost, .recipe-actions-dot) {
  color: var(--text-soft);
}

/* .menu-toggle's own padding (~1741) is sized for the recipe-index rail chip;
   match .btn-ghost's tighter box here instead of dropping it in at rail size. */
.recipe-actions--secondary .menu-toggle {
  padding: 0.3rem 0.6rem;
}

/* On narrow screens the primary row can still flex-wrap, which would leave
   a middot dangling at the wrapped line's end — there's no CSS way to drop
   only that dot, so drop them all and let the row gap separate. Scoped to
   the recipe page's stack; single-row consumers elsewhere never wrap. */
@media (max-width: 480px) {
  .recipe-actions-stack .recipe-actions-dot {
    display: none;
  }
}


/* Scale controls — animated vertical expand/collapse */
.scale-controls {
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  transition: grid-template-rows var(--duration-fast) ease;
}

.scale-bar.open .scale-controls {
  grid-template-rows: 1fr;
}

.scale-controls-inner {
  min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  white-space: nowrap;
}

.scale-controls-center {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 0.5rem;
  min-height: 0;
  padding: 0.5rem 0.25rem;
}

.scale-controls-center > :first-child {
  justify-self: end;
}

.scale-controls-center > :last-child {
  justify-self: start;
}

.scale-input-group {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.scale-presets {
  display: flex;
  gap: 0.25rem;
}

.scale-preset {
  line-height: var(--leading-flat);
}

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

.scale-preset.active {
  background: var(--red-fill);
  border-color: var(--red-fill);
  color: white;
  animation: pulse var(--duration-bloop) var(--ease-bounce);
}

.scale-divider {
  width: 1px;
  height: 1.25rem;
  background: var(--rule);
}

.scale-input {
  text-align: center;
}

/* Rule 4's visible label for the custom scale factor (#3677 phase 4). Sits in
   the flex group beside the field rather than above it — the panel is a single
   dense control strip, and a stacked label would grow its height. */
.scale-input-label {
  font-size: 0.78rem;
  color: var(--text-soft);
  white-space: nowrap;
}

.scale-input.invalid,
.scale-input.invalid:focus {
  outline: 2px solid var(--danger);
  outline-offset: -1px;
  border-color: var(--danger);
  color: var(--danger);
  background: color-mix(in srgb, var(--danger) 8%, var(--input-bg));
}

/* Reserve the Reset button's slot in the flex row so revealing it never shifts
   the input. It must stay in layout when inert, so it can't use the `hidden`
   attribute (that means display:none) — scale_panel_controller toggles this
   class. visibility:hidden also takes it out of tab order + the a11y tree. */
.scale-reset.is-hidden {
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
}

.scalable.scaled {
  background-color: var(--scaled-highlight);
  border-radius: var(--radius-xs);
  padding: 0 1px;
}

.recipe-meta .scalable.scaled,
.recipe-yield .scalable.scaled {
  background-color: transparent;
}

:where(.recipe, .recipe-listing) > header::after,
.page-header::after {
  content: "";
  display: block;
  width: 40px;
  height: 1px;
  background: var(--red);
  margin: 1.5rem auto 0;
}

/* The history frame wraps the recipe body inline (#2076); it must contribute no
   box so the body lays out exactly as before. Selector matching still sees the
   frame in the DOM, so the body-level child combinators below are relaxed too. */
#recipe-history { display: contents; }

/* Document section rhythm — scoped per #1876; non-document pages own their spacing. */
:where(.recipe, .embedded-recipe, .recipe-listing) section {
  margin-top: 2.5rem;
  break-inside: avoid;
}

:where(.recipe, .embedded-recipe, .recipe-listing) section > ul {
  column-count: 2;
  column-gap: 2.5rem;
  list-style: none;
  padding-left: 0;
}

:where(.recipe, .embedded-recipe, .recipe-listing) section > ul li {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

:where(.recipe, .embedded-recipe, .recipe-listing) section > ul li a {
  color: var(--text);
  text-decoration: none;
  font-size: 0.95rem;
  font-weight: 400;
  transition: color var(--duration-normal);
}

@media (any-hover: hover) {
  :where(.recipe, .embedded-recipe, .recipe-listing) section > ul li a:hover {
    color: var(--red);
  }
}

:where(.recipe, .embedded-recipe, .recipe-listing) section h2 {
  font-family: var(--font-display);
  font-size: 1.4rem;
  font-weight: 400;
  line-height: var(--leading-snug);
  text-transform: none;
  letter-spacing: normal;
  margin-top: 3.5rem;
  margin-bottom: 1.25rem;
  border-bottom: none;
  padding-bottom: 0;
}

:where(.recipe, .embedded-recipe, .recipe-listing) section:first-of-type h2 {
  margin-top: 0;
}

.recipe-listing section h2 {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: normal;
  color: var(--text);
  margin: 3rem 0 0;
  padding-bottom: 0;
  border-bottom: none;
}

/* Recipes index: unified navigation (categories + tag filters) */
.index-nav {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

.index-nav-section {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
}

.index-nav-categories {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

.index-nav-tags {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

/* Inline edit affordance — small ghost-text "edit X" zone button (recipe
   index nav, quick-bites). Hover guarded so touch devices don't stick. */
.btn-inline-edit {
  background: none;
  border: none;
  color: var(--text-soft);
  font-size: 0.7rem;
  font-family: var(--font-body);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.1rem 0.3rem;
  border-radius: var(--radius-sm);
}

@media (any-hover: hover) {
  .btn-inline-edit:hover {
    color: var(--red);
    background: var(--hover-bg);
  }
}

.index-nav-link a {
  font-family: var(--font-display);
  font-size: 1rem;
  color: var(--text);
  text-decoration: underline;
  text-underline-offset: 2px;
  padding: 0.2rem 0.5rem;
  border-radius: 0.3rem;
  transition: color var(--duration-normal) ease,
              background-color var(--duration-slow) ease-out;
}

@media (any-hover: hover) {
  .index-nav-link a:hover {
    color: var(--red);
    background-color: var(--hover-bg);
  }
}

.index-nav-link:not(:last-child)::after {
  content: " \00b7";
  color: var(--text-light);
}

.index-nav-divider {
  height: 1px;
  background: var(--rule-faint);
}

/* Recipes index: category heading with back-to-top */
.category-heading {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 0.4rem;
}

.category-heading h2 {
  margin: 0;
}

.category-top-link {
  flex-shrink: 0;
  font-size: 0.75rem;
  color: var(--text-soft);
  text-decoration: none;
  padding-bottom: 0.25rem;
  white-space: nowrap;
}

@media (any-hover: hover) {
  .category-top-link:hover {
    color: var(--red);
  }
}

.category-permalink-wrap {
  font-size: 0.85em;
  font-weight: normal;
  color: var(--text-light);
}

@media (any-hover: hover) {
  .category-permalink-wrap {
    opacity: 0;
    transition: opacity 0.15s ease;
  }

  .category-heading:hover .category-permalink-wrap,
  .category-heading:focus-within .category-permalink-wrap {
    opacity: 1;
  }
}

.category-permalink {
  color: var(--text-light);
  text-decoration: none;
}

@media (any-hover: hover) {
  /* The wrapper's opacity:0 reveal above is itself any-hover-gated, so on touch
     this permalink is permanently visible and genuinely tappable — the gate here
     is what keeps a tap from leaving it red and underlined. */
  .category-permalink:hover {
    color: var(--red);
    text-decoration: underline;
  }
}

/* Recipes index: category fold (#4622) — collapsed remainder of a category's
 * grid. The <details> carries BOTH `.collapse-header` and `.category-fold`
 * (markup, not a CSS extends): `.collapse-header summary` already gives any
 * <summary> inside it the flex layout, cursor, triangle marker, and
 * open-state rotation, at 0-1-1 specificity, so `.category-fold__summary`
 * only needs to declare what's genuinely different for this consumer —
 * duplicating the whole idiom here (Task 11's original approach, judged in
 * design preview) meant two places to keep an affordance change in sync.
 * `.collapse-header` is used unmodified in recipes/index.html.erb (recently
 * deleted) and, with its own overrides, in editor.css — both audited after
 * this change; see the --text-soft fix on `.collapse-header summary::before`
 * above (#4679) for why NEITHER needed a fork.
 *
 * Targets the `.category-fold__summary` CLASS, never a bare `summary` type
 * selector: the class is already on the element, and `.category-fold summary`
 * would sit at 0-1-1, silently outranking any later 0-1-0 override written
 * against the class — the same trap the --field-hint comment above documents
 * losing to a document-editor descendant selector.
 *
 * On --text-soft, not --text-light: this is prose a member reads to decide
 * whether to expand, so WCAG 1.4.3's 4.5:1 applies. Measured against
 * --ground, --text-light is 2.72:1 light / 3.17:1 dark — under the floor in
 * both themes (see the --text-light token comment above and the #3677
 * --field-hint precedent this follows). --text-soft clears it: 5.11:1 light,
 * 5.94:1 dark. (The triangle now rides --text-soft too, but via the shared
 * `.collapse-header summary::before` rule above, not a rule here.)
 *
 * padding/font-size bumped from Task 11's 0.35rem 0 / 0.85rem
 * (var(--type-sm)) per Chris's design-preview feedback ("a little bit of
 * margin or padding, and a bit larger text") — --space-2 and var(--type-base)
 * rather than new magic numbers or an invented rung. */
.category-fold__summary {
  padding: var(--space-2) 0;
  color: var(--text-soft);
  font-size: var(--type-base);
}

.category-fold__summary:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

@media (any-hover: hover) {
  .category-fold__summary:hover { color: var(--red); }
  .category-fold__summary:hover::before { border-left-color: var(--red); }
}

.category-fold__all { display: none; }
.category-fold[open] .category-fold__more { display: none; }
.category-fold[open] .category-fold__all { display: inline; }

/* Recipes index: recipe card grid.
 * A real <ul> for list semantics (sem-4). The list reset + `column-count`
 * override neutralize the `section > ul` index-column rules, which would
 * otherwise multi-column this grid since it's a direct child of <section>. */
.recipe-card-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.5rem;
  list-style: none;
  margin: 0;
  padding: 0;
  column-count: initial;
}

@media (max-width: 599px) {
  .recipe-card-grid {
    grid-template-columns: 1fr;
  }

  .index-nav {
    gap: 0.3rem;
  }
}

/* Recipes index: recipe cards — elevated paper card (#1385), typography-first
 * interior with a footer rail (#2697). Each card is an <li> (sem-4). Card
 * chrome (paper bg + radius + shadow) is the shared `card` component (#3171);
 * this rule carries structure, interaction, and the press-state shadow only.
 * `white-space`/`text-overflow` reset the single-line ellipsis the
 * `section > ul li` index rule would otherwise impose. */
.recipe-card {
  display: flex;
  flex-direction: column;
  position: relative;
  overflow: hidden;
  min-height: 2.75rem;
  color: var(--text);
  text-decoration: none;
  white-space: normal;
  text-overflow: clip;
  transition: background var(--duration-normal),
              box-shadow var(--duration-normal),
              transform var(--duration-fast) var(--ease-bloop);
}

/* Top-align so the title sits in a consistent slot regardless of whether the
 * optional description/meta render. Cards stretch to the tallest in their grid
 * row; centering floated the title of a description-less card down out of line
 * with its neighbours (#2415). */
.recipe-card__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: var(--space-2);
  padding: var(--space-4);
}

/* Heading wrapper (headings-1): semantics only. All visual styling stays on
 * the inner .recipe-card__title link, so the rendered title is unchanged.
 * Reset the UA heading margins/weight/size the <h3> would otherwise add. */
.recipe-card__heading {
  margin: 0;
  font-weight: inherit;
  font-size: inherit;
}

.recipe-card__title {
  font-family: var(--font-display);
  font-style: italic;        /* the italic is the voice */
  font-weight: 400;
  font-size: var(--type-xl);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-tight);
  color: var(--text);
  text-decoration: none;
}

/* Stretched link: the whole card is the title link's hit area */
.recipe-card__title::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* Single focus indicator: ring the card boundary, and suppress the link's
 * own outline (main a:focus-visible) so we don't draw two concentric rings. */
.recipe-card .recipe-card__title:focus-visible {
  outline: none;
}

.recipe-card:has(.recipe-card__title:focus-visible) {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* Hover rung (#4192) — the card shows --shadow-paper at rest (#1385), so
 * hover needs its own lift, not just the title-link color main a:hover
 * already supplies. Keyed off .recipe-card, not the title link, so the rail
 * controls (.menu-toggle/.pin-toggle/.star-toggle) painting above the stretched link
 * (z-index:1) don't drop the highlight when the pointer crosses onto them. */
@media (any-hover: hover) {
  .recipe-card:hover {
    --card-shadow: var(--shadow-paper-lift);
  }

  .recipe-card:hover .recipe-card__title {
    color: var(--red);
  }
}

/* Two rendered lines, width-responsive; the full text stays in the DOM, so
 * screen readers still get it all (#2697). */
.recipe-card__desc {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--type-sm);
  line-height: var(--leading-snug);
  color: var(--text-soft);
}

/* Footer rail (#2697): hairline-anchored bottom line — time left, add-to-menu
 * action right. margin-top:auto pins it to the grid-stretched bottom edge so
 * cards in a row share a baseline. Rendered only when it has content. */
.recipe-card__rail {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-top: auto;
  padding-top: var(--space-2);
  border-top: 1px solid var(--rule-faint);
  font-family: var(--font-body);
  font-size: var(--type-sm);
  color: var(--text-soft);
}

.recipe-card__time {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

.recipe-card__time svg {
  flex-shrink: 0;
}

/* Rail line (#4622) — "on the menu 3×" / "added 5 days ago" prose beside the
 * star mark. Real text a visitor reads, so --text-soft, not --text-light:
 * measured against --content-card-bg (the shared card component's surface,
 * #3171 — a different pair than the fold summary's page-ground one above),
 * --text-light is 2.82:1 light / 3.17:1 dark, under WCAG 1.4.3's 4.5:1 in
 * both themes. --text-soft clears it (5.30:1 light / 5.94:1 dark). This also
 * matches the color .recipe-card__rail already sets on its parent, so the
 * decl below is belt-and-suspenders rather than a real override. */
.recipe-card__rail-line {
  font-size: 0.75rem;
  color: var(--text-soft);
}

/* Pressed state — pure CSS; fires via the title link on pointer/touch/keyboard.
 * Navigation activates on release (native anchor); this is press-in feedback. */
.recipe-card:has(.recipe-card__title:active) {
  transform: translateY(1px);
  box-shadow: var(--shadow-press);
}

/* Add-to-menu toggle (#2048; #2697 dropped the bordered chip) — shared by the
   recipes-index card rail and the recipe page's own actions row (#4194). A
   quiet text action: soft ink, no chrome. State is never hue-only: .is-on
   swaps the glyph AND the visible words; the emphasis color (--text over
   --text-soft) carries no hue of its own (#4640). The
   z-index/negative-margin rules below are index-rail-specific: the z-index
   lifts the button above the recipe card's stretched title link
   (.recipe-card__title::after, inset:0) so a tap lands on the toggle, not the
   link, and the negative margin keeps the rail visually slim inside the 44px
   hit target. The recipe-page usage is sized instead by
   .recipe-actions--secondary .menu-toggle. */
.menu-toggle {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  border: none;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-soft);
  font-family: var(--font-body);
  font-size: var(--type-sm);
  cursor: pointer;
  padding: 0.75rem 0.5rem;
  transition: color var(--duration-fast) ease, background var(--duration-fast) ease;
}
@media (any-hover: hover) {
  .menu-toggle:hover {
    color: var(--text);
    background: var(--surface-alt);
  }
}
.menu-toggle:active {
  color: var(--text);
  background: var(--surface-alt);
}
.recipe-card__rail > .menu-toggle {
  margin: calc(-1 * var(--space-2)) -0.5rem;   /* clamped to the rail's padding — #4636, see .star-toggle */
  margin-left: auto;   /* right-aligns a toggle-only rail; harmless beside time */
}
.menu-toggle__off { display: inline-flex; align-items: center; gap: 0.3rem; }
.menu-toggle__on  { display: none; align-items: center; gap: 0.3rem; }
.menu-toggle.is-on .menu-toggle__off { display: none; }
.menu-toggle.is-on .menu-toggle__on  { display: inline-flex; }
.menu-toggle.is-on {
  color: var(--text);
}

/* Recipe-card pin and star toggles (#4405, #4621) — one grouped ladder,
   mirroring .menu-toggle's full chrome (raised above the stretched title link,
   44px hit target via negative-margin padding, negative margin scoped to a
   direct rail child, hover/active background — #4192). Neither carries a
   hue-based on-state: the Pin/Unpin and Star/Unstar label swaps
   (recipe_pin_controller.js, recipe_star_controller.js) are the state cue,
   same as the recipe page's .btn-ghost buttons. Star additionally FILLS its
   glyph when pressed (below) — the one place the two diverge, because a
   filled star is the conventional read and the rail label is visually quiet.
   Both render left of .menu-toggle in DOM order; .menu-toggle's own
   margin-left: auto still pushes it to the rail's right edge regardless. */
.pin-toggle,
.star-toggle {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  border: none;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-soft);
  font-family: var(--font-body);
  font-size: var(--type-sm);
  cursor: pointer;
  padding: 0.75rem 0.5rem;
  transition: color var(--duration-fast) ease, background var(--duration-fast) ease;
}
@media (any-hover: hover) {
  .pin-toggle:hover,
  .star-toggle:hover {
    color: var(--text);
    background: var(--surface-alt);
  }
}
.pin-toggle:active,
.star-toggle:active {
  color: var(--text);
  background: var(--surface-alt);
}
/* The vertical pull is tied to the rail's OWN padding-top, and must never
   exceed it (#4636). At the old flat -0.75rem the control's box sat 3.5px
   ABOVE the rail's border-top — 13.5px of pull against 9px of padding and a
   1px border — so a :hover background painted straight over the separator.
   Clamping the pull to var(--space-2) lets the rail grow to fit the 44px hit
   target instead of letting the control escape upward, and keeps the two in
   step if the padding token ever moves. Horizontal pull is unconstrained: no
   rule runs down the card's sides. */
.recipe-card__rail > .pin-toggle,
.recipe-card__rail > .star-toggle {
  margin: calc(-1 * var(--space-2)) -0.5rem;
}

/* The pressed on-state, both call sites: the outlined polygon fills with the
   button's own currentColor, so it tracks hover/active recolouring for free. */
.star-toggle[aria-pressed="true"] svg polygon,
#star-button[aria-pressed="true"] svg polygon {
  fill: currentColor;
}

/* Tag filter (recipe-filter controller): hide individual non-matching
 * cards within a category that still has matches, narrowing the grid.
 * These two rules were referenced by the controller but never existed in
 * CSS — filtering used to only highlight matches. */
.recipe-card.filtered-out {
  display: none;
}

/* A whole category with zero matches de-emphasizes in place rather than
 * disappearing (`display: none` made every section below it jump up the
 * page on each tap, walkthrough-inbox #3883). This
 * does NOT use parent opacity: an ancestor opacity composites the whole
 * subtree, so it drags every child's contrast down together regardless of
 * that child's own color — exactly the WCAG-AA trap --text-soft exists to
 * dodge (see the "(or milk...)" comment above, #2171's fix). Instead each
 * text role swaps to its existing muted token (same tokens/values already
 * proven AA-safe as running text elsewhere in this file), so the region
 * reads clearly secondary while every character stays legible. No images
 * render in this listing (recipe cards are text-only), so there's no photo
 * to separately mute. */
.filtered-empty .category-heading h2,
.filtered-empty .recipe-card__title {
  color: var(--text-soft);
}

.filtered-empty .recipe-card__desc,
.filtered-empty .recipe-card__rail {
  color: var(--text-soft);
}

.filtered-empty .recipe-card {
  --card-shadow: none;
  --card-border: 1px solid var(--rule-faint);
}

.recipe-tag {
  display: inline-block;
  padding: 0.06rem 0.44rem;
  border-radius: 0.625rem;
  font-size: 0.68rem;
  color: var(--text-soft);
}

.recipe-listing section:first-of-type h2 {
  margin-top: 0;
}

.loading-placeholder {
  color: var(--text-soft);
  font-style: italic;
  padding: 2rem;
  text-align: center;
}

:where(.recipe, .embedded-recipe) section > div:has(.ingredients):has(.instructions) {
  display: grid;
  grid-template-columns: 10rem 1fr;
  gap: 0 2.5rem;
}

/* Recipe ingredient/instruction typography — scoped per #1876 (final family). */
:where(.recipe, .embedded-recipe) .instructions p {
  margin: 0 0 0.85rem 0;
  font-size: 0.95rem;
  line-height: 1.75; /* named exception: long-form recipe instructions, generous leading */
}

:where(.recipe, .embedded-recipe) .instructions {
  border-left: 1px solid var(--rule-faint);
  padding-left: 2rem;
}

:where(.recipe, .embedded-recipe) .ingredients ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The ingredient row's zone geometry (.ingredients li, .ing-row and every
   .ing-* zone rule) MOVED OUT of this file to ingredient_row.css (#4292) —
   the print document never loads base.css, so keeping the geometry here meant
   maintaining a second independent copy in print_document.css, and both of
   its first two ports drifted. That sheet is linked immediately after this
   one in layouts/application.html.erb, so the cascade order this page sees is
   unchanged; the print layout links it too. Nothing here overrides it —
   `.ingredients ul li small` below out-specifies its `.ing-body small` rule
   (0,1,3 vs 0,1,1) exactly as it did when the two sat in this file. */

.quantity {
  color: var(--text-soft);
  font-weight: 300;
  white-space: nowrap;
}

/* The "(or milk, ½ cup; or oat milk, ½ cup)" tail (#3603) — author order,
   quieter than the primary so the eye lands on what's actually in the pan.
   --text-soft, NOT opacity: a parent opacity composites the whole subtree
   below WCAG AA regardless of each child's own color (#2171 ship a11y gate,
   documented at .app-version's rule below — the identical fix). .alt-branch
   (the per-branch scalable quantity) inherits this color; it doesn't need its
   own rule. Unscoped rather than :where(.recipe, .embedded-recipe)-scoped
   like its neighbors above: ingredient lines render in more places than
   document roots (the Mirabel workshop draft pane, mirabel.css), and the
   rule was already effectively global in recipe.css — page-scoping came only
   from the sheet it lived in, not from selector scoping. Moved here from
   recipe.css (#3908 PR 2): recipe.css loads only on the recipe show page, but
   the workshop pane renders ingredient lines on every page. */
.ingredient-alt {
  color: var(--text-soft);
  font-weight: normal;
}

/* .ingredients ul li small::before 	{ content: "↳ "; } */

:where(.recipe, .embedded-recipe) .ingredients ul li small {
  display: block;
  font-style: italic;
  font-size: 0.95em;
  line-height: var(--leading-snug);  /* was 1.3 — fold per fonts audit */
  padding-left: 1rem;
}

/* Document footer chrome — scoped per #1876 to the document footer only: a direct
   child of the recipe article, or of the inline history frame / its diff wrapper
   (#2076, which interposes a box). Nested component footers (.nutrition-label's)
   stay exempt by the child combinator. */
:where(.recipe, .embedded-recipe) > footer,
:where(#recipe-history, #recipe-history > .recipe--history) > footer {
  font-family: var(--font-display);
  font-style: italic;
  text-align: center;
  font-size: 1rem;
  margin-top: 3.5rem;
  padding-top: 2rem;
  color: var(--text-soft);
  position: relative;
}

:where(.recipe, .embedded-recipe) > footer::after,
:where(#recipe-history, #recipe-history > .recipe--history) > footer::after {
  content: "";
  display: block;
  width: 40px;
  height: 1px;
  background: var(--rule);
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
}

:where(.recipe, .embedded-recipe) > footer small,
:where(#recipe-history, #recipe-history > .recipe--history) > footer small {
  text-align: right;
  display: block;
}

@media (prefers-color-scheme: dark) {
  .smart-icon--crossout::after {
    border-color: rgba(220, 80, 80, 0.85);
    background: linear-gradient(
      to top left,
      transparent calc(50% - 1px),
      rgba(220, 80, 80, 0.85) calc(50% - 1px),
      rgba(220, 80, 80, 0.85) calc(50% + 1px),
      transparent calc(50% + 1px)
    );
  }
}
/******************************************************/
/* Styles for interactivity, applies to screen media */
/*****************************************************/

@media screen {
  /* switch to hand pointer for interactive elements */
  .recipe section h2,
  .recipe .instructions p,
  .recipe .ingredients li {
    cursor: pointer;
  }

  /* Strike a recipe step's h2 once all its crossable items are crossed off.
     The :has(...crossed-off) guard is load-bearing: without it, :not(:has(...))
     is vacuously true for a section with no crossable items, striking an empty
     section's heading (e.g. Photos) by accident (#2080). .recipe-scoped so
     other pages' <section> + <h2> are unaffected. */
  .crossed-off,
  .recipe section:has(.ingredients li.crossed-off, .instructions p.crossed-off):not(:has(.ingredients li:not(.crossed-off), .instructions p:not(.crossed-off))) h2 {
    text-decoration: line-through;
    color: var(--text-light);
    border-color: var(--text-light);
  }

  /* Fade the embedded card when the parent step is crossed off — same vacuous-truth
     guard as above so an item-less section never dims its embedded card. */
  :where(.recipe) section:has(.ingredients li.crossed-off, .instructions p.crossed-off):not(:has(.ingredients li:not(.crossed-off), .instructions p:not(.crossed-off))) .embedded-recipe {
    opacity: 0.4;
  }
}

/* Authored focus ring for the JS-focusable recipe cross-off controls (#1552):
   cross-off items (focus-3) and the section-heading togglers keyboard-enabled
   in #1551. Outside the hover query above — keyboard focus needs an indicator
   regardless of pointer type. Matches the app's 2px red :focus-visible ring. */
.recipe .ingredients li:focus-visible,
.recipe .instructions p:focus-visible,
.recipe section :is(h2, h3):focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/****************************************/
/* Styles for mobiles and small screens */
/****************************************/

/* small mobiles only */
@media screen and (max-width: 720px) and (pointer: coarse) {
  html {
    font-size: 16px;
  }
}

/* anything with a narrow window */
@media screen and (max-width: 720px) {
  :root {
    --gingham-gap: 0.75rem;
  }

  main {
    padding: 1.5rem 1.25rem 3rem;
  }

  :where(.recipe, .embedded-recipe) section > div:has(.ingredients):has(.instructions) {
    grid-template-columns: 1fr;
    gap: 0.5rem;
  }

  /* Ingredients are deliberately ONE column here. Measured 2026-08-11 across
     the 8 seeded recipes at 390px: two columns broke 8 of 45 ingredient rows
     mid-phrase ("Flour (all-purpose), 155 g" in a ~150px column), in 6 of the
     8 recipes. One column removes all 8, at +67% list height. Type size is NOT
     the lever here — 1rem removes no additional wraps. */

  /* Switch to two index columns as well */
  :where(.recipe, .embedded-recipe, .recipe-listing) section > ul {
    column-count: 2;
  }

  :where(.recipe, .recipe-listing) > header h1,
  .page-header h1 { font-size: 2.6rem; }


  :where(.recipe, .embedded-recipe) .instructions {
    border-left: none;
    padding-left: 0;
    padding-top: 0.5rem;
    border-top: 1px solid var(--rule-faint);
  }

}

/* Touch viewport: top nav fixed, destinations bar pinned to bottom */
@media (pointer: coarse) and (hover: none) {
  main {
    padding-top: 2rem;
  }

  #recipe-listings,
  #recipe-index-top,
  [data-recipe-filter-target="category"] {
    scroll-margin-top: 0.5rem;
  }

  body {
    padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
  }
}

html:has(dialog[open]) {
  overflow: hidden;
}
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes bloop {
  0%   { transform: scale(0.95);  opacity: 0; }
  40%  { transform: scale(1.02);  opacity: 1; }
  70%  { transform: scale(0.992); opacity: 1; }
  100% { transform: scale(1);     opacity: 1; }
}

/* Indeterminate busy cue: three currentColor dots via a masked pseudo-element,
 * no extra DOM node. Continuous loop — outside the four one-shot bloop
 * categories, catalogued custom_justified under bloops → loading in
 * style_inventory.yml (#4270). Technique popularized by Basecamp's Campfire
 * (basecamp/once-campfire, MIT-licensed) — this is an independent
 * re-derivation of the same mask/keyframe idea, not copied source.
 *
 * Three consumers share this keyframe by name (not all via the .busy-dots
 * class): Mirabel's appendThinking dots use the class directly; the two
 * photo-upload overlays (recipe.css #recipe-photo.is-uploading::after,
 * document_editor.css .doc-photo.is-uploading::after) need position:absolute
 * overlay geometry instead of inline flow, so they redeclare the mask on
 * their own ::after rather than composing this class — see those sheets.
 *
 * Reduced motion: the global `*, *::before, *::after` gate below collapses
 * the loop to one frame at 100% == 0% — a static 3-dot glyph. That's the
 * deliberate fallback everywhere this cue appears; SR users get the adjacent
 * text label instead (dots are always aria-hidden). */
.busy-dots::before {
  content: "";
  display: inline-block;
  width: 2.25em;
  height: 0.75em;
  background: currentColor;
  /* mask-size is load-bearing, not decorative: an unsized radial-gradient
     mask image defaults to filling the whole box (no intrinsic size), which
     makes mask-position's 0%/50%/100% a no-op (container minus image = 0)
     and collapses all three "dots" into one centered circle. Sizing each
     layer to just its own circle (0.22em radius → 0.44em box) gives
     mask-position real room to spread them left/center/right. */
  -webkit-mask-image:
    radial-gradient(circle 0.22em, #000 99%, transparent),
    radial-gradient(circle 0.22em, #000 99%, transparent),
    radial-gradient(circle 0.22em, #000 99%, transparent);
  -webkit-mask-size: 0.44em 0.44em;
  -webkit-mask-position: 0% 50%, 50% 50%, 100% 50%;
  -webkit-mask-repeat: no-repeat;
  mask-image:
    radial-gradient(circle 0.22em, #000 99%, transparent),
    radial-gradient(circle 0.22em, #000 99%, transparent),
    radial-gradient(circle 0.22em, #000 99%, transparent);
  mask-size: 0.44em 0.44em;
  mask-position: 0% 50%, 50% 50%, 100% 50%;
  mask-repeat: no-repeat;
  animation: busy-dots var(--duration-busy) infinite linear;
}

/* Wave: each dot lifts in turn (mask-position Y offset), one full cycle per loop. */
@keyframes busy-dots {
  0%, 100% { -webkit-mask-position: 0% 50%, 50% 50%, 100% 50%; mask-position: 0% 50%, 50% 50%, 100% 50%; }
  12%      { -webkit-mask-position: 0% 20%, 50% 50%, 100% 50%; mask-position: 0% 20%, 50% 50%, 100% 50%; }
  25%      { -webkit-mask-position: 0% 50%, 50% 50%, 100% 50%; mask-position: 0% 50%, 50% 50%, 100% 50%; }
  37%      { -webkit-mask-position: 0% 50%, 50% 20%, 100% 50%; mask-position: 0% 50%, 50% 20%, 100% 50%; }
  50%      { -webkit-mask-position: 0% 50%, 50% 50%, 100% 50%; mask-position: 0% 50%, 50% 50%, 100% 50%; }
  62%      { -webkit-mask-position: 0% 50%, 50% 50%, 100% 20%; mask-position: 0% 50%, 50% 50%, 100% 20%; }
  75%      { -webkit-mask-position: 0% 50%, 50% 50%, 100% 50%; mask-position: 0% 50%, 50% 50%, 100% 50%; }
}

/* Shimmer sweep for skeleton placeholders (pantry.css .pantry-skeleton__bar).
 * Kept beside busy-dots so style_inventory.yml's bloops section points to one
 * file for both continuous-loop keyframes; the .pantry-skeleton* rules that
 * consume it stay in pantry.css (page-scoped). Reduced motion: same global
 * gate above freezes it on the `to` frame — a static two-tone bar. */
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

.tag-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.15rem 0.55rem;
  border-radius: var(--radius-pill);
  font-size: 0.75rem;
  font-weight: 500;
  white-space: nowrap;
  cursor: default;
}
.tag-pill--tag { background: var(--tag-bg); color: var(--tag-text); }

/* A category is a different KIND of thing from a tag, not a differently-coloured
 * one. The search overlay's suggestion rows are the first place the two sit
 * adjacent, and two lozenges in two greens read as one control in two states —
 * so the category wears the category voice instead: a roundrect rather than a
 * full pill, in the display serif its section headings use (.category-heading
 * h2, same weight). The sage green it used to carry went with it; colour on a
 * neutral ground reads as status, and a category is not a status.
 *
 * Sole producer is buildPillSpan in search_overlay_controller.js, which emits
 * `tag-pill--${type}` — every other tag-pill call site in the app is
 * --tag. That is why this can be restyled here rather than scoped to the
 * overlay, and it is what to re-check before assuming so again. */
.tag-pill--category {
  background: var(--tag-bg);
  color: var(--tag-text);
  border-radius: var(--radius-md);
  font-family: var(--font-display);
  font-weight: 600;
  /* Optical, not nominal: at the shared 0.75rem the serif sets "sample" 24%
   * narrower than Outfit does (43.4px → 32.8px, measured), so the category
   * read as the SMALLER of the two pills — the opposite of the intent. The
   * bump matches them by eye rather than by declared size. */
  font-size: 0.88rem;
}

/* Smart tag color variants. The --c-bg/--c-text custom props let filter chips
   derive resting/selected states from a tag's category color (#2179); display
   pills keep using background/color directly. */
.tag-pill--green   { background: var(--smart-green-bg);   color: var(--smart-green-text);   --c-bg: var(--smart-green-bg);   --c-text: var(--smart-green-text); }
.tag-pill--amber   { background: var(--smart-amber-bg);   color: var(--smart-amber-text);   --c-bg: var(--smart-amber-bg);   --c-text: var(--smart-amber-text); }
.tag-pill--blue    { background: var(--smart-blue-bg);     color: var(--smart-blue-text);    --c-bg: var(--smart-blue-bg);     --c-text: var(--smart-blue-text); }
.tag-pill--rose    { background: var(--smart-rose-bg);     color: var(--smart-rose-text);    --c-bg: var(--smart-rose-bg);     --c-text: var(--smart-rose-text); }
.tag-pill--purple  { background: var(--smart-purple-bg);   color: var(--smart-purple-text);  --c-bg: var(--smart-purple-bg);   --c-text: var(--smart-purple-text); }
.tag-pill--cuisine { background: var(--smart-cuisine-bg);  color: var(--smart-cuisine-text); --c-bg: var(--smart-cuisine-bg);  --c-text: var(--smart-cuisine-text); }

/* Smart tag icon wrapper */
.smart-icon {
  position: relative;
  margin-right: 0.3em;
  font-size: 0.85em;
  display: inline-flex;
  align-items: center;
}

/* Crossout circle+slash overlay on the icon */
.smart-icon--crossout::after {
  content: "";
  position: absolute;
  inset: -1px;
  border: 1.5px solid rgba(180, 40, 40, 0.85);
  border-radius: 50%;
  background: linear-gradient(
    to top left,
    transparent calc(50% - 1px),
    rgba(180, 40, 40, 0.85) calc(50% - 1px),
    rgba(180, 40, 40, 0.85) calc(50% + 1px),
    transparent calc(50% + 1px)
  );
  pointer-events: none;
}

/* Recipe-index tag FILTER chips (#2179, semantics reworked by #2695). Pills
   are actions, not status lamps: resting chips recede (category tint, faded
   label, sepia-muted emoji); selecting lights the chip to full category color
   with a dark ring + bold and narrows the list to recipes carrying every
   selected tag. While filtering, unselected chips carry a live count and go
   inert at zero (dead-end prevention); the summary line below the row owns
   the global state. Category color comes from the --c-bg/--c-text props the
   .tag-pill--<color> classes publish; plain (non-registry) tags fall back to
   neutral resting and the red accent when active. The on/off signal is shape +
   luminance (ring + weight), never hue — paired with aria-pressed for
   CVD/screen-reader users. */
.tag-filter-pill {
  display: inline-block;
  padding: 0.15rem 0.6rem;
  border-radius: var(--radius-xl);
  font-size: 0.78rem;
  font-family: var(--font-body);
  background: color-mix(in srgb, var(--c-bg, var(--tag-bg)) 42%, var(--tag-bg));
  color: color-mix(in srgb, var(--tag-text) 85%, transparent);
  border: 2px solid transparent;
  cursor: pointer;
  transition: background var(--duration-fast), border-color var(--duration-fast);
}

.tag-filter-pill:not(.active) .smart-icon {
  filter: sepia(0.6) saturate(0.85) brightness(0.95);
  opacity: 0.55;
}

/* Gated on any-hover, and excluding .active, for one bug with two causes
   (#3855 walkthrough). Bare :hover sticky-hovers on touch: a tapped pill kept
   the hover state until you tapped elsewhere. And this selector scores (0,3,0)
   against .tag-filter-pill.active's (0,2,0), so its muted border-color WON over
   the selection ring — the tapped pill showed the unselected ring until the
   sticky hover cleared, which read as two borders trading places. */
@media (any-hover: hover) {
  .tag-filter-pill:not([aria-disabled="true"]):not(.active):hover {
    background: color-mix(in srgb, var(--c-bg, var(--tag-bg)) 65%, var(--tag-bg));
    border-color: color-mix(in srgb, var(--c-text, var(--tag-text)) 35%, transparent);
  }

  /* Selected + hover deepens the SURFACE only — the border is the selection
     indicator and hover must not repaint it. Mixing toward the accent rather
     than toward black/white keeps the direction right in both themes. */
  .tag-filter-pill.active:not([aria-disabled="true"]):hover {
    background: color-mix(in srgb, var(--c-bg, var(--tag-filter-active-bg)) 88%,
                          var(--c-text, var(--red)));
  }
}

/* Press rung — touch users never see hover, so without this a tap had no
   acknowledgement at all (docs/wiki/style.md § The state ladder). */
.tag-filter-pill:not([aria-disabled="true"]):active {
  box-shadow: var(--shadow-press);
}

.tag-filter-pill.active {
  background: var(--c-bg, var(--tag-filter-active-bg));
  color: var(--c-text, var(--red));
  border-color: var(--c-text, var(--red));
  font-weight: 600;
}

.tag-filter-pill.active .smart-icon {
  filter: none;
  opacity: 1;
}

.tag-filter-pill[aria-disabled="true"] {
  opacity: 0.35;
  cursor: default;
}

.tag-filter-pill__count {
  margin-left: 0.2rem;
  font-size: 0.65rem;
  opacity: 0.65;
}

.tag-filter-summary {
  margin: 0.4rem 0 0;
  font-size: 0.75rem;
  color: var(--text-soft);
}

.tag-filter-summary__clear {
  margin-left: 0.4rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--red);
  font-size: inherit;
  font-family: var(--font-body);
  text-decoration: underline;
  cursor: pointer;
}

/* The × remove control on an interactive, removable tag pill. Shared display
   vocabulary: the recipe editor's tag-input component and the search overlay's
   active-filter chips both render it (grep tag-pill__remove). */
.tag-pill__remove {
  position: relative;
  font-size: 0.6rem;
  opacity: 0.5;
  cursor: pointer;
  line-height: var(--leading-flat);
  border: none;
  background: none;
  color: inherit;
  padding: 0;
}
/* WCAG 2.5.8: the visible × stays ~10px to keep the pill dense; a transparent
   overlay extends only the clickable area to the 24×24 minimum without
   enlarging the glyph or disturbing the inline-flex pill layout. */
.tag-pill__remove::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 24px;
  height: 24px;
  transform: translate(-50%, -50%);
}
@media (any-hover: hover) {
  .tag-pill__remove:hover { opacity: 1; }
}
/* WCAG 2.4.7: a keyboard user tabbing to the × needs a visible focus ring. */
.tag-pill__remove:focus-visible {
  opacity: 1;
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}
/************************/
/* Notification toasts  */
/************************/

/* Floating card stack (#4404). --toast-inset lets pages with bottom chrome
   (groceries offline banner, the navshell dock) lift the stack clear —
   override the custom property, never the stack's own bottom rule. */
.notify-stack {
  position: fixed;
  bottom: calc(env(safe-area-inset-bottom, 0px) + var(--toast-inset, 1rem));
  left: 50%;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: min(24rem, calc(100vw - 2rem));
  pointer-events: none;
}

.notify-card {
  pointer-events: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  background: var(--ground);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid var(--rule);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-popover);
  font-family: var(--font-body);
  font-size: 0.9rem;
  color: var(--text);
  opacity: 0;
}

.notify-card--error {
  border-color: var(--red);
  background: var(--red-light);
}

@keyframes slide-in {
  0%   { transform: translateY(100%); opacity: 0; }
  60%  { transform: translateY(-8%);  opacity: 1; }
  100% { transform: translateY(0);    opacity: 1; }
}

@keyframes notify-exit {
  from { transform: translateY(0);   opacity: 1; }
  to   { transform: translateY(30%); opacity: 0; }
}

.notify-card.notify-visible {
  opacity: 1;
  animation: slide-in var(--duration-slow) var(--ease-bounce);
}

.notify-card.notify-leaving {
  animation: notify-exit var(--duration-normal) ease forwards;
}

.notify-dismiss {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: none;
  font-size: 1.25rem;
  /* Contrast is a property of the pair: --rule on --ground was ~1.2:1 —
     invisible (#4404 design-round finding). --text-soft clears AA on both
     card grounds in both themes. */
  color: var(--text-soft);
  cursor: pointer;
  padding: 0.25rem 0.4rem;
  line-height: var(--leading-flat);
  flex-shrink: 0;
}

@media (any-hover: hover) {
  .notify-dismiss:hover {
    color: var(--text);
  }
}

/* The error card's × matches its border accent — --red carries ~6:1 over the
   --red-light tint (both themes), where --rule was unreadable. */
.notify-card--error .notify-dismiss {
  color: var(--red);
}

@media (any-hover: hover) {
  .notify-card--error .notify-dismiss:hover {
    color: var(--text);
  }
}

.app-version {
  text-align: center;
  /* Bottom margin mirrors the visible gap above (main's --gingham-gap bottom
     margin minus the 0.75rem this footer is pulled up into it). */
  margin: -0.75rem auto calc(var(--gingham-gap) - 0.75rem);
  padding: 0 0 0.25rem;
  max-width: 35rem;
  font-size: 0.7rem;
  font-style: normal;
  /* #1876 transfer: the display family came from base's bare `footer` rule (now document-scoped). */
  font-family: var(--font-display);
  /* Faint via --text-soft, NOT opacity: a parent opacity composites the whole
     footer subtree (incl. the made-by credit links) below WCAG AA regardless of
     each child's own color (#2171 ship a11y gate). */
  color: var(--text-soft);
}

/* The footer sits outside <main>, so `main a` never colors these links and the
   UA `a:link` rule would render them bright blue. Inherit the footer's
   --text-soft instead (the opacity:0.3 that used to mute the UA blue is gone). */
.app-version a {
  color: inherit;
}

@media (any-hover: hover) {
  .app-version a:hover {
    color: var(--red);
  }
}

.app-version a:active {
  color: var(--red);
}

.app-version a:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
  border-radius: var(--radius-xs);
}

/* Author + build-tool credit. Self-contained: sets its own font/size/color
   rather than inheriting from a parent footer. Rendered only via
   `content_for :footer_credit`, which only the app layout's footer yields. */
.made-by {
  margin: 0.35rem 0 0;
  text-align: center;
  font-family: var(--font-display);
  font-size: 0.7rem;
  color: var(--text-soft);
}

.made-by a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (any-hover: hover) {
  .made-by a:hover {
    color: var(--text);
  }
}

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

  .recipe-card:has(.recipe-card__title:active) {
    transform: none; /* keep the --shadow-press feedback, drop the positional shift */
  }
}
