/*
 * Mitten Made Spice — design system.
 *
 * M001 captured this look from the live site's own rendered CSS (Astra's
 * --ast-global-color-* properties, its container widths, the Inter/Outfit
 * pairing). M002-A tightened it without replacing it: every token was a value
 * already in the stylesheet, collapsed onto the most common member of its
 * group, plus four WCAG AA repairs.
 *
 * M003-A REPLACED THE COLOUR, and only the colour. Type, spacing, radii and
 * layout are untouched — the palette section below documents what moved and
 * why. Everything outside that section is still M001's captured look with
 * M002's corrections.
 *
 * Naming is by ROLE, never by appearance. `--color-text-muted` survives a
 * palette change; `--color-grey-3` becomes a lie the moment anything shifts —
 * which M003-A has just demonstrated, since every one of these tokens changed
 * value and not one of them needed renaming.
 */

/*
 * FONTS, SELF-HOSTED (M002-B, D10).
 *
 * These four declarations are a verbatim copy of what fonts.googleapis.com was
 * serving for `Inter:wght@400;500` and `Outfit:wght@500` — same families, same
 * weights, same unicode-range, same font-display — with the URLs pointing at
 * our own origin. The .woff2 files in src/assets/fonts/ are BYTE-IDENTICAL to
 * the ones Google's CDN was serving (sha256 recorded in the M002-B report), so
 * the outlines and every metric in them are the same font, not a substitute.
 * That is what makes this invisible: nothing about the rendering changes, only
 * where the bytes come from.
 *
 * WHY IT IS WORTH DOING
 * A visitor used to need four sequential network trips before a single glyph
 * could paint: our HTML, then DNS+TLS to fonts.googleapis.com, then the CSS it
 * returns, then DNS+TLS to fonts.gstatic.com for the font itself. The font
 * request could not even START until the third of those finished, because its
 * URL is inside the stylesheet. Now both files are preloaded from the same
 * connection that delivered the HTML, in parallel with the stylesheet. "The
 * site is slow" is one of the owner's three stated reasons for this project.
 *
 * Both faces are VARIABLE fonts, which is why Inter 400 and Inter 500 name the
 * same file: one variable file carries the whole weight axis and the browser
 * instantiates the weight it is asked for. This is exactly how Google served
 * it. Two declarations, one download.
 *
 * The latin subset alone is shipped, again matching what Chrome was actually
 * downloading. Every character in the built site was checked against that
 * range — including the Á in "Smoked Árbol" — and all of them are inside it,
 * so no glyph falls back to a system face. If content ever arrives outside it,
 * that check is the thing to re-run.
 *
 * font-display: swap is kept from Google's own declarations. With the files
 * preloaded from our origin they are in almost every case ready before first
 * paint; swap is the safe behaviour if they are not, because the alternative
 * (block) hides the text entirely while a local font is sitting right there.
 */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/inter-v20-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/inter-v20-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/*
 * INTER 600, FROM THE FILE THAT IS ALREADY IN THE ARTIFACT (M017 Task 2).
 *
 * `inter-v20-latin.woff2` is a VARIABLE font with a real weight axis, which is
 * why the 400 and 500 declarations above can name the same file and still
 * render different cuts. But a `font-weight: <single value>` descriptor CLAMPS
 * what the browser will instance, so before this rule existed `font-weight:
 * 600` on any element resolved to the 500 face and Chrome faked the rest.
 *
 * MEASURED, NOT ASSUMED. The same string set in the loaded family measured
 * 1050.97px at 500, 600 AND 700 — one outline serving all three. Registering
 * the same file with an explicit `100 900` range gives 976.81 / 1037.61 /
 * 1050.97 / 1064.34 / 1077.67 / 1111.83 at 100 / 400 / 500 / 600 / 700 / 900:
 * every step distinct, so these are real interpolated cuts and not synthesis.
 *
 * IT COSTS NO DOWNLOAD. Same URL, already preloaded, already counted by R31.
 * The weight is declared as a single 600 rather than a range so the set stays
 * the three explicit cuts this stylesheet actually uses.
 */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/inter-v20-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/outfit-v15-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* ---------------------------------------------------------------- colour */
  /*
   * SMOKE AND FIRE (M003-A). The product taxonomy is the palette: Slow Smoked
   * is charcoal and ash, Fire Roasted is the ember, Signature is where they
   * meet. Every pair that occurs in the built site is pinned by R28.
   *
   * Nothing here was picked. Two hues were CHOSEN — 55 for the neutral axis,
   * 48 for the ember — and every value below is the lightness at which that
   * hue clears its WCAG threshold, computed in OKLCH by tools/derive-palette.mjs
   * and reproducible by running it. That is M002-A's method applied to a whole
   * palette rather than to four repairs.
   *
   * WHAT CHANGED AND WHY. The old palette was Astra's default and neither half
   * of it was ever chosen: a bright azure accent (#0164fa) — the most generic
   * signal on the web, and one that reads SaaS rather than artisan food — over
   * blue-tinted slate neutrals (hue ~260). Cool neutrals actively harm this
   * site's content: they drain the warmth out of food photography and make it
   * look flat, which is why food magazines live in cream. Every neutral has
   * moved from blue to warm, and the ground is bone rather than #ffffff.
   *
   * THE RESTRAINT IS THE DESIGN. Charcoal + cream + orange is also the
   * roadside-BBQ palette, and the difference is not which orange, it is how
   * much. The ember appears on no more than two or three elements per page —
   * buttons, active states, the occasional rule — and never on a section
   * background, a heading, a large fill, or a gradient. Space and quiet type do
   * the luxury work. If a page seems to want a fourth ember, it wants more
   * whitespace instead.
   */
  --color-surface: #fcf9f7;             /* bone. Not #ffffff — see above.     */
  --color-surface-sunken: #f6ece5;      /* warm ash: footer, panels, empty
                                           media, the deals band              */
  --color-surface-inverse: #1d1713;     /* warm charcoal: the store notice bar */

  --color-text: #4c4541;                /* body copy.        8.96:1 / 8.08:1  */
  --color-text-strong: #312a26;         /* headings, prices. 13.45:1 / 12.11:1 */
  --color-text-muted: #726b66;          /* secondary lines.  5.00:1 / 4.50:1  */
  --color-text-inverse: #fcf9f7;        /* bone, on ember and on charcoal     */
  /*
   * The secondary ink for dark grounds, added by M003-A for the deals band's
   * "4 × 4 oz jar" line. It exists because --color-text-muted is a 5:1 ink for
   * LIGHT grounds and measures 2.7:1 on charcoal — using it there would have
   * been an accessibility failure introduced by reflex. Derived the same way as
   * everything else: hue 55, lightness moved until it cleared 4.5:1 on the
   * charcoal, and no further, so it stays quieter than the bone beside it.
   *
   * REMOVED IN M005 TASK 6, REINSTATED IN M006 TASK 1, and the round trip is
   * the record rather than an embarrassment. M005 retired the "n × n oz" pack
   * breakdown, which was the only text this ink had ever painted, so the token
   * went with the rule that used it — correctly, because a colour nothing
   * paints is a colour nobody re-measures. M006 put a secondary line back on
   * the same band (the owner's "(4 Jars)" qualifier), so the ink is back, at
   * the same value, DERIVED AGAIN rather than pasted from the old report:
   * `node tools/derive-palette.mjs` now emits this row and reproduces #867f7a
   * at L=0.600 C=0.011 h=55.
   *
   * depends: --color-surface-inverse
   *
   * IT CLEARS BY 0.0013. Measured 4.5013:1 on charcoal — in Node from the
   * hex, and independently in a real browser by resolving the rendered
   * element's effective background. Both agree to four places. That is not a
   * near miss, it is this palette's method: every ink here is the last value
   * that clears its threshold. The consequence worth knowing before anybody
   * touches --color-surface-inverse: darkening the band by a single step puts
   * this pair under 4.5 and R28 fails the build. That is the rule working, and
   * it is why the pair is pinned in AA_PAIRS rather than trusted.
   */
  --color-text-inverse-muted: #867f7a;  /* secondary ink on charcoal. 4.5013:1 */

  /*
   * TWO EMBERS, ONE HUE. Both are hue 48 at chroma 0.13, differing only in
   * lightness, so they are the same colour twice rather than two oranges. A
   * glowing ember lands near 2.5:1 on a light ground; the one that carries LINK
   * TEXT has to be deep enough for 4.5:1, which is why it is burnt rather than
   * bright. Hover DARKENS — M002-A found Astra's hover lightened, which made
   * links less readable exactly when the reader was aiming at them.
   */
  --color-accent: #aa541f;              /* links, primary fill. 5.01 / 4.52   */
  --color-accent-hover: #944000;        /* darker, never lighter. 6.69 / 6.03 */

  --color-border: #e3dad5;              /* decorative: cards, rules, dividers */
  --color-border-strong: #918782;       /* form controls, which must be
                                           identifiable.     3.34:1 / 3.01:1  */

  /*
   * The focus ring is NO LONGER an alias of the accent, and that is a real
   * change rather than tidying.
   *
   * It has to be findable on all three grounds, and the store notice bar is
   * near-black. The accent is a deep ember chosen for 4.5:1 on a LIGHT ground;
   * against charcoal it measures 1.9:1, so a keyboard user tabbing to the
   * dismiss control on that bar had a focus ring they could not see. This value
   * is the lightness whose WORST ratio across bone, ash and charcoal is best:
   * 4.33:1, 3.90:1 and 3.91:1 — margin on all three rather than a pass on two.
   */
  --color-focus: #b65e2b;

  /* --color-inverse-line and --color-inverse-wash were here, and are DELETED
     (M007 Task 3). They were the store-notice dismiss pill's outline and hover
     wash, and nothing else on the site ever painted them; the bar is gone, so
     the ink is gone. See the store-notice block below for the full record and
     for why R28 makes this all-or-nothing. */

  /*
   * THE HERO SCRIM (M004-A) — the charcoal wash between the hero photograph
   * and the headline, and the ONE place this site has anything laid over
   * anything else.
   *
   * It is derived from the charcoal rather than written as its own rgba(), so
   * there is no second copy of #1d1713 to fall out of step with the first: the
   * scrim is the site's dark surface at 62% and it follows the palette if the
   * palette ever moves again.
   *
   * 62% is not a look, it is a measurement. See src/_data/hero.json: 55% is
   * where the brightest part of this particular photograph reaches 4.5:1 under
   * bone type, and 62% is that with margin. R39 re-reads THIS declaration,
   * composites it over the actual generated derivative and fails the build if
   * any sampled cell would drop the headline below AA — so the number here and
   * the number in hero.json cannot drift apart, and neither can drift away
   * from the pixels.
   */
  --color-hero-scrim: color-mix(in srgb, var(--color-surface-inverse) 62%, transparent);

  /* ------------------------------------------------------------------ type */
  --font-heading: 'Outfit', system-ui, sans-serif;
  --font-body: 'Inter', system-ui, sans-serif;

  /*
   * Twelve rungs, down from twenty-two declared values. Every rung was already
   * in the stylesheet; every value that went was within 0.8px of the rung that
   * absorbed it, which is below the threshold at which anyone can see a
   * difference. The collapses are recorded in the M002-A report.
   */
  /* --text-micro (8px) is GONE. It existed for one thing: the "Awaiting
     product image" label crushed into the 48px configurator thumbnail. M002-B
     replaced that with product initials at --text-sm, so the smallest rung on
     the scale is no longer half the size of the next one up. */
  --text-3xs: 0.72rem;     /* 11.5px  cart-count badge                        */
  --text-2xs: 0.8rem;      /* 12.8px  absorbed 0.78, 0.82                     */
  --text-xs: 0.85rem;      /* 13.6px                                          */
  --text-sm: 0.9rem;       /* 14.4px  absorbed 0.88, 0.92, 0.93               */
  --text-md: 0.95rem;      /* 15.2px                                          */
  --text-base: 1rem;       /* 16.0px  absorbed 0.98                           */
  --text-lg: 1.05rem;      /* 16.8px                                          */
  --text-xl: 1.15rem;      /* 18.4px                                          */
  --text-2xl: 1.25rem;     /* 20.0px  absorbed 1.2                            */
  --text-3xl: 1.4rem;      /* 22.4px                                          */
  --text-4xl: 1.5rem;      /* 24.0px                                          */

  /*
   * M002-C added ONE rung, and it is at the top rather than between two
   * existing ones — which is the distinction that matters for a scale. The
   * homepage h1 was rendering at --text-h1, the same size as the h1 on every
   * other page, so the one heading whose job is to introduce the whole business
   * was the same weight as "Get in Touch". The owner asked for an intro that
   * "kinda blows you away"; the brief translated that as a large confident
   * heading over quiet body text, and there was nothing above 48px to be
   * confident with. Used on the homepage h1 and nowhere else.
   */
  --text-hero: clamp(2.4rem, 1.5rem + 3.4vw, 4rem);

  /*
   * M004-A adds ONE rung, and like --text-hero it is at the top of the scale
   * rather than between two existing rungs.
   *
   * The brand block is the site's position statement and it was set at
   * --text-2xl (20px) — body copy with a heading face on it. The owner asked
   * for it to be the second-largest type on the page, and there was nothing
   * between --text-h2 (which tops out at 33.6px and is what every section
   * heading uses) and --text-hero (64px, the homepage h1). This sits between
   * them by construction: its ceiling is above every h2 on the page and its
   * floor is below the h1 at every viewport width, so the hierarchy holds
   * fluidly rather than only at the width somebody checked.
   *
   * Used by the brand block's CLAIM half only. The evidence half stays on the
   * existing scale (--text-xl), which is what makes the two halves read as one
   * weighted statement instead of as a heading with a subline — the pattern
   * that flattened this block when it was tried.
   *
   * ⚠ THAT LAST PARAGRAPH WAS STALE FOR SIX MILESTONES AND IS TRUE AGAIN AS OF
   * M023. M017 Task 4 moved the evidence one step UP, to --text-2xl, to fix the
   * owner's "bland" ruling, and did not correct this note — so the comment said
   * --text-xl while the code said --text-2xl. M023 Task 2 reduces the type on
   * the owner's ruling and the evidence lands back on --text-xl, which is the
   * size this note has claimed all along. Recorded rather than quietly fixed: a
   * comment is not a guarantee, and this one was wrong the whole time.
   *
   * M023 TASK 2 — OWNER RULING, 2026-09-03: REDUCE THE TEXT SIZE. The claim
   * was clamp(1.6rem, 1.1rem + 2vw, 2.6rem), measuring 25.6px at 390 and 41.6px
   * at 1440. It is now 21.6px and 32.8px. THE CEILING/FLOOR PROPERTY THIS TOKEN
   * WAS BUILT ON STILL HOLDS AND WAS RE-CHECKED, NOT ASSUMED: --text-h2 tops out
   * at 2.1rem and --text-h1's floor is 1.9rem, so at 2.05rem this ceiling still
   * sits at or below no h1 it must not outrank, and its floor of 1.35rem is
   * still below every h2. The claim is also no longer the widest thing in its
   * column: rows 2 and 3 are half-width now, which is the other half of why the
   * size came down.
   */
  --text-brand-claim: clamp(1.35rem, 0.95rem + 1.6vw, 2.05rem);

  /*
   * M023 TASK R3 — ONE NEW RUNG, BETWEEN --text-h2 AND --text-h1. OWNER RULING,
   * 2026-09-04: make "Our Core Values" and "FAQs" bigger.
   *
   * WHY A TOKEN AND NOT --text-h1. Reaching for --text-h1 would have put two
   * section headings at exactly the size of the page's own <h1>, which flattens
   * the outline: three things on the page would shout equally and none would be
   * the title. So this sits BETWEEN the two, which is the same move M004-A made
   * for --text-brand-claim and for the same reason.
   *
   * THE CEILING/FLOOR PROPERTY THIS SCALE INSISTS ON, CHECKED RATHER THAN
   * ASSUMED, at both ends and against the h1 these sit under:
   *     floor   1.75rem (28.0px)  vs --text-h1's floor 1.9rem (30.4px)   below
   *     ceiling 2.6rem  (41.6px)  vs --text-h1's ceiling 3rem (48px)     below
   *     floor   1.75rem (28.0px)  vs --text-h2's floor 1.5rem (24px)     above
   *     ceiling 2.6rem  (41.6px)  vs --text-h2's ceiling 2.1rem (33.6px) above
   * So it outranks every other section heading at every width and outranks no
   * h1 at any width. Both bounds hold across the whole clamp, not just at the
   * two widths this milestone measured.
   */
  --text-h2-lead: clamp(1.75rem, 1.2rem + 2.2vw, 2.6rem);

  /* Fluid heading sizes, unchanged from M001. */
  --text-h1: clamp(1.9rem, 1.3rem + 2.4vw, 3rem);
  --text-h2: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem);
  --text-h3: clamp(1.15rem, 1rem + 0.7vw, 1.4rem);

  --leading-body: 1.65;
  --leading-heading: 1.2;
  --leading-tight: 1.5;

  --weight-normal: 400;
  --weight-medium: 500;
  /* M017 Task 2. A REAL cut of the variable Inter already in the artifact, not
     a synthesised bold — see the @font-face above for the measurement. Used by
     the featured blurb alone today. */
  --weight-semibold: 600;

  --tracking-tight: -0.01em;
  --tracking-wide: 0.01em;
  --tracking-caps: 0.04em;

  /* --------------------------------------------------------------- spacing */
  /*
   * A complete 4px ladder, and it was already here — every rung below was
   * in use before M002-A. What was NOT here was consistency: seventeen further
   * values (0.1, 0.35, 0.55, 0.7, 0.85, 0.95, 1.1, 1.4 rem and so on) sat
   * between these rungs. Each has been moved to its nearest rung; the largest
   * single move is 2.4px.
   */
  --space-3xs: 0.25rem;    /*  4px  absorbed 0.1, 0.15, 0.2                   */
  --space-2xs: 0.5rem;     /*  8px  absorbed 0.4, 0.55, 0.6                   */
  --space-xs: 0.75rem;     /* 12px  absorbed 0.7, 0.8, 0.85                   */
  --space-sm: 1rem;        /* 16px  absorbed 0.9, 0.95, 1.1                   */
  --space-md: 1.25rem;     /* 20px                                            */
  --space-lg: 1.5rem;      /* 24px  absorbed 1.4                              */
  --space-xl: 1.75rem;     /* 28px                                            */
  --space-2xl: 2rem;       /* 32px                                            */
  --space-3xl: 2.5rem;     /* 40px                                            */
  --space-4xl: 3rem;       /* 48px                                            */
  --space-5xl: 3.5rem;     /* 56px                                            */
  /*
   * Two further rungs, M002-C, both on the same 4px grid as the rest.
   *
   * These are SECTION rungs, not component rungs. The ladder above tops out at
   * 56px because until now the largest gap on the site was between a heading
   * and the thing under it. The homepage now has bands that have to read as
   * separate places, and separation at that scale is what whitespace is for —
   * it is the strongest signal of expense available and the cheapest. The old
   * homepage was 7,866px tall and still felt crowded, which is the exact
   * symptom of length without generosity: lots of content, no room around any
   * of it.
   */
  --space-6xl: 4rem;       /* 64px  band padding, mobile section rhythm        */
  --space-7xl: 6rem;       /* 96px  between homepage sections, desktop         */

  /* Heading margins stay RELATIVE to their own size on purpose: a 3rem h1 and
     a 1.4rem h3 should not be pushed apart by the same absolute gap. */
  --space-heading-below: 0.5em;
  --space-heading-above-2: 1.6em;
  --space-heading-above-3: 1.4em;

  /* ------------------------------------------------- shape, motion, layout */
  --radius-sm: 6px;        /* absorbed the stepper button's 8px               */
  --radius-md: 10px;
  /* M018 Task 4. The owner ruled the claims band's square figures carry a
     GENEROUS corner radius, which --radius-md at 10px is not on a 360px box.
     One rung, added because R27 forbids a literal radius outside :root and the
     standing rule is to add a token rather than to write the number inline.
     Used by the claims band alone today. */
  --radius-lg: 24px;
  --radius-pill: 999px;
  --radius-circle: 50%;

  --border-width: 1px;
  --border-width-marker: 2px;   /* the nav's current-page underline           */

  /*
   * D5 — DECIDED: there is no elevation system, and that is the design.
   *
   * There is not one box-shadow in this stylesheet. M002-A reported the absence
   * as a gap; the architect has ruled it is a choice. Depth here is carried by
   * borders and by the tint surface, consistently, on every surface that has
   * it — which is a coherent system, and it is what "keep the current look"
   * means. Adding shadows by reflex, one component at a time, is precisely the
   * drift R27 exists to stop. If elevation is ever wanted it is a deliberate
   * design pass with the owner, not a --shadow-sm someone reaches for.
   */

  --transition-fast: 0.15s ease;

  /* The minimum comfortable hit area. Not a spacing value — a target size. */
  --tap-min: 44px;

  /*
   * 🔴 THE HEADER'S OWN HEIGHT, DERIVED FROM THE THREE TOKENS THAT BUILD IT
   * (M024 Task 2). Not measured off a screenshot and not a magic 69.
   *
   * `.site-header` is `position: sticky` and therefore IN FLOW, so anything
   * that wants to be "the viewport minus the header" has to subtract a number
   * CSS cannot ask for. The honest way to have that number is to make it the
   * same arithmetic the header itself performs:
   *
   *     .brand        min-height: var(--tap-min)      44px — the tallest child
   *     .header-inner padding-block: var(--space-xs)  12px, twice
   *     .site-header  border-bottom: var(--border-width) 1px
   *
   * 44 + 24 + 1 = 69px, and 69.00px is exactly what the header measures at
   * 768, 844, 1280, 1440 and 2560. Written this way the token cannot drift
   * from the header: raise --tap-min and both move together.
   *
   * ⚠ IT DESCRIBES THE ONE-ROW HEADER ONLY, AND THAT IS WHY THE ONE THING
   * THAT USES IT IS SCOPED TO 768px AND UP. Below 768 the nav wraps to a
   * second row and the header measures 129px, which this arithmetic does not
   * predict and does not try to. Nothing below 768 subtracts it.
   */
  --header-h: calc(var(--tap-min) + 2 * var(--space-xs) + var(--border-width));

  /*
   * THE HERO'S FLOOR — how short the first screen is allowed to get (M024).
   *
   * DERIVED FROM THE HERO'S OWN CONTENT, MEASURED. The h1, the lead and the
   * Browse button together measure 316.0px at 1280, 1440 and 2560, which is
   * their tallest — --text-hero is a clamp and has reached its 4rem cap by
   * 1280, so the block cannot grow past this at any wider viewport. Plus the
   * hero's new 40px of padding top and bottom that is 396px, and 416px leaves
   * 20px (5%) in hand for font-loading variance.
   *
   * WITHOUT IT A VIEWPORT-HEIGHT HERO CRUSHES ITS OWN CONTENTS on a short
   * screen: a phone in landscape reports 390px of viewport height, of which
   * 321px is left after the header, and 321 is 75px less than the text needs.
   */
  --hero-floor: 26rem;

  --width-container: 1200px;
  --width-prose: 750px;    /* the prose COLUMN: headings, figures, panels.
                              M002-A reported this and --width-deal (720px) as
                              the same role at two values; D2 collapsed them,
                              so a deal page is 30px wider than it was.       */
  --width-cart: 900px;
  --width-summary: 15rem;

  /*
   * The MEASURE — how wide a line of running text is allowed to get (D6).
   *
   * This is not the same thing as the prose column and never was. The column
   * was 750px, and measured in a browser that is 99 characters per line at
   * 1440px and 103 at its worst. Typographic practice puts the comfortable
   * range at 45-75; past about 80 the eye starts losing its place on the
   * return sweep, and the reader re-reads a line or skips one. 99 is well past
   * that, and it is a readability defect rather than a matter of taste.
   *
   * 34rem measures ~72 characters in Inter at 16px. It is not a new value:
   * --width-support was already 34rem, capping the one paragraph on the cart
   * page that someone had noticed running long. That token is gone and this
   * one does its job as well, which is the M002-A pattern — the value was
   * already here, being used for exactly this, under a name that hid it.
   *
   * Applied to running text ONLY. Figures, the testimonial panel and the card
   * grids keep the full 750px column, because narrowing an image is a
   * different change from narrowing a paragraph and only one of them was
   * asked for.
   */
  --width-measure: 34rem;
}

/*
 * BREAKPOINTS — one set, used by the stylesheet AND by the templates (D4).
 *
 * CSS custom properties cannot be used inside a media query, so these are named
 * here and written literally below.
 *
 *   560px   below this the cart table stops being a table and becomes rows
 *   768px   at and above this the product page goes two columns
 *
 * M002-A reported that the templates' image `sizes` attributes used a
 * different pair — 640 and 760 — which meant the site had four breakpoints,
 * two of which described nothing. D4 reconciled them to these two, and the
 * direction of the reconciliation was decided by measurement, not by which
 * file was easier to edit: 560 and 768 are where the layout actually changes,
 * and 640 and 760 are where nothing happens at all.
 *
 * The card grids have NO breakpoint and never did: `auto-fill minmax(220px,
 * 1fr)` reflows continuously, so a card's `sizes` states its widest possible
 * column rather than pretending a breakpoint exists (see product-grid.njk).
 */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

/*
 * `hidden` must actually hide.
 *
 * The attribute's UA rule is `[hidden] { display: none }` at specificity
 * (0,0,1), so ANY class rule setting `display` silently beats it. Two elements
 * were doing exactly that: `.nudge` (display: flex) rendered an empty blue
 * panel on every cart that had no nudge to show, and `.cart-count`
 * (display: inline-block) rendered an empty blue pill beside "Cart" in the
 * header on every page with an empty cart. Both are visible in the M002-A
 * before/ screenshots. Nothing on this site wants a class rule to outrank
 * `hidden`, so this settles it once for all of them.
 */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--color-surface);
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: var(--leading-body);
}

h1, h2, h3, h4 {
  font-family: var(--font-heading);
  color: var(--color-text-strong);
  line-height: var(--leading-heading);
  margin: 0 0 var(--space-heading-below);
}
h1 { font-size: var(--text-h1); }
h2 { font-size: var(--text-h2); margin-top: var(--space-heading-above-2); }
h3 { font-size: var(--text-h3); margin-top: var(--space-heading-above-3); }
h4 { font-size: var(--text-lg); }

p { margin: 0 0 var(--space-sm); }

a { color: var(--color-accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: var(--color-accent-hover); }

img, picture { max-width: 100%; height: auto; display: block; }

.container { width: min(100% - var(--space-3xl), var(--width-container)); margin-inline: auto; }
.muted { color: var(--color-text-muted); }

.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--color-accent); color: var(--color-text-inverse);
  padding: var(--space-2xs) var(--space-sm);
  min-height: var(--tap-min);
  display: flex; align-items: center;
}
.skip-link:focus { left: 0; }

/*
 * ONE focus style, on everything focusable.
 *
 * `:where()` keeps this at zero specificity so any component can still style
 * its own focus without an !important war — but nothing does, and nothing
 * should. `summary` and `select` are included because both are natively
 * focusable and neither was covered before.
 *
 * The ring is the accent at 3px with a 2px offset, so the gap shows whatever
 * the control sits on and the ring reads against it: 4.99:1 on the white page,
 * 4.53:1 on the sunken surface, 3.64:1 on the inverse bar. All clear the 3:1
 * WCAG 2.4.11 asks of a focus indicator.
 */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/*
 * Hit-area expansion.
 *
 * Applied where a control sits inside running text or a tight row and cannot
 * grow to 44px without moving the things around it. The pseudo-element is
 * invisible and out of flow, so the painted control and the layout are exactly
 * as they were; only the area that responds to a finger changes.
 */
/* .store-notice-dismiss was in both selector lists and is deleted with the bar
   (M007 Task 3). It was the reason the expansion had to be invisible and out of
   flow in the first place: growing the pill would have grown the notice bar by
   5px on every page of the site. */
.breadcrumb a,
.product-cats a,
.link-button,
.nudge-link { position: relative; }

.breadcrumb a::after,
.product-cats a::after,
.link-button::after,
.nudge-link::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  min-width: var(--tap-min);
  height: var(--tap-min);
}

/* ------------------------------- header -------------------------------- */

.site-header {
  border-bottom: var(--border-width) solid var(--color-border);
  background: var(--color-surface);
  position: sticky; top: 0; z-index: 20;
}
.header-inner {
  display: flex; align-items: center;
  gap: var(--space-sm); padding: var(--space-xs) 0;
}

/*
 * THE BRAND LOCKUP: mark then wordmark, on one line.
 *
 * The tagline that used to sit under the name has moved to the footer (see
 * base.njk), which is what lets this be a single row and is most of why the
 * mobile header is now shorter rather than taller.
 */
/*
 * `color` here is load-bearing, not inherited decoration.
 *
 * The mark is an inline SVG filled with `currentColor`, and .brand is an <a>,
 * so without this it inherits the global link colour and the mitten renders
 * EMBER on every page of the site. That is wrong twice over: the mark is
 * charcoal on light surfaces by design, and it would silently spend one of the
 * two or three ember elements 1.1 allows per page before any page content had
 * a say. It rendered that way in the first build of this header.
 */
.brand {
  text-decoration: none; display: flex; align-items: center;
  gap: var(--space-2xs); margin-right: auto;
  color: var(--color-text-strong);
  /*
   * The brand is a link, and it is now the ONLY route to the homepage from the
   * header — "Home" left the nav because the logo does that job. So it has to
   * clear the same 44px target every nav link does.
   *
   * It does not get there on its own: the mark is sized by WIDTH, and the
   * drawing is wider than it is tall (661:518), so a 48px-wide mark is only
   * 37.6px high and the whole lockup measured 216.7 x 37.6. The audit caught
   * it — which is the second time the 44px hit-test has caught a control that
   * looked completely fine.
   */
  min-height: var(--tap-min);
}
/*
 * 48px WIDE (the drawing is 661:518, so 37.6px tall), and it is a STRUCTURAL
 * dimension rather than a step on the spacing
 * scale — the same category as the 48px thumbnail and the 44px touch target,
 * which R27 documents as exempt.
 *
 * The size was measured against the wordmark, not chosen. Legibility alone is
 * satisfied earlier: at 28px the outline goes grey and indistinct, at 32px it
 * reads, and at 40px it is unambiguously a drawing of Michigan. But 2.5 asks
 * for PRESENCE rather than decoration, and rendered beside a 20px wordmark the
 * 40px mark still reads as a faint doodle attached to the words — legible and
 * inconsequential. At 48px it holds its own half of the lockup. At 56px it
 * starts to drive the header's height instead of sitting in it.
 *
 * The thickened trace was tried here too and is NOT used: at this size the
 * extra weight tightens the counters and the drawing loses the light hand that
 * makes it the owner's. It stays scoped to the favicon.
 */
.brand-mark { display: block; width: 48px; flex: none; }
.brand-mark svg { display: block; width: 100%; height: auto; }
.brand-name {
  font-family: var(--font-heading); font-weight: var(--weight-medium);
  font-size: var(--text-2xl); color: var(--color-text-strong);
  letter-spacing: var(--tracking-tight);
}

.site-nav ul {
  display: flex; flex-wrap: wrap; gap: var(--space-3xs) var(--space-md);
  list-style: none; margin: 0; padding: 0;
}
/*
 * Nav links carry the site's only 44px-by-growth touch fix. They cannot use
 * the invisible expansion above: the list wraps to two rows on a phone, and
 * two 44px hit areas either side of a 4px gap would overlap, so a tap between
 * them would open the wrong page. Growing the real box is the only correct
 * answer, and it makes the mobile nav rows taller. Reported as such.
 */
.site-nav a {
  text-decoration: none; color: var(--color-text-strong);
  font-weight: var(--weight-medium); font-size: var(--text-md);
  display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--tap-min); min-width: var(--tap-min);
  border-bottom: var(--border-width-marker) solid transparent;
}
/*
 * The current-page marker is CHARCOAL, not ember, and that is an ember-budget
 * decision rather than an aesthetic one (1.1: no more than two or three ember
 * elements per page).
 *
 * The header already spends one on the Shop button, which is where the owner
 * wants people to go. Making the active nav item ember too would spend a
 * second before any page content has had a chance to, and on most pages the
 * active item is About or Contact — the least important thing in the row. A
 * charcoal underline marks position perfectly well.
 *
 * Hover stays ember: it is transient, only ever on one element, and never
 * present in a screenshot of a page at rest.
 */
.site-nav a[aria-current='page'] {
  color: var(--color-text-strong); border-bottom-color: var(--color-text-strong);
}
.site-nav a:hover {
  color: var(--color-accent); border-bottom-color: var(--color-accent);
}

/*
 * THE CART: an icon with a count, set apart from the text links.
 *
 * It is a state indicator, not a destination, so it is not in the nav list and
 * does not get the nav's underline marker. It keeps the 44px target the nav
 * links have.
 */
.cart-link {
  display: inline-flex; align-items: center; justify-content: center;
  position: relative; text-decoration: none;
  min-width: var(--tap-min); min-height: var(--tap-min);
  color: var(--color-text-strong);
  margin-left: var(--space-2xs);
}
.cart-link:hover { color: var(--color-accent); }
.cart-icon { display: block; width: 24px; height: 24px; }
/*
 * The count sits on the icon's corner. It is CHARCOAL rather than ember for
 * the same budget reason as the nav marker above — and it reads better as a
 * neutral state chip than as a second call to action competing with Shop.
 */
.cart-count {
  position: absolute; top: var(--space-2xs); right: var(--space-3xs);
  min-width: 1.4em;
  background: var(--color-surface-inverse); color: var(--color-text-inverse);
  border-radius: var(--radius-pill); font-size: var(--text-3xs);
  font-weight: var(--weight-medium); line-height: 1.4;
  padding: 0 var(--space-3xs); text-align: center;
}

/*
 * MOBILE (3.7: "the header is currently taller on mobile than desktop; do not
 * make that worse").
 *
 * Brand and cart share the first row, the nav takes the second. 560px is the
 * stylesheet's own lower breakpoint — the one at which the cart table becomes
 * rows — and no new breakpoint is introduced here (M002-B, D4: the site has
 * exactly two and they describe real layout changes).
 *
 * This is SHORTER than what it replaces, not taller. The old header wrapped a
 * two-line brand above seven 44px links that needed two rows; this is a
 * one-line brand and four links that fit on one.
 */
@media (max-width: 560px) {
  .header-inner { flex-wrap: wrap; }
  .site-nav { order: 3; flex-basis: 100%; }
  .site-nav ul { justify-content: space-between; gap: var(--space-3xs) var(--space-2xs); }
}

/* ----------------------------- deals band ------------------------------- */
/*
 * Full-bleed charcoal band at the top of /shop/ (Part 4).
 *
 * "Prominent, not tasteful" — so the device is the strongest one the site has:
 * the only full-width dark surface outside the store notice, carrying the
 * largest link targets on the site. It is used exactly ONCE, on one page, which
 * is what stops "prominent" becoming "loud".
 *
 * On the ember budget (1.1): the band's ground is CHARCOAL, not ember. Part 4.5
 * allows a larger ember presence here, and this spends it on one filled button
 * rather than on a section background — which 1.1 rules out in terms. A band
 * of ember behind the whole thing is the single fastest way to turn this site
 * into a rib shack.
 */
.deals-band {
  background: var(--color-surface-inverse);
  color: var(--color-text-inverse);
  /* Cancel main's top padding so the band sits flush under the header; a gap
     of page above it would read as a widget rather than as part of the page. */
  margin-top: calc(-1 * var(--space-3xl));
  margin-bottom: var(--space-3xl);
  padding: var(--space-2xl) 0;
}
/*
 * M026 - THE HEADING IS ABOVE THE ROW, NOT BESIDE IT.
 *
 * This was a wrapping flex row of the heading and the card list, and the
 * owner ruled the heading onto its own line: at heading weight beside the
 * cards it read as a peer competing with them rather than as a label over
 * them, where "Shop" directly below reads clean because it is a heading on
 * the page rail with its content beneath.
 *
 * ONE COLUMN, AND THE ROW GAP IS THE ONE THIS ALREADY USED. --space-lg is
 * what the flex row gapped its wrapped lines by, so the distance between the
 * heading and the cards at 390 - where it had already wrapped - is unchanged.
 * No new token and no new value.
 *
 * THE CARD GRID'S TRACK COUNT IS NOT SET HERE AND NEVER WAS. The five cards
 * are a grid inside .deals-band-list; this only decides how much width that
 * list is handed. It is now handed the whole container, so the cards get
 * wider. That is measured in the M026 outcome block rather than assumed.
 */
.deals-band-inner {
  display: grid; gap: var(--space-lg);
}
/*
 * The band's heading uses the page-level h2 size, not a component size. At
 * --text-3xl it was 22px next to 20px prices, so it read as a label stuck on
 * the side of a list rather than as the thing announcing the band.
 */
.deals-band-title {
  font-size: var(--text-h2); color: var(--color-text-inverse);
  margin: 0; line-height: var(--leading-heading);
}
.deals-band-list {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: var(--space-2xs) var(--space-2xl);
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  /* `flex: 1 1 380px` lived here until M026 and is gone rather than left: the
     parent is no longer a flex container, so it sized nothing. A dead
     declaration is a comment that looks like code. */
}
/*
 * M027 TASK 1 — THREE ACROSS ON TABLETS, ON THE OWNER'S RULING.
 *
 * M026 handed this list the whole container and at tablet widths auto-fit
 * started fitting FOUR tracks, so five cards wrapped 4 + 1 with an orphaned
 * Full Rack on its own line. The owner has seen it rendered and ruled it back
 * to 3 + 2.
 *
 * 🔴 M026 PROPOSED RAISING THE `minmax` FLOOR AND THAT FIX DOES NOT EXIST.
 * It was derived from arithmetic at 768 alone. Measured across the whole range
 * (tools/m027-band.mjs), the track count is a fact about the CONTAINER, and
 * with the 32px column gap the thresholds are:
 *
 *     3 tracks from container 520      4 tracks from container 700
 *     5 tracks from container 880
 *
 * A floor of 155-159 does give 3 + 2 at 768 (container 728) — and still gives
 * 4 + 1 at 820 (container 780), which is iPad Air and 11-inch iPad Pro
 * portrait. Fixing 820 needs a floor of 175+, and at 390 the container is 350,
 * two tracks need 2f + 32 <= 350 so f <= 159, and anything above collapses the
 * phone band to ONE column and adds 301px of height. The two constraints do
 * not overlap. NO SINGLE FLOOR SATISFIES ALL FOUR WIDTHS — do not try it again.
 *
 * SO THE RULE STATES THE INTENT INSTEAD OF ENCODING IT AS A COINCIDENCE
 * between a floor, a gap and a container's padding. Three explicit columns
 * across the band where five do not yet fit.
 *
 * WHERE THE EDGES ARE AND WHY THEY ARE THERE. The measured 4 + 1 window is
 * container 700 to 879 — viewport 740-919 on a tablet, 755-934 in a desktop
 * window whose 15px scrollbar eats into the container. Both edges clear it:
 *
 *   LOWER  560.02px, the complement of the existing `max-width: 560px`
 *          breakpoint, and NOT a new one. It is also, measured, within a
 *          pixel of where three tracks start fitting on their own, so this
 *          query changes NOTHING between 560 and 740 — auto-fit was already
 *          giving three there and `repeat(3, 1fr)` gives the same three equal
 *          tracks. Below it 390 is untouched and stays 2 + 2 + 1.
 *   UPPER  959.98px, and this one IS a new breakpoint rather than an existing
 *          one, because the repository had nothing between 768 and infinity.
 *          It sits above BOTH five-track thresholds — 920 without a scrollbar,
 *          935 with one — with 25px of margin, so a desktop window cannot land
 *          in the orphan window on a platform whose scrollbar is wider than
 *          Chrome's. Above it 1440 is untouched and stays five across.
 *
 * THE `auto-fit` FLOOR IS DELIBERATELY UNCHANGED. It still decides the phone
 * (2 + 2 + 1 at 390) and the desktop (5 at 1440); this query only takes the
 * range in between away from it.
 *
 * The `0.02px` pair rule applies to both edges: exactly one rule may apply at
 * a boundary. → .claude/rules/design-system-css.md
 */
@media (min-width: 560.02px) and (max-width: 959.98px) {
  .deals-band-list { grid-template-columns: repeat(3, 1fr); }
}
/*
 * M026 - THE ENTRY GAPS ITS TWO PAIRS; EACH PAIR GAPS ITS OWN TWO LINES.
 *
 * THE TWO DISTANCES AND THEIR TOKENS, WHICH ARE THE POINT OF THE REORDER:
 *   BETWEEN pairs  --space-xs   12px   name+contents | price+shipping
 *   WITHIN a pair  --space-3xs   4px   the gap this card already used
 *
 * The owner ruled the four lines into two pairs because "(3 Jars)" describes
 * the NAME and "+ $7.99 shipping" modifies the PRICE. If the two gaps were
 * equal the reorder would buy nothing - four evenly spaced lines read as four
 * lines whatever order they are in. A grid gap is uniform, so the pairs have
 * to be boxes for the two distances to differ at all, which is why
 * .deals-band-pair is an element and not a margin.
 *
 * NO MARGIN AND NO PADDING ON ANY OF THE FOUR LINES, still. Every distance in
 * this card is a gap on a grid, so there is one place each of them is set and
 * no arithmetic between two rules.
 */
.deals-band-list a {
  display: grid; gap: var(--space-xs);
  text-decoration: none; color: var(--color-text-inverse);
  padding: var(--space-2xs) 0;
}
.deals-band-pair { display: grid; gap: var(--space-3xs); }
.deals-band-list a:hover .deals-band-name { text-decoration: underline; }
.deals-band-name { font-weight: var(--weight-medium); }
.deals-band-price {
  font-family: var(--font-heading); font-size: var(--text-2xl);
  color: var(--color-text-inverse);
}
/*
 * M006 TASK 1 — the owner's qualifier line, and it must READ AS SUBORDINATE to
 * both the name above it and the price above that. Three devices do that, and
 * none of them is a new token:
 *
 *   POSITION  third in the entry's grid, last thing in the tap target.
 *   SIZE      --text-sm (14.4px), below the name's 16 and the price's 20.
 *   INK       --color-text-inverse-muted, quieter than the bone beside it.
 *
 * Weight is deliberately left alone. The name already carries --weight-medium
 * and this does not, so it is lighter than the name without a rule saying so.
 *
 * NO MARGIN AND NO PADDING. The entry is a grid with its own gap; adding space
 * here would put this line at a different distance from the price than the
 * price sits from the name, and the strip's compactness is a stated
 * constraint — it is a band, not a list of specifications.
 */
.deals-band-detail {
  font-size: var(--text-sm); color: var(--color-text-inverse-muted);
}
/*
 * M025 — THE SHIPPING LINE, AND IT IS QUIETER THAN THE PRICE ABOVE IT.
 *
 * NO NEW TOKEN, AND DELIBERATELY THE SAME TWO THE LINE BELOW IT ALREADY USES:
 * --text-sm (14.4px, under the price's 20 and the name's 16) and
 * --color-text-inverse-muted, which is a 4.50:1 ink measured against this
 * band's charcoal ground and is already painted on this card. So there is no
 * ink here R28 has not already judged on this background.
 *
 * WHY IT LOOKS THE SAME AS .deals-band-detail RATHER THAN A STEP BETWEEN IT
 * AND THE PRICE: both lines are subordinate facts about the same deal, and a
 * third size would rank them against each other for no reason. Position is
 * what separates them — this one sits against the price it adds to.
 *
 * NOTHING HERE MAY BECOME A BADGE. The owner ruled that the Full Rack's free
 * shipping reads by CONTRAST — it is the only card with no number — so an
 * accent colour, a pill or a weight change on the free card would replace the
 * device he chose with the one he ruled against. No margin and no padding, for
 * the same reason the line below carries none: the entry is a grid with its
 * own gap.
 */
.deals-band-ship {
  font-size: var(--text-sm); color: var(--color-text-inverse-muted);
}
.deals-band-cta { margin: 0; }

/* ------------------------------ content -------------------------------- */

main { display: block; padding: var(--space-3xl) 0 var(--space-5xl); }
.page { max-width: var(--width-prose); }
.page figure { margin: var(--space-xl) 0; }
.page figure picture img { border-radius: var(--radius-md); }

/*
 * The measure (D6). Running text only, and by DIRECT CHILD on purpose.
 *
 * `.page p` would also catch the paragraph inside the testimonial panel and
 * leave a 528px line of type sitting in a 750px tinted box, which looks like a
 * bug rather than a decision. The content blocks emit their running text as
 * direct children of .page — including bare <li> elements, which is how the
 * captured lists arrive — so the child combinator is exactly the set of
 * elements that carry prose.
 *
 * .product-description is the same defect on a different page: the product
 * column runs to 576px, which measured 75 characters.
 *
 * Headings are deliberately NOT capped. At 24-48px a heading reaches nothing
 * like 70 characters before it wraps, and forcing them to the body measure
 * would ragged them for no reading benefit.
 */
.page > p,
.page > li,
.page > blockquote,
.product-description p { max-width: var(--width-measure); }

.breadcrumb {
  font-size: var(--text-xs); color: var(--color-text-muted);
  margin-bottom: var(--space-md); display: flex; gap: var(--space-2xs); flex-wrap: wrap;
}

/* ------------------------------- grids --------------------------------- */

.product-grid, .post-grid, .deal-grid {
  list-style: none; margin: var(--space-lg) 0 0; padding: 0;
  display: grid; gap: var(--space-lg);
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
/*
 * CENTRED, ON ONE BASELINE GRID (M004-K Part 7) — the same change as
 * .featured-card and for the same two reasons, so the two are worth reading
 * together.
 *
 * The owner ruled that a product card centres its name and its price. The
 * moment the text is centred, a title that wraps stops being untidy and starts
 * looking broken: on the shop grid "Fire Roasted Lemon Pepper" takes two lines
 * where "Smoked Árbol" takes one, and a centred price half a line lower than
 * the one beside it reads as a rendering fault rather than as a longer name.
 *
 * Three rows — media, title, price — shared across every card in a row, so the
 * longest name in a row sets the title track for all of them and every price
 * lands on the same line. `.product-grid` is `auto-fill minmax(220px, 1fr)`,
 * so the number of columns changes continuously with the viewport and the
 * subgrid follows it without a breakpoint.
 *
 * `row-gap: 0` because a subgrid inherits its parent's gap, and this parent's
 * 24px is the space between CARDS. The card's internal spacing is the title's
 * and price's own margins, unchanged.
 */
.product-card, .post-card, .deal-card {
  display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 0;
}
.product-card a, .post-card a, .deal-card a {
  text-decoration: none; color: inherit;
  display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 0;
  text-align: center;
  border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md);
  overflow: hidden; height: 100%; background: var(--color-surface);
  transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.product-card a:hover, .post-card a:hover, .deal-card a:hover {
  border-color: var(--color-accent); transform: translateY(-2px);
}
.card-media { aspect-ratio: 1 / 1; overflow: hidden; background: var(--color-surface-sunken); }
.card-media picture, .card-media img { width: 100%; height: 100%; object-fit: cover; }
.card-media-empty, .product-media-empty {
  display: grid; place-items: center; height: 100%; min-height: 200px;
  background: var(--color-surface-sunken); color: var(--color-text-muted);
  font-size: var(--text-xs); text-align: center;
}
.card-title { font-size: var(--text-base); margin: var(--space-xs) var(--space-xs) var(--space-3xs); }
.card-price { margin: 0 var(--space-xs) var(--space-sm); font-weight: var(--weight-medium); color: var(--color-text-strong); }
.post-card .muted { margin: 0 var(--space-xs) var(--space-sm); font-size: var(--text-sm); }

.cat-filter ul {
  list-style: none; display: flex; flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-xs); padding: 0; margin: var(--space-md) 0 0;
}
.cat-filter a {
  /* A flex container drops whitespace-only text nodes, so the space between
     the category name and its "(5)" count has to be re-stated as a gap. */
  display: inline-flex; align-items: center; gap: var(--space-3xs);
  min-height: var(--tap-min);
  padding: var(--space-3xs) var(--space-xs);
  border: var(--border-width) solid var(--color-border); border-radius: var(--radius-pill);
  font-size: var(--text-sm); text-decoration: none; color: var(--color-text-strong);
}
.cat-filter a:hover { border-color: var(--color-accent); color: var(--color-accent); }
/*
 * M026 - THE CHIP FOR THE PAGE YOU ARE ON.
 *
 * KEYED ON `aria-current`, NOT ON A CLASS, AND THAT IS THE WHOLE DESIGN. The
 * attribute is what a screen reader announces and what this paints, so there
 * is exactly one statement of "you are here" and no way for the visible state
 * and the announced state to drift apart. A separate `.is-current` alongside
 * would be two facts about one thing, which is the defect shape this
 * repository keeps repairing in the data.
 *
 * FILLED, NOT TINTED, BECAUSE IT IS ONE CHIP AMONG SIX. The row is six
 * outlined pills; a current state that only shifts the border weight or the
 * ink is a difference you have to hunt for. Inverting one of them is the
 * cheapest unmistakable difference and it needs no new colour.
 *
 * NO NEW TOKEN AND NO NEW PAIR. --color-surface-inverse with
 * --color-text-inverse is the deals band's own pairing and is already pinned
 * in R28's AA_PAIRS; it measures 16.92:1 here, read off the painted element.
 * The ember is deliberately NOT used: it is this palette's hover ink on these
 * very chips, so an ember current state would say the same thing as a
 * pointer, and the restraint note in :root caps ember at two or three
 * elements a page.
 *
 * ⚠ THE COUNT INSIDE IT NEEDS THE DARK-GROUND INK, AND THIS IS THE TRAP THE
 * PALETTE WARNS ABOUT IN TERMS. `.muted` is --color-text-muted, a 5:1 ink
 * derived for LIGHT grounds, and it measures 2.71:1 on this charcoal - an
 * accessibility failure introduced by inheriting a colour rather than
 * choosing one, which is exactly how M003-A came to derive
 * --color-text-inverse-muted for the deals band. It is reused here at
 * 4.50:1. Both pairs are already in AA_PAIRS; neither is new.
 */
.cat-filter [aria-current="page"] {
  background: var(--color-surface-inverse);
  border-color: var(--color-surface-inverse);
  color: var(--color-text-inverse);
}
.cat-filter [aria-current="page"] .muted { color: var(--color-text-inverse-muted); }
.cat-filter [aria-current="page"]:hover {
  /* Hover must not undo the current state. Without this the ember rule above
     repaints the ink to --color-accent, which measures 1.90:1 on charcoal -
     the same failure the focus ring was re-derived to avoid. */
  border-color: var(--color-surface-inverse); color: var(--color-text-inverse);
}

/* ------------------------------ product -------------------------------- */

.product-layout { display: grid; gap: var(--space-2xl); align-items: start; }
@media (min-width: 768px) {
  .product-layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-4xl); }
}
.product-media picture img, .product-hero { border-radius: var(--radius-md); width: 100%; }
.product-thumbs {
  list-style: none; padding: 0; margin: var(--space-xs) 0 0;
  display: flex; gap: var(--space-2xs); flex-wrap: wrap;
}
.product-thumbs li { width: 84px; }
.product-thumbs img { border-radius: var(--radius-sm); }

.product-info h1 { margin-top: 0; }
.price { font-size: var(--text-3xl); font-weight: var(--weight-medium); color: var(--color-text-strong); margin: 0 0 var(--space-sm); }
.ingredients { font-size: var(--text-sm); }

/*
 * M004-A Part 7.2. The heat level and the allergen warning were buried inside
 * the captured ingredient STRING — "…Citrus Beer ALLERGEN WARNING: CONTAINS
 * GLUTEN Heat Level: 5/10" — so they were on the page only by accident of
 * being in the same field. The audit separated them; these two rules are what
 * stops that separation losing them.
 *
 * The notice is set on the sunken surface with the control border rather than
 * in ember. It is the ONE piece of information on a product page a person may
 * need before they can safely buy — so it has to be findable — but ember is
 * the site's call-to-action colour and spending it here would say "buy this"
 * with the words "contains gluten". A bordered block on a tint is found
 * without being sold. It costs the ember budget nothing, which is why product
 * pages still measure 2.
 */
.product-fact { font-size: var(--text-sm); margin-top: calc(-1 * var(--space-xs)); }
.product-notice {
  font-size: var(--text-sm);
  color: var(--color-text-strong);
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  padding: var(--space-sm) var(--space-md);
  margin-top: var(--space-md);
}

/*
 * THE ALLERGEN WARNING IS THE SAME BLOCK, ONE STEP HEAVIER (M004-B).
 *
 * It shares .product-notice's shape on purpose — the two lines come off the
 * same part of the same jar label and inventing a second visual language for
 * them would say they are different KINDS of statement, which they are not.
 * What separates them is weight, because a person scanning for one word wants
 * the line that carries it to be the first one their eye stops on.
 *
 * No new colour, no new surface, no ember. Ember is the call-to-action ink and
 * spending it here would say "buy this" with the words "contains gluten" — see
 * the note above .product-notice, which made that argument first and still
 * holds. The ember budget on a product page is unchanged at 2.
 */
.product-allergen { font-weight: var(--weight-medium); }

.product-cats { font-size: var(--text-sm); color: var(--color-text-muted); margin-top: var(--space-lg); }

.stock { font-size: var(--text-md); margin: calc(-1 * var(--space-2xs)) 0 var(--space-sm); }

/* ---------------------- progressive enhancement ------------------------- */

/*
 * Cart controls are hidden until cart.js sets .js-on on <html>, so a visitor
 * with JavaScript disabled never meets a button that cannot do anything.
 */
.js-only { display: none; }
.js-on .js-only { display: block; }
.js-on form.js-only { display: flex; }

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

/* ------------------- store notice: DELETED (M007 Task 3) ----------------- */

/*
 * The charcoal bar at the top of all 42 pages is gone with the sentence it
 * carried — "PLEASE CONTACT NICK@MMSPICE.COM DIRECTLY TO PLACE AN ORDER" —
 * because there is an order form on /cart/ now and a banner sending people to
 * email instead would steer them off it. Owner ruling; see site.json's
 * orderNoticeRetiredNote and base.njk.
 *
 * TWO COLOUR TOKENS WENT WITH IT, AND THAT IS THE PART TO KNOW ABOUT.
 * `--color-inverse-line` (the dismiss pill's outline) and `--color-inverse-wash`
 * (its hover) were painted by these rules and by nothing else on the site, so
 * they are deleted from :root and from AA_EXEMPT in the same change. This is
 * the M005 `--color-text-inverse-muted` situation exactly, and R28 is what
 * makes it all-or-nothing rather than a diff nobody reads: deleting the rules
 * but keeping the tokens trips its "appears in no AA_PAIRS entry" check, and
 * deleting the tokens but keeping the pairs trips "is not defined in :root".
 * The measured values survive here in case a translucent control ever sits on a
 * dark ground again: rgba(255,255,255,0.5) and rgba(255,255,255,0.15).
 *
 * --color-surface-inverse and --color-text-inverse are UNTOUCHED and still
 * painted — the deals band on /shop/ is the same charcoal, and it is now the
 * only dark surface on the site. --color-focus keeps its charcoal pair for the
 * same reason; it is why that token stopped being an alias for the accent in
 * M003-A, and the band still needs a visible focus ring.
 */

/* ------------------------------ buttons --------------------------------- */

/*
 * ONE button. `.button` is the filled primary; `.button-quiet` and
 * `.stepper-btn` are the same control outlined. Before M002-A these were
 * built in separate passes and had drifted: two radii (8px and 10px), two
 * disabled opacities (0.35 and 0.5), two border colours, and a `.nav-cta` that
 * needed three !important declarations to beat `.site-nav a`. The !importants
 * are gone — `.site-nav a.nav-cta` simply outranks `.site-nav a`.
 *
 * `.button-primary` appeared on the two most important buttons on the site —
 * "Email This Order" and "Add ... to cart" — and had no rule anywhere. It has
 * been removed from the templates rather than given one, because `.button`
 * already is the primary.
 */
.button, .button-quiet, .stepper-btn, .nav-cta {
  font: inherit;
  font-weight: var(--weight-medium);
  border-radius: var(--radius-md);
  border: var(--border-width) solid transparent;
  cursor: pointer;
  text-decoration: none;
  transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.button, .button-quiet {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--tap-min);
  padding: var(--space-2xs) var(--space-lg);
}

.button {
  background: var(--color-accent);
  color: var(--color-text-inverse);
  border-color: var(--color-accent);
}
.button:hover {
  background: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
  color: var(--color-text-inverse);
}

.button-quiet {
  background: var(--color-surface);
  color: var(--color-text-strong);
  border-color: var(--color-border-strong);
}
.button-quiet:hover {
  background: var(--color-surface-sunken);
  color: var(--color-text-strong);
  border-color: var(--color-accent);
}

/*
 * One disabled treatment, for every button (D7).
 *
 * This was `opacity: 0.5`, and on the deal configurator — the most commercially
 * important page on the site — that is what the primary CTA looks like until
 * the shopper has chosen their spices. A half-transparent blue button on white
 * does not read as "not yet", it reads as broken: the shopper cannot tell
 * whether the page has failed to load, the button has failed to work, or they
 * have done something wrong. And it is the FIRST thing on that page.
 *
 * A disabled control still has to look like a control. So the treatment is now
 * a real one — the tint surface, muted ink, and the same border every other
 * control uses to say "this is a control" — rather than the enabled treatment
 * with the contrast drained out of it. The reason it is disabled is stated in
 * .deal-reason directly beneath it, and always has been.
 *
 * WCAG exempts disabled controls from contrast requirements. These clear it
 * anyway: 4.52:1 for the label and 3.13:1 for the outline, both already pinned
 * by R28. "Exempt" is not a reason to be unreadable.
 */
.button:disabled, .button-quiet:disabled, .stepper-btn:disabled {
  background: var(--color-surface-sunken);
  color: var(--color-text-muted);
  border-color: var(--color-border-strong);
  cursor: default;
}

.link-button {
  background: none; border: 0; padding: 0;
  font: inherit; font-size: var(--text-sm);
  color: var(--color-accent); text-decoration: underline; cursor: pointer;
}
.link-button:hover { color: var(--color-accent-hover); }

.nav-cta, .site-nav a.nav-cta {
  background: var(--color-accent); color: var(--color-text-inverse);
  padding: var(--space-3xs) var(--space-sm);
  border-bottom: var(--border-width-marker) solid transparent;
}
.site-nav a.nav-cta:hover { background: var(--color-accent-hover); color: var(--color-text-inverse); }


/* --------------------------- add to cart -------------------------------- */

.add-to-cart {
  align-items: flex-end; flex-wrap: wrap; gap: var(--space-xs);
  margin: var(--space-lg) 0 var(--space-sm);
}
.quantity { display: grid; gap: var(--space-3xs); }
.quantity label { font-size: var(--text-2xs); color: var(--color-text-muted); }
.qty {
  width: 5rem; padding: var(--space-2xs); font: inherit;
  min-height: var(--tap-min);
  color: var(--color-text-strong);
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border-strong);
  border-radius: var(--radius-md);
}
.add-status { width: 100%; margin: 0; font-size: var(--text-sm); color: var(--color-text-strong); }

/*
 * `.order-direct` IS DELETED (M007 Task 3). It dressed the owner's captured
 * ordering instruction at the point of sale on product pages, deal pages and
 * /gift/; that instruction named the mailto: as the only way to order, and the
 * owner has ruled it out. A rule kept for markup nobody emits is a rule nobody
 * can tell has stopped applying.
 */

/* ------------------------------- deals ---------------------------------- */

.deal-card a { padding: var(--space-md); }
.deal-name { margin: 0 0 var(--space-3xs); font-size: var(--text-xl); }
.deal-price { margin: 0 0 var(--space-3xs); font-size: var(--text-4xl); font-weight: var(--weight-medium); color: var(--color-text-strong); }
.deal-meta { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); }

/* D2: was --width-deal (720px), which was --width-prose in all but name. */
.deal-page { max-width: var(--width-prose); }
.deal-header .price { margin-bottom: var(--space-3xs); }
.deal-nojs {
  padding: var(--space-xs) var(--space-sm); background: var(--color-surface-sunken);
  border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md);
}

/*
 * The spice picker. Designed at 320px first: a single column of rows, each
 * row a thumbnail, a name that wraps, and a stepper that never shrinks. A
 * 10-item deal on a small phone is the hard case, so it is the base case.
 */
.deal-picker-head {
  position: sticky; top: 0; z-index: 5;
  background: var(--color-surface); padding: var(--space-2xs) 0;
  border-bottom: var(--border-width) solid var(--color-border);
}
.deal-counter { margin: 0; font-weight: var(--weight-medium); color: var(--color-text-strong); }

/*
 * THE PICKER GROUPS (M005-B, OWNER RULING).
 *
 * The seventeen-row flat scroll is three headed groups now — Slow Smoked,
 * Fire Roasted, Signature Blends, in the homepage tiles' own order. The rows
 * themselves are untouched; this is the heading and the space around it.
 *
 * NAVIGATIONAL, NOT PROMOTIONAL, which the owner named as a requirement and
 * which is a type decision rather than a copy one. The heading has to sit
 * BELOW the deal's name and its description in the hierarchy: the name is
 * --text-h1 (up to 3rem) and the description is body size at regular weight,
 * so this takes --text-lg at medium weight — one rung above body and one
 * weight up. That is enough to read as structure and not enough to read as a
 * second title. It is deliberately NOT uppercase: capitals are this site's
 * transcription idiom for warning lines off the physical jars (see the
 * standing decision on label facts), and borrowing that voice for a picker
 * divider would weaken it where it matters.
 *
 * NO RULE, NO TINT, NO CARD. A group is separated by space alone, which is
 * the same device the brand block uses for its beats and is consistent with a
 * stylesheet that has no box-shadow in it by design. The rows already carry a
 * hairline bottom border, so a border here would be the second line in eight
 * pixels.
 *
 * NO NEW R28 PAIR: --color-text-strong on --color-surface is already the
 * site's heading pair and is already measured.
 * depends: --color-text-strong
 */
.spice-group + .spice-group { margin-top: var(--space-xl); }
.spice-group-title {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: var(--weight-medium);
  color: var(--color-text-strong);
}

.spice-picker { list-style: none; margin: var(--space-2xs) 0 0; padding: 0; }
.spice-row {
  display: grid;
  grid-template-columns: 48px minmax(0, 1fr) auto;
  align-items: center; gap: var(--space-xs);
  padding: var(--space-2xs) var(--space-3xs);
  border-bottom: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-sm);
}
.spice-row.is-selected { background: var(--color-surface-sunken); }
.spice-thumb {
  width: 48px; height: 48px; border-radius: var(--radius-sm);
  overflow: hidden; background: var(--color-surface-sunken);
}
.spice-thumb picture, .spice-thumb img { width: 100%; height: 100%; object-fit: cover; }
/*
 * The stand-in for a product with no photography (M002-B Part 3).
 *
 * This replaced an 8px "Awaiting product image" label — the smallest text on
 * the site by half, in the smallest box on the site. The owner's ruling was
 * initials on a tint.
 *
 * M002-C: the mark is now ALWAYS two characters. M002-B sized this for the
 * worst case of five ("CeleS", "CeltS") at --text-sm, which meant the common
 * two-character case sat at 14px — smaller than the product name beside it, so
 * the stand-in for a photograph was the quietest thing in its own row. With the
 * length fixed at two there is no worst case to size around, and the mark takes
 * --text-xl: ~25px of glyph inside a 48px box, which reads as a mark rather
 * than as text that failed to fit. No padding: the box is the padding.
 *
 * The ink is --color-text-strong on the tint, 16.5:1 — this stands in for a
 * photograph, so it gets the strongest ink available rather than the muted one
 * the old label used at 4.5:1.
 */
.spice-thumb-initials {
  display: grid; place-items: center; height: 100%;
  font-family: var(--font-heading); font-weight: var(--weight-medium);
  font-size: var(--text-xl); line-height: var(--leading-heading);
  letter-spacing: var(--tracking-tight);
  color: var(--color-text-strong); text-align: center;
}
.spice-name { font-size: var(--text-md); color: var(--color-text-strong); overflow-wrap: anywhere; }

.spice-stepper { display: flex; align-items: center; gap: var(--space-3xs); }
.stepper-btn {
  width: var(--tap-min); height: var(--tap-min);
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border-color: var(--color-border-strong); background: var(--color-surface);
  border-radius: var(--radius-sm); font-size: var(--text-2xl); line-height: 1;
  color: var(--color-text-strong);
}
.stepper-btn:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-accent); }
.spice-qty { min-width: 2ch; text-align: center; font-weight: var(--weight-medium); color: var(--color-text-strong); }

.deal-actions { margin: var(--space-md) 0 0; display: grid; gap: var(--space-2xs); justify-items: start; }
.deal-reason, .deal-status { margin: 0; font-size: var(--text-sm); color: var(--color-text-strong); }

/* The order-mode conflict prompt lived here. Pricing model v4 lets deals and
   individual jars share an order, so there is nothing left to resolve. */

/* ------------------------------ cart page -------------------------------- */

.cart-page { max-width: var(--width-cart); }
.cart-table { width: 100%; border-collapse: collapse; margin: var(--space-lg) 0; }
.cart-table th, .cart-table td {
  text-align: left; padding: var(--space-xs) var(--space-2xs);
  border-bottom: var(--border-width) solid var(--color-border); vertical-align: middle;
}
.cart-table thead th {
  font-size: var(--text-2xs); text-transform: uppercase;
  letter-spacing: var(--tracking-caps); color: var(--color-text-muted);
}
.cart-table tbody th { font-weight: var(--weight-medium); }
.cart-table a { text-decoration: none; }

.cart-summary { display: grid; gap: var(--space-2xs); justify-items: end; text-align: right; }
.cart-count-line { margin: 0; color: var(--color-text-muted); font-size: var(--text-sm); }
.total-row {
  margin: 0; display: flex; gap: var(--space-lg); justify-content: space-between;
  min-width: var(--width-summary); font-size: var(--text-base);
}
.cart-total-line {
  margin: var(--space-3xs) 0 0; display: flex; gap: var(--space-lg); justify-content: space-between;
  min-width: var(--width-summary); align-items: baseline; font-size: var(--text-xl);
  border-top: var(--border-width) solid var(--color-border); padding-top: var(--space-2xs);
}
.cart-total-line strong { font-size: var(--text-3xl); color: var(--color-text-strong); }
.shipping-ceiling {
  margin: var(--space-3xs) 0 0; font-size: var(--text-sm);
  color: var(--color-accent); font-weight: var(--weight-medium);
}

.bundle-contents {
  list-style: none; margin: var(--space-3xs) 0 0; padding: 0;
  font-size: var(--text-xs); color: var(--color-text-muted); font-weight: var(--weight-normal);
}

.nudge {
  margin: var(--space-md) 0 0; padding: var(--space-xs) var(--space-sm);
  background: var(--color-surface-sunken); border: var(--border-width) solid var(--color-accent);
  border-radius: var(--radius-md); font-size: var(--text-md); color: var(--color-text-strong);
  display: flex; flex-wrap: wrap; gap: var(--space-2xs) var(--space-sm); align-items: baseline;
}
.nudge-link { font-weight: var(--weight-medium); }

/*
 * THE ORDER FORM (M007 Task 3).  depends: .order-form-section
 *
 * `.order-actions`, `.order-copy`, `.order-copy-address`, `.order-email-plain`,
 * `.order-text` and `.copy-status` are all DELETED. They dressed the mailto:
 * button and the copy-your-order-yourself block beneath it, both of which the
 * owner has ruled out.
 *
 * `.order-support` SURVIVES, and it is the only one that does, because it is
 * the only one that was carrying words rather than a mechanism: the owner's
 * "Send your order and Nick will reply with payment options" line is still on
 * the page and still his.
 *
 * The section is a panel rather than bare fields because it is the last thing
 * on the page and the first thing that is asking for something — a name, an
 * address — where everything above it was showing. The tint is the same one
 * every other panel on this site uses; there are no shadows here by design.
 */
.order-form-section {
  margin-top: var(--space-3xl);
  padding: var(--space-lg);
  background: var(--color-surface-sunken);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
}
.order-form-section > h2 { margin-top: 0; }
.order-honesty {
  margin: 0; max-width: var(--width-measure);
  font-size: var(--text-sm); color: var(--color-text);
}
/* The form's own max-width comes from .site-form. Left to itself the order
   textarea would be the widest thing on the page and the labels above it the
   narrowest, which reads as two different forms. */
.order-form textarea { font-family: inherit; }
/*
 * The cart, as it will be sent. Monospaced because it is a COLUMN — the amounts
 * are right-aligned into one — and a proportional face throws that alignment
 * away the moment a price has a 1 in it.
 * depends: --text-base
 *
 * ⚠ 16px IS A FLOOR HERE, NOT A PREFERENCE. THIS FIELD MUST NEVER GO BELOW IT.
 *
 * It was --text-xs (13.6px) until M012. iOS Safari zooms the whole viewport when
 * a text-entry field under 16px receives focus, and this is the REQUIRED field
 * carrying the entire order, on the one path the business receives money
 * through. A customer tapping it got the page yanked to a zoomed state mid
 * checkout — and with scripting off it is the field they type their whole order
 * into by hand, so that is the path where it hurt most.
 *
 * WHAT IT COSTS, stated rather than discovered later: monospace at 16px fits
 * about 29 characters in the 308px field against about 34 at 13.6px. The field
 * is `white-space: pre; overflow-x: auto`, so long lines scrolled sideways
 * before and still do — five characters fewer before that starts. The alignment
 * the monospace exists for is unaffected, because every line shifts equally.
 *
 * Raising the field also raises the section, and the numbers are measured at
 * 390 with a real mixed cart: the field goes 222px -> 258px, the order section
 * 1,382px -> 1,418px, and /cart/ 2,999px -> 3,035px. With scripting off, where
 * the section is shorter because the gift message stays closed, 1,090px ->
 * 1,126px. Thirty-six pixels, on every path. That is the price of the fix and
 * it was accepted.
 */
.order-form [data-order-field] {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--text-base); line-height: var(--leading-tight);
  white-space: pre; overflow-x: auto;
}
.order-support { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); max-width: var(--width-measure); }

@media (max-width: 560px) {
  .cart-table thead { display: none; }
  .cart-table tr {
    display: grid; gap: var(--space-3xs); padding: var(--space-xs) 0;
    border-bottom: var(--border-width) solid var(--color-border);
  }
  .cart-table th, .cart-table td { border: 0; padding: var(--space-3xs) 0; }
  .cart-table td[data-label]::before {
    content: attr(data-label) ': '; color: var(--color-text-muted); font-size: var(--text-2xs);
  }
  .cart-summary { justify-items: stretch; text-align: left; }
  .total-row, .cart-total-line { min-width: 0; }
  /* The order button goes full width on a phone for the same reason the cart's
     old one did: it is the page's single action and a 154px control adrift in a
     350px column reads as secondary. The rules that used to do this named
     .order-actions, which is deleted. */
  .order-form .field-actions .button { width: 100%; }
  /* The section's padding is a whole gutter's worth on a 390px screen, so it is
     halved rather than letting the panel eat the measure. */
  .order-form-section { padding: var(--space-md); }
}

/* --------------------------- restored blocks ----------------------------- */

.block-cta { margin: var(--space-md) 0 var(--space-2xl); }

/*
 * The testimonial panel is capped by its own CONTENTS, not by the column (D6).
 *
 * The quote was the last body text on the site still running long: 702px and
 * 89 characters at 1440, because the measure cap deliberately does not reach
 * inside this panel. Capping the paragraph alone would leave a 544px line of
 * type ragged inside a 750px tinted box, which reads as a bug. Capping the
 * panel to the measure plus its own padding puts the quote at exactly the
 * measure and keeps the box fitted to it.
 */
.testimonial {
  margin: var(--space-2xl) 0; padding: var(--space-lg);
  max-width: calc(var(--width-measure) + var(--space-lg) + var(--space-lg));
  background: var(--color-surface-sunken); border-radius: var(--radius-md);
}
.testimonial blockquote { margin: 0; }
.testimonial blockquote p { margin: 0; font-size: var(--text-lg); color: var(--color-text-strong); }
.testimonial figcaption {
  display: flex; align-items: center; gap: var(--space-xs); margin-top: var(--space-sm);
}
.testimonial-photo, .testimonial figcaption picture img {
  width: 48px; height: 48px; border-radius: var(--radius-circle); object-fit: cover;
}
.testimonial-author { font-weight: var(--weight-medium); color: var(--color-text-strong); }

.related-posts {
  margin-top: var(--space-4xl);
  border-top: var(--border-width) solid var(--color-border);
  padding-top: var(--space-sm);
}

/* ------------------------------ homepage -------------------------------- */

/*
 * M002-C. The homepage was 7,866px tall at 1440 and had no hierarchy above the
 * fold: an h1, one line, a small button, and then four portrait photographs
 * rendered a metre tall each in a 750px column. The three category images the
 * owner asked to see "stacked side by side" were stacked vertically.
 *
 * Nothing here changes what the page says. The sections below are the sections
 * the captured content already had; the flat block list simply could not
 * express them. Every rule is built from the existing tokens except the three
 * added for this milestone (--text-hero, --space-6xl, --space-7xl), each
 * justified where it is declared.
 *
 * THE PAGE STAYS FLAT (D5). No shadow, no gradient, no glass. Separation is
 * carried by space and by the tint surface, which is the system this site
 * already has — decoration is where cheapness comes from.
 */

/* The homepage manages its own vertical rhythm, band by band, so main's uniform
   padding would only add an unexplained gap above the first heading. */
main:has(> .home) { padding-block: 0; }

/* Section rhythm scales with the viewport. 96px between bands is generous on a
   1440px desktop and merely wasteful on a 390px phone, where the same gap is a
   quarter of the screen and the reader is already scrolling. */
.home-section, .home-intro, .home-review { padding-block: var(--space-5xl); }
@media (min-width: 768px) {
  .home-section, .home-intro, .home-review { padding-block: var(--space-7xl); }
}

/*
 * EVERY FULL-BLEED BREAK-OUT ON THIS PAGE MEASURES ITSELF AGAINST THIS BOX,
 * AND THAT IS THE WHOLE OF M004-K PART 1.
 *
 * The homepage scrolled sideways by 8px at every desktop width. Two elements
 * broke out of the centred container with the standard `width: 100vw;
 * margin-left: calc(50% - 50vw)` idiom, and that idiom is wrong on any page
 * tall enough to have a vertical scrollbar: `100vw` is the VISUAL viewport and
 * includes the classic scrollbar, while the layout every other box is measured
 * against is `documentElement.clientWidth`, which does not. On a 1440px window
 * with a 15px scrollbar the break-out is 1440px wide inside a 1425px layout,
 * so it hangs 7.5px past the right edge and the page grows a horizontal
 * scrollbar to reach content that is only there because of the vertical one.
 *
 * `overflow-x: hidden` on a wrapper hides the scrollbar and leaves the
 * overflow, so it is not a fix; it is the defect with the evidence removed.
 *
 * The section is already exactly the width the layout has — no horizontal
 * padding, no max-width — so making it a query container gives every
 * descendant a unit that means "the layout viewport" rather than "the window".
 * `100cqi` is `100vw` minus the scrollbar, which is the number the break-outs
 * meant all along. Nothing else about either break-out changes: the geometry
 * is identical at every width where no scrollbar exists, which is why the
 * before/after measurements move by exactly the scrollbar and by nothing else.
 *
 * `container-type: inline-size` also applies layout containment. That costs
 * nothing here: the section already establishes its own block formatting
 * context through `padding-block`, and the only absolutely positioned
 * descendants on this page (the brand photograph, the hero) sit inside their
 * own `position: relative` parents.
 */
.home-section { container-type: inline-size; container-name: bleed; }

/*
 * ABOVE THE FOLD.
 *
 * Centred and narrow: three elements, nothing beside them, and the top of the
 * category band visible underneath at 1440x900 so the photography is part of
 * the first impression rather than a scroll away.
 */
.home-intro { padding-bottom: var(--space-3xl); text-align: center; }
.home-intro-inner { max-width: var(--width-prose); }
.home-title { font-size: var(--text-hero); letter-spacing: var(--tracking-tight); margin: 0; }
.home-lead {
  font-size: var(--text-2xl); color: var(--color-text-muted);
  max-width: var(--width-measure); margin: var(--space-md) auto 0;
}
.home-intro-cta { margin: var(--space-2xl) 0 0; }

/* ---------------------------------------------------------------------------
   THE HERO PHOTOGRAPH (M004-A Part 1)
   ---------------------------------------------------------------------------
   The first screen was three elements on flat bone. It was correct and it was
   bland: nothing above the fold said what this business makes.

   THE SCRIM IS A SCOPED EXCEPTION TO D5, AND IT IS THE ONLY ONE. D5 says this
   site is flat — no shadow, no gradient, no glass — and that ruling stands
   everywhere else including on this same page. What D5 is about is DECORATION:
   depth invented to make a component look expensive. A scrim is legibility.
   Bone type on a photograph is unreadable without one, and the alternatives
   are worse: a text-shadow is decoration applied to the type itself, and a
   solid panel behind the words is a box sitting on a photograph.

   It is a FLAT wash and not a gradient, deliberately. A flat wash has exactly
   one number in it, and that number can be checked against the real pixels —
   which R39 does, over every sampled cell of the generated derivative. A
   gradient would pass in the band it was tuned for and fail quietly at a
   viewport nobody screenshotted.

   The photograph is a real <img> in a <picture>, not a background-image: a
   background cannot carry a srcset, so it would ship one width to every device
   and could not be preloaded — and this is the page's LCP element. */
.home-hero {
  position: relative;
  isolation: isolate;
  color: var(--color-text-inverse);
  /*
   * THE GROUND UNDER THE PHOTOGRAPH, and it is not decoration.
   *
   * Without it the hero's only opaque ancestor is the bone body, so if the
   * image fails — a blocked request, a slow connection, a format nothing in
   * the chain can decode — the headline is bone type on a bone page and the
   * first screen of the site is blank. It also gives the two contrast tools an
   * honest answer: tools/audit-design.mjs resolves an element's effective
   * background through its ancestors and was reporting this pair as 1.00:1,
   * which was a true statement about the CSS and a false one about the page.
   * Now it resolves to the charcoal, and R39 measures what actually happens
   * over the photograph.
   */
  background: var(--color-surface-inverse);
  /* Enough height that the photograph reads as one, at every width. Below this
     the three elements set the height themselves and the picture becomes a
     strip. A structural dimension, like the 44px target — not a scale step. */
  min-height: 30rem;
  display: grid;
  align-content: center;
  /*
   * 🔴 M024 TASK 2 — THE PADDING COMES DOWN, ON THE OWNER'S INSTRUCTION.
   * "Reduce the top and bottom margin to make it fit on one page without
   * scrolling." This is that reduction: 96px/40px above 768 and 56px/40px
   * below it both become 40px/40px, one rung, --space-3xl.
   *
   * ⚠ A CORRECTION TO THE INSTRUCTION'S PREMISE, FROM MEASUREMENT, AND IT IS
   * WORTH STATING BECAUSE IT CHANGES WHAT THE FIX IS. The padding is NOT what
   * made the hero overflow the screen. The box was 960px tall because
   * `min-height` said 60rem; its padding was 136px of that, and with
   * `align-content: center` the padding does not even set the height — it only
   * bounds the box the content is centred inside. Removing every pixel of it
   * would have left a 960px hero and the owner would still have been
   * scrolling. THE OVERFLOW IS THE min-height BELOW, AND THAT IS WHAT FIXES IT.
   *
   * SO WHY DO IT AT ALL, BEYOND HIS HAVING ASKED? Because it buys 56px of
   * FLOOR. The floor has to clear the content plus the padding, so cutting
   * 136px of padding to 80px lowers the shortest the hero may ever be from
   * 452px to 396px — which is exactly what lets the hero shrink further on a
   * short screen without clipping the h1. It is a real 56px and it is spent on
   * the thing he asked for.
   */
  padding-block: var(--space-3xl);
}
/*
 * 🔴 M023 TASK 3 — LENGTHEN THE HERO. OWNER RULING, 2026-09-03: "roughly double
 * its current length, or whatever length reads correctly against the hero's real
 * dimensions." 30rem -> 60rem, 480px -> 960px, from the 768px breakpoint up.
 *
 * A CORRECTION TO THE RULING'S PREMISE, from the repo. The ruling asks for the
 * photograph to be "extended down over the 'elevate your culinary experience'
 * section". THAT SECTION IS THIS ONE. "Elevate Your Culinary Experience" is the
 * homepage h1 and it is already inside `.home-hero`, over this photograph, with
 * the lead and the Browse button. There is no separate section to extend over;
 * what "extend down" means in this stylesheet is this one number.
 *
 * ⚠ IT IS NOT APPLIED AT 390 AND THAT IS A MEASURED CALL, NOT A TRIM. The hero
 * is object-fit: cover on a 2:1 photograph, so the box's HEIGHT sets how wide
 * the image has to be painted: a 390x960 box needs 1920 CSS px of image across,
 * of which 390 is visible. At DPR 2 that is 3840 device px served from the 800w
 * rung the `sizes="100vw"` attribute asks for — a 4.8x upscale, and a density of
 * 0.208 against 0.417 today. Doubling the phone hero would halve the sharpness
 * of the LCP element at the width most visitors arrive at, to buy height nobody
 * asked for on a phone. THE OWNER CAN RULE IT ONTO THE PHONE AND IT IS ONE LINE;
 * the number is here so the ruling can be made on it.
 *
 * WHAT IT COSTS WHERE IT IS APPLIED, reported rather than smoothed. Cover
 * density — file width over the device pixels the cover actually paints across,
 * which is the metric this change moves and NOT the width-only one M020 quoted:
 *
 *     390/DPR2   0.417 -> 0.417   unchanged (rule does not apply)
 *     390/DPR3   0.417 -> 0.417   unchanged (rule does not apply)
 *     768/DPR2   0.833 -> 0.417
 *     1440/DPR1  1.123 -> 0.833   ⚠ CROSSES BELOW 1:1 — this is the new item
 *     2560/DPR2  0.566 -> 0.566   unchanged: at 2560 the box is still wider
 *                                 than the cover needs, so width still governs
 *
 * THE ALTERNATIVE THE RULING ITSELF OFFERS IS 44.5rem, AND IT IS RECORDED
 * BECAUSE IT IS A REAL CHOICE: the photograph is 2:1, so at a 1425px container
 * its true uncropped height is 712.5px. At 44.5rem the hero shows the WHOLE
 * frame with nothing cropped off the sides and 1440/DPR1 stays at 1.123, above
 * 1:1. It is 1.48x rather than 2x. The owner asked for double first and offered
 * this second, so double is what shipped.
 *
 * `sizes="100vw"` UNDER-DESCRIBES A COVER BOX AND ALWAYS HAS — see
 * docs/history/M023-task3-hero.md. Not changed here: it would pull the 2880 rung
 * onto phones and trade LCP for sharpness, which is its own ruling.
 */
/*
 * 🔴 M024 TASK 2 — THE HERO FITS THE FIRST SCREEN. OWNER RULING, 2026-09-04,
 * AND IT REVERSES M023 TASK 3 IN EFFECT. His words: "I want this to be the full
 * browser page, but not to exceed the standard size. I have to scroll and the
 * image continues past the scroll."
 *
 * M023's 60rem is GONE. A fixed 960px hero is taller than a 900px laptop
 * viewport before the header is even counted — measured, header 69px plus hero
 * 960px is 1029px against 900px of screen — so the first screen of the site
 * could not be seen without scrolling at any desktop size the owner uses. That
 * ruling was made on a number and this one is made on the rendered page.
 *
 * 🔴 svh, NOT vh, AND THE FALLBACK IS THE POINT OF THE @supports BLOCK.
 * `100vh` on a mobile browser is the viewport with the address bar RETRACTED,
 * so a `100vh` hero is taller than the screen whenever the bar is showing —
 * which is exactly the "it continues past the scroll" complaint, and adopting
 * it here would have shipped the defect back under a different name. `100svh`
 * is the SMALL viewport: the screen with the browser chrome at its largest,
 * so the box fits whether the bar is out or not. The plain `vh` rule below is
 * the fallback for anything that does not know `svh`; it is written FIRST and
 * the `@supports` block overrides it, which is the ordinary cascade rather
 * than a hack. On a desktop the two are identical.
 *
 * 🔴 max() IS THE FLOOR AND IT IS NOT OPTIONAL. `calc(100svh - 69px)` on a
 * phone in landscape is 321px, and the h1, the lead and the Browse button
 * measure 316px between them: at 321px the copy would sit inside 241px of
 * padding box and be clipped. --hero-floor (416px) is derived in :root from
 * that measurement. Below the floor the hero stops shrinking and the page
 * scrolls, which is the correct trade — a hero you scroll past is a
 * inconvenience, a headline cut in half is a defect.
 *
 * ⚠ SCOPED TO 768px AND UP, LIKE M023's RULING WAS, AND FOR A MEASURED REASON
 * OF THE SAME KIND. --header-h describes the ONE-ROW header and the header is
 * two rows below 768. And the phone hero ALREADY FITS: 129px of header plus
 * 480px of hero is 609px inside a 844px screen. Growing it to fill the screen
 * would add 235px of height nobody asked for and, because the photograph is
 * `object-fit: cover` on a 2:1 frame, would need 1430 CSS px of image painted
 * across a 390px box — which is the trade M023 declined on this same line and
 * declines again here.
 *
 * WHAT IT COSTS AND WHAT IT BUYS, cover density, measured not predicted, and
 * PER DEVICE PIXEL — the basis M023 and tools/measure-images.mjs both quote:
 *
 *     390/DPR2   0.417 -> 0.417   unchanged (rule does not apply)
 *     390/DPR3   0.417 -> 0.417   unchanged (rule does not apply)
 *     768/DPR2   0.417 -> 0.419
 *     1440/DPR1  0.833 -> 0.963   recovers most of what M023 spent
 *     2560/DPR2  0.566 -> 0.525   ⚠ this one gets WORSE, and it is the one
 *                                 place the trade runs the other way. At 2560
 *                                 the OLD 960px hero was short enough that the
 *                                 box's WIDTH governed the cover and the height
 *                                 was free; a 1371px hero needs 2742 CSS px
 *                                 painted across against the 2880 rung, so
 *                                 height governs now.
 *
 * ⚠ THE FIRST VERSION OF THIS TABLE WAS WRONG BY EXACTLY THE DEVICE PIXEL
 * RATIO, and it is corrected here rather than quietly replaced. It divided the
 * file width by the CSS width and printed 0.833 / 1.250 / 0.838 / 0.963 /
 * 1.132 -> 1.050. Every row agreed with M023 to within rounding EXCEPT the four
 * at DPR 2 and 3, and the one it agreed on — 1440/DPR1 — is the only row in the
 * set at DPR 1. That was the tell, and it is the same class of defect as the
 * naturalWidth trap this repository has been caught by twice: a complete,
 * plausible table on the wrong basis.
 *
 * ⚠ 1440/DPR1 DOES NOT COME BACK ABOVE 1:1 AT 1440x900, AND THAT IS REPORTED
 * RATHER THAN SMOOTHED. Cover on a 2:1 photograph needs twice the box's height
 * painted across it, so 1:1 at the 1600w rung needs a hero no taller than
 * 800px; viewport-minus-header at 1440x900 is 831px, 31px over. At 1440x700 it
 * is 631px and the density is 1.123, back where it was before M023. The
 * remaining shortfall is the `sizes="100vw"` under-description of a cover box,
 * which is pre-existing, out of scope here, and recorded in
 * docs/history/M023-task3-hero.md.
 */
@media (min-width: 768px) {
  .home-hero { min-height: max(var(--hero-floor), calc(100vh - var(--header-h))); }
}
@supports (height: 100svh) {
  @media (min-width: 768px) {
    .home-hero { min-height: max(var(--hero-floor), calc(100svh - var(--header-h))); }
  }
}
.home-hero-media { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
/* The global `img { height: auto }` has to be overridden here: this image is
   sized by its box, not by its own aspect ratio. */
.home-hero-media picture,
.home-hero-media .home-hero-img { width: 100%; height: 100%; }
.home-hero-media .home-hero-img { object-fit: cover; }
.home-hero-media::after {
  content: '';
  position: absolute; inset: 0;
  background: var(--color-hero-scrim);
}
/*
 * BOTH LINES ARE FULL-STRENGTH BONE, and the lead does NOT take the muted ink.
 *
 * --color-text-inverse-muted is a 4.5:1 ink measured against the charcoal bar.
 * Over the scrim the effective ground is lighter than that bar wherever the
 * photograph is bright, so the muted ink lands below AA — reaching it would
 * need a scrim near 93%, which is a charcoal panel with a faint texture behind
 * it. The lead is quieter here because it is a third of the size, which is a
 * hierarchy the photograph cannot erode.
 */
.home-hero .home-title,
.home-hero .home-lead { color: var(--color-text-inverse); }

/*
 * THE FOCUS RING ON A CONTROL THAT SITS ON A PHOTOGRAPH.
 * depends: --color-text-inverse
 *
 * THE PROBLEM. `--color-focus` was derived in M003-A as the lightness whose
 * WORST ratio across bone, ash and charcoal was best — three FLAT grounds. The
 * hero call to action sits on none of them: `outline-offset: 2px` draws the ring
 * 2px outside the button, which lands it on the PHOTOGRAPH. Measured from
 * screenshot pixels, 360 samples around the ring perimeter: worst 1.93:1 at
 * 1440, and 65% of the perimeter below the 3:1 WCAG 1.4.11 threshold. A
 * keyboard user tabbing the primary route into the shop could not see where
 * they were.
 *
 * WHY NO BUILD RULE CAUGHT IT. R28 compares foreground/background TOKEN pairs.
 * A photograph is not a token, so R28 has no opinion about it — and it never
 * will have, because the pixels are in a JPEG the validator does not decode.
 *
 * THE FIX MOVES THE RING OFF THE PHOTOGRAPH RATHER THAN RECOLOURING IT AGAINST
 * ONE. `outline-offset: -3px` draws the ring INSIDE the button's own ember fill,
 * so the pair becomes `--color-text-inverse` on `--color-accent` — which is
 * already in R28's AA_PAIRS at 4.5:1 and already passing, because it is the
 * button's own label colour. That turns an unmeasurable problem into a measured
 * one: the ratio no longer depends on which rung `srcset` picked, on where the
 * chiles happen to be bright, or on the viewport width.
 *
 * WHY NOT THE ALTERNATIVES. A second contrasting ring means `box-shadow`, and
 * this stylesheet has none by design (M002-B: "stays flat", recorded in :root so
 * no future pass adds one by reflex). A lighter `--color-focus` would still be
 * measured against a photograph and would drag every other ring on the site with
 * it. A solid plate behind the button changes the hero's design to fix a focus
 * state. No new token was needed and none was invented.
 *
 * MEASURED AFTER, from screenshot pixels, 184 samples along the straight runs of
 * the ring at each width (the corner arcs are excluded: at a 10px radius the
 * ring antialiases into the fill and a blend pixel reads as a failure no eye
 * perceives). Every rung the hero can serve:
 *
 *   width   rung                    ring vs fill    ring vs photograph
 *   390     hero-chiles-400.avif    5.01:1          8.96:1
 *   768     hero-chiles-800.avif    5.01:1          9.40:1
 *   1440    hero-chiles-1600.avif   5.01:1          8.02:1
 *   2560    hero-chiles-2880.avif   5.01:1          8.70:1
 *
 * 0 of 184 samples below 3:1 at any width, on either edge. Before: worst 1.93:1
 * with 65% of the perimeter under 3:1 at 1440. THE 5.01 BEING IDENTICAL AT EVERY
 * WIDTH IS THE POINT, not a suspicious column — it is a token pair now, so the
 * photograph cannot move it.
 *
 * IF ANOTHER CONTROL IS EVER PLACED ON A PHOTOGRAPH, it needs this treatment
 * too — today the hero button is the only one on all 42 pages, verified by
 * walking every control's ancestors for a background-image and every control's
 * box for an overlapping <picture>.
 */
.home-hero .button:focus-visible {
  outline: 3px solid var(--color-text-inverse);
  outline-offset: -3px;
}

.home-section-head { max-width: var(--width-prose); text-align: center; margin-bottom: var(--space-3xl); }
.home-section-head h2 { margin-top: 0; }
.home-section-lead { margin: 0 auto; max-width: var(--width-measure); color: var(--color-text-muted); }

/*
 * THE CATEGORY BAND — the three images side by side, full-bleed.
 *
 * Full width of the WINDOW, not of the container: this is the one place on the
 * site where the photography is the message, and a 1200px box around it makes
 * it a component instead. A full-layout width plus the negative half-margin is
 * the standard break-out; it is not a hack, it is the only way to escape a
 * centred container without moving the container. It is measured in `cqi`
 * rather than `vw` — see `.home-section` for why the window is the wrong ruler.
 *
 * 4/3 with object-fit: cover, because the two real sources are 3:2 camera
 * files — a portrait crop would throw away most of the frame to satisfy a
 * layout preference. See the M002-C report for the third source, which is a
 * 728px screenshot and is the honest ceiling on "HD" here.
 */
.band {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: var(--space-lg);
}
/* The break-out. The side padding belongs to this rule and not to .band,
   because a band that sits inside the container already has the container's
   gutter.

   Measured against `bleed` rather than the window — see the note beside
   `.home-section`. This band is already a direct child of the section, so the
   margin resolves to exactly 0 and the declaration is a statement of intent
   rather than a correction; it stays because the intent is "as wide as the
   layout allows", and that has to keep being true if the markup around it
   ever gains a container. */
.home-section-hero .band {
  width: 100cqi; margin-left: calc(50% - 50cqi);
  padding-inline: var(--space-lg);
}
/* CENTRED (M004-A Part 4.1). Each card is a photograph, a two-word title and
   one short line — short text, which is what the centring pass is scoped to.

   M004-K: THE SCOPE IS NO LONGER "SHORT TEXT". M004-A deliberately left the
   card grids that carry a price and a control — .featured-card and the shop's
   .product-card — left-aligned, on the reasoning that a card with a price and
   a button is a control surface rather than a caption. The owner has ruled the
   other way: every card on the site centres its name and its price. Which is
   the better call is not the interesting part; what matters is that a card is
   now centred everywhere or nowhere, because one straggler is far more
   noticeable than none of them being centred. See .featured-card and
   .product-card, both of which also had to grow a shared baseline so a title
   that wraps cannot knock the row out of line. */
.band-item { text-align: center; }
.band-media { margin: 0; }
.band-media picture img {
  width: 100%; aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--radius-md);
}
.band-title { margin: var(--space-md) 0 var(--space-2xs); }
.band-text { margin: 0; color: var(--color-text-muted); font-size: var(--text-md); }

/*
 * A container-bound two-up band, kept for the general case although M003-C
 * removed the only one the site had ("Why Choose Mitten Made Spice?", which
 * duplicated the brand copy). Not full-bleed: giving every band the same weight
 * would mean none of them has any. It is also WIDER per tile than the three-up
 * — two columns of a 1200px container is 588px against the category band's
 * 448px — so at the same 4:3 it rendered taller than the band it sits beneath,
 * and the supporting section visually outranked the product categories. 16/9
 * puts it just under them at every width.
 */
.band-pair .band-media picture img { aspect-ratio: 16 / 9; }

@media (min-width: 768px) {
  .band { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .band-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/*
 * THE COMPANY. Copy beside its photograph rather than above it — the captured
 * paragraph is 550 characters, and a column of that length under a full-width
 * portrait is what made this stretch of the old page read as two unrelated
 * things.
 */
.home-split { display: grid; gap: var(--space-2xl); align-items: center; }
@media (min-width: 768px) {
  .home-split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-4xl); }
}
.home-split-copy h2 { margin-top: 0; }
.home-split-media { margin: 0; }
.home-split-media picture img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
/* The rounded corner belongs to the CONTAINED treatment, not to every split
   figure. The brand block's photograph is full-bleed (M004-B) and a radius on
   an image running off the edge of the viewport draws a corner in mid-air.
   Scoped here rather than reset to 0 in the brand rules, because R27 requires
   every radius to be a token and "0" is not one. */
.home-split:not(.home-brand) .home-split-media picture img { border-radius: var(--radius-md); }
/*
 * `margin-inline: auto` IS THE WHOLE FIX (M004-B).
 *
 * This rule capped itself at 750px inside the 1200px centred .container and
 * never centred itself, so every imageless prose section sat flush left —
 * 225px left of centre at any viewport wide enough to reach the cap. The
 * signup was the visible symptom; the cause was never about the signup.
 *
 * WHAT WAS DELETED WITH IT. M004-A had centred the signup's CHILDREN inside
 * this off-centre box (.site-form, .owner-fill and .home-text each given their
 * own margin-inline: auto under .home-section-tint). That is why the block
 * read as half-corrected rather than plainly broken, and it is why the defect
 * survived a milestone: a nudge that makes a symptom look nearly right removes
 * the evidence that anything is wrong. Those three rules are gone. One
 * declaration here does what all three were approximating, on all four pages
 * that carry this call to action rather than only on the tinted one.
 */
.home-narrow { max-width: var(--width-prose); margin-inline: auto; }
.home-narrow h2 { margin-top: 0; }
.home-text { max-width: var(--width-measure); }
.home-cta { margin: var(--space-lg) 0 0; }

/*
 * THE BRAND BLOCK (M003-C) — the owner's four lines beside the lids.
 *
 * FOUR BEATS. The lines are four statements, not a paragraph and not a list,
 * and the only thing separating them is space. That is the whole design: no
 * bullet, no icon, no rule, no card. --space-2xl between them is larger than
 * the gap between any two paragraphs elsewhere on the site, which is what makes
 * them read as four things rather than one block set on four lines.
 *
 * They are set at --text-2xl in the heading face because they ARE the section:
 * the heading above them is visually hidden by the owner's ruling, so if these
 * were body copy the section would have no voice at all. --width-measure still
 * caps them; three of the four are short enough that it never binds, and the
 * one that is not is the one that most needs a controlled line length.
 *
 * THE ASPECT RATIO IS THE LAYOUT ADAPTING, NOT THE CROP (Part 5.4). Every other
 * .home-split-media is forced to 4/5 and cropped by object-fit, which is right
 * when the photograph is a portrait the layout is trimming to fit a column. It
 * is wrong here: this image has ALREADY been cropped, deliberately, to a region
 * the owner chose, and a second crop by the stylesheet would cut the lids off
 * both sides of a frame that was framed on purpose. `aspect-ratio: auto` lets
 * the declared region be exactly what is shown.
 */
/* ---------------------------------------------------------------------------
   M004-A Part 2 — the same four lines, set properly.
   ---------------------------------------------------------------------------
   The owner's verdict was "plain text on white on white section without
   anything to break it up is horrible", and every part of that was true: four
   same-sized paragraphs, on the same bone ground as the sections either side,
   with no title, no weight and paragraph-sized gaps between them.

   Not one word changed. What changed is the setting. */

/*
 * WARM ASH (M004-B), and .home-section-dark is GONE.
 *
 * M004-A made this the site's second charcoal band, and that did solve what
 * the owner complained about — four paragraphs floating on white with nothing
 * containing them. What it could not know is that the hero above would stay a
 * dark photograph. Two near-black bands in the top half of one page is heavy,
 * and it flattens the very rhythm the alternation was introduced to create.
 *
 * Ash keeps every bit of the containment — the band still has edges, and the
 * edges still do the work, with no rule and no shadow — and gives the beat
 * back: dark hero, light tiles, WARM BAND, light products.
 *
 * The section now simply uses .home-section-tint, the same token as every
 * other tinted band on the site. No new surface, no new pair for R28, and one
 * fewer class than before.
 */

/* THE COLUMN IS WIDER THAN THE PHOTOGRAPH'S (2.3). Half and half put the
   claims at ~26 characters, which is where "More aromatic. More fragrant. /
   More potent." came from. 1.2fr against 0.8fr gives the copy ~180px more and
   the lids lose nothing that matters — they are a texture, not a subject with
   an edge to protect. */
@media (min-width: 768px) {
  .home-brand.home-split { grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); }
}

/* CENTRED (Part 4.1). The four statements are short, they are the section's
   whole voice, and centring is what makes them read as assertions rather than
   as a paragraph that happens to have gaps in it. It is the one place on this
   page where centred text sits in a column beside something else; it works
   because the beats below mean no line is ever ragged by accident. */
.home-brand .home-split-copy { text-align: center; }

/* .brand-title is GONE with the heading it styled — see the note in home.njk.
   The section opens straight on the first claim. */

/*
 * M015: `.home-brand .brand-lines { max-width: none; }` IS DELETED. The
 * `.brand-lines` wrapper no longer exists in any template — the claims band
 * that replaced it uses `.brand-rows`, one row per line. A rule for markup
 * nobody renders is the stale-artifact defect in its purest form, and it is
 * removed here rather than left "in case", which is the argument R28 already
 * makes about a pair describing text that no longer exists.
 */

/* AIR (2.5, retuned by M004-K Part 3b).
 *
 * M004-A set this to --space-6xl, a SECTION rung, so that four assertions read
 * as four separate things that happen to be in one place. With four lines and
 * a section rung between them the band measured 853px — taller than the hero —
 * and the owner's word for it was "abnormally large". He is right, and the
 * reason is that the spacing was solving a problem the copy had already
 * stopped having: a section rung between statements is what you need when the
 * statements might be mistaken for a paragraph, and three weighted claims with
 * an evidence line under each cannot be mistaken for one.
 *
 * --space-3xl still separates them decisively — it is 40px against the 12px
 * that joins a claim to its own evidence, so the grouping is unambiguous at a
 * glance — and the band now reads as one statement rather than as something to
 * scroll through. Deleting the lead line (Part 3a) did the rest.
 *
 * The SECTION rung is untouched. Every band on this page is padded on the same
 * --space-7xl, and taking it off one of them to make that one shorter would
 * buy 64px by breaking the rhythm the alternation exists to create. */
.home-claims .brand-line { margin: 0 0 var(--space-3xl); }
.home-claims .brand-line:last-child { margin-bottom: 0; }

/* WEIGHT (2.4). The claim, then its evidence, quieter and beneath — one
   statement at two weights, not a heading with a subline. */
.home-claims .brand-claim {
  display: block;
  font-family: var(--font-heading);
  font-size: var(--text-brand-claim);
  font-weight: var(--weight-medium);
  line-height: var(--leading-heading);
  letter-spacing: var(--tracking-tight);
  color: var(--color-text-strong);
}
/*
 * THE EVIDENCE INK WAS RE-DERIVED ON ASH, NOT CARRIED ACROSS (M004-B Part 4).
 *
 * It was --color-text-inverse-muted, which is a 4.50:1 ink measured against
 * CHARCOAL and named for that ground. On ash it measures 3.39:1 — a WCAG AA
 * failure, and precisely the trap of moving a surface without re-deriving the
 * inks on it. R28 would have caught it; it should not have needed to.
 *
 * Measured on #f6ece5, all four candidates:
 *
 *     --color-text-strong   #312a26   12.11:1
 *     --color-text          #4c4541    8.08:1
 *     --color-text-muted    #726b66    4.50:1   <- chosen
 *     --color-text-inverse-muted #867f7a  3.39:1   FAILS
 *
 * --color-text-muted is the answer and NO FIFTH GREY WAS INVENTED. The brief
 * warned that deriving a new ink at 4.5 would land on top of text-muted and
 * collapse a level of hierarchy; that is true, and the conclusion is to USE
 * text-muted rather than to mint a duplicate of it. It is the site's muted
 * supporting-text role, it is already an R28-validated pair on this exact
 * ground, and R27 exists to stop the forty-third slightly-different grey.
 *
 * HIERARCHY SURVIVES WITHOUT A FAILING COLOUR, and the colour is not carrying
 * it alone: the claim is 25.6-41.6px of the HEADING face at medium weight and
 * 12.11:1, the evidence is 18.4px of the body face at regular and 4.50:1.
 * That is a 2.7x separation in contrast on top of a change of size, weight and
 * typeface — far more distinction than the charcoal version had, where the two
 * sat at 16.92 and 4.50 in the same relationship.
 *
 * The one thing worth watching: 4.50:1 clears AA with no margin at all. It is
 * safe because both values are tokens and neither moves without R28 re-running,
 * but the ash surface is now load-bearing for legibility and cannot be
 * "warmed up" a shade without re-deriving this.
 *
 * M005 FOOTNOTE, so nobody hunts for a token that is gone: the candidate
 * table above still lists --color-text-inverse-muted, and that token NO
 * LONGER EXISTS in :root. Task 6 removed the deals band's pack-breakdown
 * line, which was the only text it ever painted, so the token and its
 * AA_PAIRS entry went with it (see the note there in validate.mjs). Nothing
 * in the reasoning above changes — it was listed here as a REJECTED
 * candidate and it is still the right rejection; #867f7a measured 3.39:1 on
 * ash then and would now. The value is kept in this comment precisely so the
 * comparison stays readable after the token's removal.
 *
 * ⚠ M017 CORRECTION — EVERYTHING ABOVE THIS LINE IS ABOUT A GROUND THIS BAND
 * NO LONGER SITS ON, and it is kept as dated history rather than rewritten.
 * M016 Task 6 moved the claims band from warm ash to BONE. Measured on the
 * built page, the band's effective background is rgb(252, 249, 247)
 * (--color-surface), not #f6ece5. So the 4.50:1 figure derived above is not
 * this band's number and has not been since M016 — on bone the same ink
 * measures 5.00:1. The 4.5003:1 ash pair still exists and is still pinned, but
 * it is carried by the footer tagline and the empty-media labels, NOT by these
 * evidence lines. THIS TASK DOES NOT TOUCH IT.
 *
 * 🔴 M017 Task 4 — OWNER RULING 2026-08-31: the band reads bland. Fix the LOOK,
 * keep the writing. NOT ONE CHARACTER OF THE COPY CHANGED.
 *
 * MEASURED FIRST. The subline was 18.4px of the body face at weight 400 in
 * --color-text-muted, against a claim of up to 41.6px of the heading face at
 * medium weight in --color-text-strong. That is a 2.26x drop in size, a drop in
 * weight AND a drop from 13.45:1 to 5.00:1 all at once, which is what made it
 * read as a caption rather than as the supporting half of one statement.
 *
 * THE MINIMUM THAT FIXED THE MEASURED DEFECT, and nothing beyond it: one step
 * up in size, one step up to a weight that is a REAL cut of the variable Inter,
 * and one step darker to an ink ALREADY PINNED BY R28 — no new token, no new
 * grey, no ember, no ground, no case change, no tracking change.
 *
 *     --color-text (#4c4541) on bone measures 8.96:1, against 4.5:1 required.
 *     It is AA_PAIRS' 'body copy on the page' entry, so nothing new is pinned
 *     and R28's all-or-nothing does not come into it.
 *
 * The hierarchy still separates cleanly: the claim is the HEADING face at
 * medium and 13.45:1, the evidence is the BODY face one size down at 8.96:1.
 * Typeface, size and weight still carry it; the contrast gap is simply no
 * longer doing the work of making the line hard to read.
 *
 * ⚠ THE VERTICAL VOID IS NOT FIXED HERE AND CANNOT BE — see the note on
 * .brand-row. It is a figure-height problem, not a type problem.
 *
 * depends: --color-surface
 * depends: --color-text
 */
/* M023 Task 2: --text-2xl -> --text-xl, one step down, on the owner's ruling to
   reduce the text size. M017's spacing fix, weight and INK are untouched — the
   claim/evidence gap is still --space-sm and the ink is still --color-text at
   8.96:1 on bone, so R28 is not involved: this changes size, not contrast. */
.home-claims .brand-evidence {
  display: block;
  margin-top: var(--space-sm);
  font-size: var(--text-xl);
  font-weight: var(--weight-medium);
  line-height: var(--leading-tight);
  color: var(--color-text);
}

/* BREAKS (2.3). A sentence is an inline-block, so it cannot be split across
   lines; the whitespace between two of them is the only place a line may wrap.
   Nothing is hard-coded — no <br>, no &nbsp;, no width tuned to one viewport.
   See the beatHtml filter in eleventy.config.js, which renders a ONE-sentence
   line plainly for the reason that matters here: an unbreakable 60-character
   inline-block would overflow every phone on the market. */
.home-claims .beat { display: inline-block; }

/* The slack above "Learn More" was --space-5xl, which was more air than sits
   between two of the statements themselves after Part 3b. It is a link at the
   end of the block, not a fourth claim. */
.home-brand .home-cta { margin-top: var(--space-3xl); }

/*
 * THE LIDS RUN OFF THE RIGHT EDGE (M004-B Part 3b), AND THE BAND HAS NO DEAD
 * LOWER THIRD (Part 3a).
 *
 * Both defects had one cause. The photograph was `object-fit: contain` with
 * `aspect-ratio: auto`, so it kept its own natural height inside a column that
 * was as tall as the copy beside it — and .home-split centres its items, so
 * the leftover height collected UNDERNEATH the picture as a void. Add a
 * rounded corner and a gutter and it read as an island floating in the band,
 * which is the same complaint the owner made about the white version, just on
 * a different ground.
 *
 * So the media column now stretches to the band's height and the photograph
 * covers it. There is no leftover height to collect anywhere, at any
 * breakpoint, because the picture is exactly as tall as its column.
 *
 * THE BREAK-OUT, AND WHY IT IS ON THE GRID RATHER THAN ON THE PICTURE.
 *
 * The grid spans the full layout width and the COPY is padded back in by
 * exactly the container's own gutter, so the text still starts on the site's
 * left margin and the photograph ends on the viewport's right edge.
 *
 * The alternative — leaving the grid in the container and pulling only the
 * picture rightwards with a negative margin — renders identically and was
 * rejected for what it does to `sizes`. There the media track is a fraction of
 * the CONTAINER plus a gutter that stops growing at 1240px, so its width is
 * two different expressions either side of a number that is not one of this
 * site's breakpoints, and D4 spent a milestone removing invented breakpoints
 * from `sizes` attributes. On a full-bleed grid the track is a fixed fraction
 * of the layout at every width: 0.4 x (100% - 48px gap) = calc(40vw - 19px),
 * one expression, exact everywhere above 768px, and R23 re-derives it from
 * that attribute and checks it against the policy.
 *
 * M004-K narrowed the grid by the width of the vertical scrollbar (it is
 * `cqi` now, not `vw`), so the real track is up to ~6px narrower than the
 * `sizes` attribute claims. That is the safe direction and the attribute is
 * left alone: `sizes` is resolved against the viewport by the browser too, so
 * a slot declared very slightly large can only ever ask for a rung at or above
 * the one it needs. Nothing here can make an image soft.
 *
 * The copy column is ~50px wider than it was, which only makes the claims
 * wrap later; the beats mean a line can still only break between sentences.
 *
 * Below 768px the layout is one column and there is nothing to bleed into, so
 * all of it is scoped to the wide breakpoint and the mobile figure stays an
 * ordinary full-width image.
 */
/*
 * MOBILE KEEPS AN ORDINARY FIGURE. Below the wide breakpoint there is one
 * column, nothing to bleed into, and no second column to match heights with,
 * so the photograph is simply a 4:5 image the width of the screen. The crop is
 * 4:5, so object-fit has nothing to discard here.
 */
.home-brand .home-split-media picture { display: block; }
.home-brand .home-split-media picture img { aspect-ratio: 4 / 5; object-fit: cover; }

/*
 * M005 TASK 4 — THE PHOTOGRAPH COMES OFF THE VIEWPORT EDGE. OWNER RULING.
 *
 * The break-out is GONE. .home-brand.home-split is an ordinary grid inside
 * .container again, so the photograph respects the same left/right gutter as
 * every other section and the text column and the picture read as a balanced
 * pair. The owner asked for exactly that and rejected full bleed when it was
 * offered again.
 *
 * THIS SUPERSEDES M004-B PART 3b, whose long rationale is directly above and
 * is kept rather than rewritten: it argued the grid should span the full
 * layout width with the copy padded back in, so the text started on the site
 * margin and the photograph ended on the viewport edge. That was owner-
 * specified at the time. It is superseded, and it is NOT to be restored on
 * the grounds that the owner once asked for it.
 *
 * WHAT SURVIVES FROM THAT RATIONALE, because it was solving two different
 * problems and only one of them was the bleed:
 *
 *   - align-items: stretch STAYS. It is what makes the media column exactly
 *     as tall as the row, which is half of the fix for the dead lower third
 *     and for the 350x0 collapse. Nothing about it depended on the width.
 *   - The absolutely-positioned picture below STAYS, for the same reason.
 *     Together they are what keeps BAND HEIGHT DRIVEN BY THE COPY rather
 *     than by the photograph, which M004-K Part 3b required and which this
 *     change had to preserve: measured at 624.9 css px at 1200, 1440 and
 *     2560 before, and 624.9 at all three after. Not one pixel moved.
 *   - The `sizes` argument survives INVERTED and is worth reading in
 *     home.njk. M004-B chose a full-bleed grid because it made the media
 *     track one clean expression of the viewport at every width. Back inside
 *     the container the track is a fraction of a container that CAPS, so it
 *     is a constant above the cap and a fraction of the viewport below it.
 *     That is expressed with min() in one expression rather than by
 *     inventing a 1240px breakpoint, which is what D4 spent a milestone
 *     removing from this codebase.
 *
 * The copy column loses its padding compensation with the break-out it was
 * compensating for: .container already puts the copy on the site margin.
 * depends: --width-container
 */
@media (min-width: 768px) {
  .home-brand.home-split {
    align-items: stretch;
  }
  /*
   * THE PHOTOGRAPH FILLS ITS COLUMN AND DOES NOT SET THE BAND'S HEIGHT.
   *
   * The obvious version of this — height: 100% on the img — is circular and
   * was measured doing two wrong things. On desktop the image has an intrinsic
   * ratio, so at a 1005px-wide column it claimed 1256px of height and the BAND
   * grew to fit it, which is the picture deciding how tall the brand statement
   * is. On a phone, where nothing stretches, the same declaration resolved
   * against a parent with no definite height and the photograph collapsed to
   * ZERO — it was gone from the page entirely at 390px, and it looked fine in
   * the desktop screenshot.
   *
   * Taking the picture out of flow fixes both. The figure is a stretched grid
   * item so it is exactly as tall as the row; the row is now sized by the COPY
   * alone, which is what should decide the height of a band of copy; and the
   * image fills that box by covering it. No leftover height can collect
   * anywhere, which is Part 3a.
   */
  .home-brand .home-split-media { position: relative; overflow: hidden; }
  .home-brand .home-split-media picture { position: absolute; inset: 0; }
  .home-brand .home-split-media picture img {
    width: 100%; height: 100%;
    aspect-ratio: auto;
    object-fit: cover;
  }
}

/*
 * THE REVIEW — one quote, and it has to look deliberate (Part 5).
 *
 * There is exactly one genuine testimonial on this site. A three-up layout with
 * one filled slot reads as a site nobody has bought from, so this is not a
 * grid: it is a tinted band the full width of the page with a single quote set
 * large and centred in it. The same content that looked like a leftover in a
 * 750px column looks like the point when it is given the room.
 *
 * The quote is capped at the measure like all running text, but centred rather
 * than left-aligned — at this size and this length centring reads as a pull
 * quote, which is what it is.
 */
/*
 * THE REVIEW IS WARM ASH (M004-K Part 5, OWNER RULING), and the page's whole
 * sequence is here because this is the rule that decides it:
 *
 *     hero            dark photograph + charcoal scrim
 *     category tiles  bone
 *     brand copy      WARM ASH
 *     featured        bone
 *     review          WARM ASH        <- was bone (M004-B); ash again
 *     doors           bone            <- was ash (M004-B); bone again
 *
 * Strict alternation, every boundary the same size. M004-B had the review and
 * the doors the other way round and argued for it on two grounds: that the
 * customer's words should not sit on the same ground as the company's claims
 * about itself, and that a heading is a stronger break than a change of ground.
 * The first of those is a real point and the owner has weighed it against an
 * even beat down the page and chosen the beat. The second turns out to cost
 * nothing — the heading is still there, doing the same job, on ash.
 *
 * EVERY INK IN THIS BAND WAS RE-DERIVED RATHER THAN CARRIED ACROSS, because
 * this is the second time a section has changed ground on this page and the
 * FIRST time it happened a colour would have failed if it had ridden along:
 * --color-text-inverse-muted measures 3.39:1 on ash. Nothing here uses it. The
 * review sets --color-text-strong for the quote, the author and the heading,
 * which measures 12.11:1 on ash against 13.45:1 on bone, and the pair was
 * already in R28's AA_PAIRS because the brand block sits on the same ground.
 * No new pair, no new token, and no fifth grey.
 *
 * This rationale describes STATE — which band is on which ground — and state
 * is the kind that goes stale (M004-L §3d, convention 2). The order above is
 * only true while these declarations say so, so it names them.
 *
 * depends: .home-review
 * depends: --color-surface-sunken
 */
/*
 * M015 TASK 9: THE REVIEW IS BONE.
 *
 * The rationale above describes STATE, and this is the third time that state
 * has moved — which is what the M004-L convention warned about when it wrote
 * "depends:" onto it. The order it describes is superseded: the owner's M015
 * running order is hero / tiles / favorites / REVIEWS / claims / doors / about
 * link, and the favorites band immediately above this one is warm ash now. Two
 * warm-ash bands touching is the exact defect the alternation exists to
 * prevent, so the review takes the bone side of the alternation.
 *
 * WHAT IT COSTS IN INK, MEASURED RATHER THAN ASSUMED: the review sets
 * --color-text-strong for the quote, the author and the heading, which is
 * 13.45:1 on bone against 12.11:1 on ash — the move makes every one of them
 * MORE legible, not less, and the pair was already in R28's AA_PAIRS. No new
 * token, no new pair, no fifth grey.
 *
 * depends: .home-review
 * depends: --color-surface
 */
/*
 * M016 TASK 6: THE REVIEW RETURNS TO WARM ASH.
 *
 * The rationale above describes STATE, and this is the fourth time that state
 * has moved — which is exactly what the M004-L convention predicted when it
 * wrote "depends:" onto it. The order it describes is superseded again.
 *
 * THE OWNER'S M016 RULING is that ALTERNATION IS THE RULE AND THE ASSIGNMENT IS
 * FREE: it does not matter which band carries which ground so long as no two
 * identical grounds touch. He moved the CLAIMS BAND ABOVE THE REVIEWS, so with
 * favorites on warm ash the claims band had to take bone, and the review takes
 * warm ash behind it. Running order: hero / tiles / favorites / CLAIMS /
 * REVIEWS / doors / about link.
 *
 * WHAT IT COSTS IN INK, MEASURED ON THE RENDERED PAGE rather than assumed —
 * --color-text-strong carries the quote, the author and the heading:
 * 12.11:1 on ash against 13.45:1 on bone. Lower, and still nearly three times
 * the AA minimum. THIS IS A RE-INSTATEMENT, NOT A NEW PAIR: the review carried
 * warm ash from M004-K until M015, the pair has been in R28's AA_PAIRS
 * throughout, and --color-border-strong on the portrait outline is back on the
 * ground it was DERIVED on (3.01:1, see the note above). No new token, no new
 * pair, no fifth grey.
 *
 * depends: .home-review
 * depends: --color-surface-sunken
 */
.home-review { background: var(--color-surface-sunken); }

/*
 * The review's heading (M004-B Part 5). It was introduced when the review and
 * the featured band above it were both bone and something had to separate
 * them. M004-K gave the review its own ground back, so the heading is no
 * longer load-bearing for that — it stays because it earns its place on its
 * own terms: it says what the section IS rather than merely that it is a
 * different one, and a band of quoted text with no label is a band a reader
 * has to work out.
 */
.review-heading { text-align: center; margin: 0 0 var(--space-3xl); }

/*
 * THE REVIEW GRID (M013) — `repeat(2, 1fr)` IS ALREADY THE FOUR-REVIEW LAYOUT.
 * depends: .review-grid
 *
 * Two items fill it as a balanced row today; four fill it as a 2 x 2 with no
 * layout change and no CSS edit, which is why it is written as a fixed two-track
 * grid rather than as `auto-fit`. `auto-fit` would put four reviews in one row
 * on a wide screen and turn a wall of quotes into a ticker.
 *
 * THERE IS NEVER AN EMPTY CELL. The number of cells follows the number of real
 * reviews, never the other way round — see reviews.js. A grid with a gap in it
 * is a grid that has to be fixed, not a list that has to be padded.
 */
.review-grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(2, 1fr);
  gap: var(--space-3xl);
  align-items: stretch;
}
.review-grid > li { display: flex; }

/*
 * ONE COLUMN ON A PHONE, AND A TIGHTER GAP THAN THE DESKTOP ONE.
 * depends: .review-grid
 *
 * Stacked, the desktop gap reads as two unrelated sections rather than as a
 * pair — the horizontal gap between columns and the vertical gap between
 * stacked cards are not the same measurement doing the same job. One step down
 * the scale, not an invented value: --space-2xl here against --space-3xl above,
 * measured as a 32px gap on the phone against 40px side by side.
 *
 * Measured at 390 with two reviews: the section is 860.9px and the page is
 * 6,777px. The stacked cards are deliberately NOT equal height — 273.7px and
 * 374.5px — because equal height is a claim about two things standing beside
 * each other, and in one column each card is its own row. See .review.
 */
@media (max-width: 767.98px) {
  .review-grid { grid-template-columns: 1fr; gap: var(--space-2xl); }
}

/*
 * EQUAL HEIGHT, UNEQUAL CONTENT.
 * depends: .review
 *
 * The two quotes are different lengths and always will be. Without this the
 * shorter card floats its attribution up mid-air beside the longer one's, and
 * two names at two different heights read as a rendering fault rather than as
 * two people. The card is a flex column, the caption takes `margin-top: auto`,
 * and both names land on a shared baseline whatever the quotes do.
 *
 * `max-width` is dropped here and the measure moves onto the QUOTE instead —
 * see .review-quote. A 750px cap on a card that is now half the row would fight
 * the grid track rather than cap the text.
 */
.review {
  margin: 0; max-width: none; width: 100%;
  display: flex; flex-direction: column;
  text-align: center;
}
.review blockquote { margin: 0; }
.review-quote {
  margin: 0 auto; max-width: var(--width-measure);
  font-family: var(--font-heading);
  font-size: var(--text-3xl); line-height: var(--leading-tight);
  color: var(--color-text-strong);
}
/*
 * THE ATTRIBUTION IS PINNED TO THE BOTTOM OF THE CARD.
 * depends: .review-by
 *
 * `margin-top: auto` inside the flex column is what puts both names on one
 * baseline regardless of quote length. It does nothing at all when there is one
 * review, which is why it can be added without a visual change to that case.
 */
.review-by {
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-xs); margin-top: auto; padding-top: var(--space-lg);
}

/*
 * A HAIRLINE RING ON BOTH AVATARS, IN AN EXISTING TOKEN, DERIVED AGAINST ASH.
 * depends: --color-border-strong
 *
 * THE TWO PHOTOGRAPHS DO NOT MATCH AND CANNOT BE MADE TO. The Phelps portrait
 * is a 720x960 frame of a man silhouetted in an aircraft ramp — a dark
 * head-and-shoulders shape against bright sky, heavily compressed. The Rizor
 * portrait is a lit, sharp face on a near-black ground. Side by side at 48px
 * the difference is visible in a way it never was with one on the page.
 *
 * THE PHELPS PORTRAIT IS NOT RE-CROPPED OR REPLACED. Owner ruled: end of story.
 * So the treatment is applied identically to both instead — same 48px diameter,
 * same circle, same `object-fit: cover`, same ring — and what the ring buys is
 * a defined EDGE. Without it the bright portrait ends in a clean circle while
 * the dark one bleeds into the ash and reads as a smudge rather than a person.
 *
 * WHY --color-border-strong AND NOT --color-border, measured rather than
 * chosen: against ash (#f6ece5), --color-border (#e3dad5) is 1.18:1 and simply
 * does not appear; --color-border-strong (#918782) is 3.01:1 and is a visible
 * hairline. That pair is ALREADY in R28's AA_PAIRS at a 3:1 threshold and
 * already passing, so no new token was invented and no new pair was needed.
 *
 * `box-shadow` was not used and must not be: there is not one in this
 * stylesheet and that is the design (M002-B, recorded in :root). `outline` with
 * a circular `border-radius` follows the circle in every current browser.
 */
.review-photo, .review-by picture img {
  width: 48px; height: 48px; border-radius: var(--radius-circle); object-fit: cover;
  outline: var(--border-width) solid var(--color-border-strong);
  outline-offset: calc(-1 * var(--border-width));
}
.review-author { font-weight: var(--weight-medium); color: var(--color-text-strong); }

.home-section-tint { background: var(--color-surface-sunken); }

/*
 * THE ABOUT LINK (M016 Task 9) — OWNER RULING: the section is CENTRED.
 *
 * It was a two-column split carrying the jar-lids crop. It is now one centred
 * column: heading, paragraph, link, photograph.
 *
 * ⚠ THE MEASURE CAP AND THE AUTO MARGINS ARE BOTH DECLARED HERE BY NAME.
 * `.page > p` matches DIRECT CHILDREN ONLY and this paragraph sits inside
 * .home-about-link-inner, so an inherited cap would silently not apply — no
 * error, no warning, a passing build, which is exactly how M015 shipped
 * 91-character running text on /about/. CENTRED text at full container width
 * is that same defect made more visible rather than less, so it gets both the
 * cap and the margins explicitly and neither is assumed.
 *
 * depends: --width-measure
 * depends: --width-prose
 */
.home-about-link-inner {
  max-width: var(--width-prose);
  margin-inline: auto;
  text-align: center;
}
.home-about-link-inner > h2 { margin-top: 0; }
.home-about-link-inner > p.home-text {
  max-width: var(--width-measure);
  margin-left: auto;
  margin-right: auto;
}
/*
 * `.home-about-link-media` IS DELETED (M017 Task 3, owner ruling 2026-08-31):
 * the about-link section carries no photograph, so the rule that spaced one has
 * nothing to select. A rule kept "in case" is a rule nobody can tell has
 * stopped applying — the same argument recorded above for the deleted hairline.
 *
 * `.home-about-link-inner` needed NO other change. It was already a plain
 * centred block — max-width, margin-inline: auto, text-align: center — and not
 * a grid, so there was no one-child grid left behind to take out and no
 * `order:` or `row-reverse` to survive the edit. Checked rather than assumed.
 */
/*
 * The hairline that used to be drawn between the review and the signup is
 * GONE, because the thing it was compensating for is gone: M003-C left those
 * two tinted sections adjacent, and 192px of stacked padding between two
 * identical grounds read as dead space inside one long section. M004-A Part 3
 * alternates the surfaces down the whole page, so the three doors — on bone —
 * now sit between them and no two tinted sections touch anywhere on the site.
 * A rule kept "in case" is a rule nobody can tell has stopped applying.
 */
/*
 * The three `margin-inline: auto` rules that used to sit here are GONE
 * (M004-B) — see .home-narrow. They centred this section's children inside a
 * box that was itself off-centre, which is a different thing from centring the
 * section and only ever looked right on the one page they were scoped to.
 * `text-align` stays: that is this section's own decision about its copy, not
 * a repair of somebody else's geometry.
 */
.home-section-tint .home-narrow { text-align: center; }
.home-section-tint .field { text-align: left; }

/* ------------------------------- forms --------------------------------- */

/*
 * The contact and newsletter forms (M002-C, Part 6).
 *
 * Both post straight to a provider with no JavaScript, so everything here is
 * ordinary form styling — there is no loading state to design, because the
 * browser's own navigation is the loading state, and no error toast, because a
 * failed POST is a page the provider serves.
 *
 * Labels are visible, above their field, and permanent. Placeholder-as-label is
 * the standard defect here and it removes the description of the field at the
 * exact moment someone starts filling it in.
 */
.site-form { margin: var(--space-lg) 0 0; max-width: var(--width-measure); }
.field { display: grid; gap: var(--space-3xs); margin: 0 0 var(--space-md); }
.field label { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--color-text-strong); }
.field input, .field textarea {
  font: inherit;
  width: 100%;
  min-height: var(--tap-min);
  padding: var(--space-2xs) var(--space-xs);
  color: var(--color-text-strong);
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border-strong);
  border-radius: var(--radius-md);
}
.field textarea { min-height: 0; resize: vertical; }
.field-actions { margin: 0 0 var(--space-2xs); }
.field-note { margin: 0; font-size: var(--text-sm); }

/*
 * M006 TASK 3 — "Is this a gift?" on the Starter Set page.
 *
 * THE REVEAL IS A CHECKBOX AND A SIBLING SELECTOR, WITH NO SCRIPT ANYWHERE.
 * `.gift-toggle:checked ~ .gift-request-body` is the whole mechanism. That is
 * not merely a no-JavaScript courtesy: the checkbox is ALSO the customer's
 * answer to the question, so the state that opens the form and the state that
 * means "yes, a gift" are one state and cannot drift apart. A <details>
 * element would have needed a second, invisible input to carry the answer.
 *
 * CLOSED IS THE DEFAULT AND COSTS ONE ROW. Somebody buying a Starter Set for
 * themselves meets an unticked checkbox and scrolls past it; nothing about
 * gifting is in the way of an ordinary purchase, which is the owner's ruling
 * expressed as layout.
 *
 * The hint sits on its own line at 390 because the row wraps — `flex-wrap`
 * rather than a media query, so the break happens where the words actually
 * run out instead of at a width somebody guessed.
 */
/*
 * THE SECTION IS THE GRID, so the checkbox, its label, its hint and the body
 * can all be SIBLINGS. That is not a layout preference — `~` only reaches
 * siblings, and the first version of this wrapped the input and label in a <p>
 * for exactly this row, which put .gift-request-body out of the selector's
 * reach. The checkbox ticked, the rule matched nothing, the panel stayed
 * display:none, and the markup looked right. It was found by measuring the
 * box, which is the standing instruction in this repository and earned its
 * keep again here.
 */
.gift-request {
  margin: var(--space-2xl) 0 0;
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  column-gap: var(--space-xs);
  row-gap: var(--space-2xs);
}
/*
 * 44px, like every other control on this site (M002-A). A checkbox is the
 * smallest thing on a page and the easiest to miss with a thumb, so the input
 * carries the full target and the label carries one of its own beside it.
 */
.gift-toggle {
  grid-column: 1;
  width: var(--tap-min); height: var(--tap-min);
  margin: 0; accent-color: var(--color-accent);
}
.gift-toggle-label {
  grid-column: 2;
  font-size: var(--text-lg); font-weight: var(--weight-medium);
  color: var(--color-text-strong);
  min-height: var(--tap-min); display: flex; align-items: center;
}
/* Under the label rather than beside it: at 390 there is no room for both on
   one line, and a hint that wraps under a label reads as belonging to it. */
.gift-toggle-hint {
  grid-column: 2;
  font-size: var(--text-sm); color: var(--color-text-muted);
}

.gift-request-body { grid-column: 1 / -1; display: none; }
.gift-toggle:checked ~ .gift-request-body { display: block; }

/*
 * WHAT OPENS IS A TINTED PANEL ON A DEAL PAGE AND PLAIN SPACING ON THE ORDER
 * FORM, and the difference is the ground underneath it (M007 Task 3).
 *
 * The class this used to be on — `.gift-form` — is gone with the <form> it
 * dressed: the deal-page control POSTs nothing now, so there is no form there
 * to style. The panel treatment moves onto the body itself, which is what was
 * actually being drawn.
 *
 * It is scoped to `.deal-page` because the order form is ALREADY a tinted
 * panel, and a tinted panel opening inside a tinted panel is two boxes with no
 * edge between them — it reads as a rendering fault rather than as a reveal.
 * There, the reveal is carried by the fields appearing, which is enough.
 *
 * Flat either way, like everything else here — there is not one box-shadow in
 * this stylesheet and that is the design (M002-B, D3).
 */
.deal-page .gift-request-body {
  margin: var(--space-md) 0 0;
  padding: var(--space-lg);
  background: var(--color-surface-sunken);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
  max-width: var(--width-measure);
}
.order-form .gift-request-body { margin: var(--space-md) 0 0; }
/*
 * THE HONESTY LINE IS NOT SMALL PRINT. It states that this does not place an
 * order, which is the single most important sentence in the control, so it is
 * body ink at body size and sits first — not muted, not shrunk, not below the
 * button where it would be read after the decision.
 */
.gift-honesty { margin: 0 0 var(--space-lg); color: var(--color-text); }
.field-hint { font-size: var(--text-sm); color: var(--color-text-muted); }
.gift-card-note {
  margin: 0 0 var(--space-md);
  font-size: var(--text-sm); color: var(--color-text-muted);
}

/*
 * THE ERROR STATE is the browser's own, and that is a decision.
 *
 * `required` and type="email" give every field native constraint validation: a
 * message bubble in the visitor's own language, announced by screen readers,
 * that blocks submission and focuses the offending field. It needs no script,
 * so it survives the no-JavaScript case that the rest of this form is built
 * for.
 *
 * The obvious addition — a red border on :user-invalid — was NOT made. It would
 * mean adding a second hue to a palette that M002-A spent the whole milestone
 * collapsing to one, and R28 would then require that red to be classified and
 * contrast-checked against three surfaces, for a signal the browser is already
 * giving more accessibly than a border colour can. If an error treatment is
 * ever wanted it is a deliberate palette decision with the owner, not a colour
 * someone reaches for while building a form.
 *
 * A failure AFTER submission — provider down, network gone — is a page the
 * provider serves. Nothing is swallowed, which is the property that matters:
 * the visitor always ends up somewhere that says what happened.
 */

/* The honeypots. Off-canvas rather than display:none — some bots skip anything
   the CSS hides outright, and neither field is reachable by keyboard or read
   out, because both carry tabindex="-1" and their container is aria-hidden. */
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

/*
 * An owner-fill slot, rendered where something is built but not yet switched
 * on. Same device as the empty deal descriptions: it states what is missing and
 * who it belongs to, rather than showing a control that cannot work.
 */
.owner-fill {
  margin: var(--space-lg) 0 0;
  padding: var(--space-xs) var(--space-sm);
  max-width: var(--width-measure);
  background: var(--color-surface-sunken);
  border: var(--border-width) solid var(--color-border-strong);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
}
/* The half of the notice that is for whoever edits the repository rather than
   for whoever is reading the page. Quieter, but not hidden — an owner-fill slot
   that hides what to fill in is just a gap. */
.owner-fill-detail { color: var(--color-text-muted); }
.owner-fill code { font-size: var(--text-xs); }

/*
 * THE CONFIRMATION BLOCK, AND THE ONE RULE UNDER IT THAT IS THE WHOLE FIX
 * (M007 Task 2).  depends: --width-measure
 *
 * The owner reported the confirmation page as "off-centre" from a screenshot.
 * Measured rather than believed, the ARTICLE is centred to the pixel — left gap
 * 345, right gap 345 at 1440 on all three pages. What is not centred is what
 * anybody actually looks at: the body paragraph and the button sat 206px LEFT
 * of the block's centre while the h1 above them was dead centre.
 *
 * The cause is the measure (D6). `.page > p` caps running text at
 * --width-measure and, correctly for every other page on this site, does it
 * with a plain max-width and no auto margin — a left-aligned column is meant to
 * stay against the left edge of its column. A confirmation page is the one
 * place where that box sits inside a CENTRED block, so a 544px paragraph in a
 * 750px article pinned itself to the left while centring its own type. The
 * screenshot was reading a real defect and naming the wrong box.
 *
 * The fix is here rather than at `.page > p` deliberately: capping the measure
 * is right site-wide and centring it is right only inside a centred block, so
 * the override belongs to the block that is centred. Direct children only —
 * this centres the boxes the block lays out, not everything inside them.
 *
 * It cannot be seen at 390: the block is 350px there, below the 544px cap, so
 * the cap never binds and the diff was already 0. Desktop only, which is
 * exactly where the owner was looking.
 *
 * `tools/measure-centering.mjs` measures the outcome on every confirmation page
 * in the artifact; R46 lints this pairing so the declaration cannot be dropped
 * while the block stays centred.
 */
.form-confirm { text-align: center; padding: var(--space-6xl) 0; }
.form-confirm > * { margin-inline: auto; }

/* ======================== M015: the /about/ layout ======================= */

/*
 * ⚠ THE MEASURE CAP HAD TO BE RE-APPLIED BY HAND, AND THIS IS THE PART TO
 * UNDERSTAND BEFORE TOUCHING ANY OF IT.
 *
 * `.page > p` (see "The measure", D6) matches DIRECT CHILDREN ONLY. Moving the
 * lead, the values and the answers into layout wrappers switched it off for
 * every one of them — with no error, no warning and a passing build. Measured
 * in a browser, the running text came out at 91 CHARACTERS against a cap that
 * exists because M004 found 99 was a readability defect.
 *
 * Every wrapper below therefore re-declares the cap. If a wrapper is added or
 * renamed in about.njk, THE CAP GOES WITH IT. This will bite again: the same
 * shape applies anywhere a prose page grows a layout div.
 *
 * depends: --width-measure
 * depends: .page > p
 */

/*
 * `.page` caps at --width-prose and sets no auto margins, so in the 1200px
 * container it sits hard left. Nobody saw it for fourteen milestones because
 * the type was left-aligned and a left-aligned column looks the same either
 * way. It stops looking the same the moment a heading centres.
 */
.page.about-page { margin-inline: auto; }

.about-intro { text-align: center; }
.about-intro > p {
  max-width: var(--width-measure);
  margin-left: auto; margin-right: auto;
}

/*
 * 🔴 M023 TASK R3 — EVERY TEXT BOX ON /about/ IS CENTRED. OWNER RULING,
 * 2026-09-04.
 *
 * WHAT "CENTRE THE TEXT BOXES" HAD TO MEAN, DECIDED BY MEASUREMENT RATHER THAN
 * BY PREFERENCE, because the two readings are not the same change and only one
 * of them does anything.
 *
 * Reading A — centre the BOXES in their columns. Measured before touching
 * anything: at 1440 `.about-values p` is 324.6px wide and its column is
 * (1200 - 64) / 3.5 = 324.57px. THE BOX ALREADY FILLS ITS COLUMN EXACTLY.
 * `margin-inline: auto` would have moved it by nothing, on every value, at
 * every width. The same is true of the FAQ answers against their columns.
 * Reading A is a no-op, so it cannot be what he is looking at.
 *
 * Reading B — centre the TEXT. That is what shipped, and it is also the
 * precedent this page already set: M017 Task 5's ruling was "Our Core Values is
 * not centred, centre it", and the fix was `text-align: center`.
 *
 * ⚠ THE COST, STATED PLAINLY RATHER THAN BURIED. Centred running text gives
 * every line a ragged left edge, and the eye has to find the start of each new
 * line instead of returning to a known one. That is a real readability cost on
 * the value bodies and the FAQ answers, which are the longest prose on the
 * site outside a recipe. IT IS AN OWNER RULING AND IT SHIPPED; it is recorded
 * here with its cost so that reversing it later is a decision and not a
 * rediscovery, and so nobody "fixes" it as a defect.
 *
 * `.about-intro` was ALREADY centred and is untouched.
 *
 * depends: --width-measure
 */
.about-page .about-values,
.about-page .about-values p,
.about-page .about-faq,
.about-page .about-faq .faq-item,
.about-page .about-faq .faq-item p { text-align: center; }

/* The capped measure now sits centred in its column as well as reading centred.
   Without this the centred text would be centred inside a box pinned to the
   column's left edge, which looks like a mistake at any width where the cap
   bites — that is every width below 768, where the column is the full 350/750
   and the cap is 34rem. */
.about-page .about-values p,
.about-page .about-faq .faq-item p { margin-inline: auto; }

/*
 * THE CORE VALUES BAND (M016 Task 11) — OWNER RULING: three values, the
 * photograph, three values, with the photograph LARGER than it was.
 *
 * This REPLACES `.about-split` (M015: values left, figure right). M015's ban on
 * `order:` and `row-reverse` SURVIVES: the DOM order is values 1–3, figure,
 * values 4–6, which is already the visual order. Nothing is reordered.
 *
 * WHY IT BREAKS OUT OF THE PROSE COLUMN, which is the part worth recording
 * because the arithmetic forces it: `.page` caps at --width-prose (750px), and
 * THREE columns inside 750 always make the middle one smaller than half of 750
 * — smaller than the 359px the two-column layout already gave the figure. The
 * ruling asks for LARGER, so the band has to be wider than the prose column.
 * At the container width the middle column is 487px.
 *
 * `cqi`, NOT `vw` — R42 lints viewport units in the inline axis, and this is
 * the same break-out idiom .home-section already uses, bounded to the container
 * width instead of full bleed. `main` becomes the query container; the article
 * itself cannot be, because 100cqi would then resolve to the 750px it is
 * capped at, which is the number we are trying to escape.
 *
 * `margin-left: calc(50% - <half the band>)` centres the band on the article's
 * own centre: 50% resolves against the article's inline size, so the band stays
 * centred whatever that is, and at 768 the two are equal and the offset is 0.
 *
 * depends: --width-container
 * depends: --width-prose
 */
main { container-type: inline-size; container-name: page-bleed; }

/*
 * 🔴 "OUR CORE VALUES" IS CENTRED (M017 Task 5, owner ruling 2026-08-31).
 *
 * THE COLUMNS WERE NEVER UNEQUAL — that was measured before anything moved.
 * `grid-template-columns: minmax(0,1fr) minmax(0,1.5fr) minmax(0,1fr)` makes
 * the two value columns equal by construction, and they measure 324.562px and
 * 324.578px at 1440 and 258.562/258.578 at 1024. The 0.016px is sub-pixel
 * `1fr` rounding, not a defect, and both gutters are exactly 32px.
 *
 * WHAT WAS ACTUALLY WRONG WAS THIS HEADING. It inherits `text-align: start`
 * inside the 750px prose column while the grid below it breaks out to the full
 * 1200px container, so its text began 225px inboard of the grid's first column
 * at 1440 (112.5px at 1024). The two boxes already share a centre axis — the
 * heading's box and the grid both centre on 712.5 at 1440 — so centring the
 * TEXT puts it on the grid's axis exactly, and no geometry moves.
 *
 * SCOPED WITH A CLASS, not with `.page h2`, because the FAQ heading below is
 * also an h2 and the owner ruled on this one. The heading's element, level and
 * copy are untouched; only its alignment changes.
 *
 * depends: .about-values-grid
 */
/*
 * M023 TASK R3 — BOTH SECTION HEADINGS, LARGER AND CENTRED. OWNER RULING,
 * 2026-09-04: "increase the size of Our Core Values" and "increase the size of
 * FAQ". They take the new --text-h2-lead rung, which sits between --text-h2 and
 * --text-h1 so neither outranks the page's own h1 at any width. See the token.
 *
 * "Our Core Values" was already centred (M017 Task 5); the FAQ heading was not,
 * and it is now, so the two sections are headed the same way. The FAQ heading
 * carries its own class rather than being reached positionally — a selector
 * meaning "the second h2" breaks the day a section is added.
 *
 * depends: --text-h2-lead
 */
.about-values-heading,
.about-faq-heading {
  text-align: center;
  font-size: var(--text-h2-lead);
}

/*
 * 🔴 M023 TASK R3 — THE ALTERNATING BAND, FULL BLEED. OWNER RULING, 2026-09-04.
 *
 * The Core Values section takes warm ash while the intro above and the FAQs
 * below keep bone. It BLEEDS to the container edge by the same arithmetic the
 * values grid already uses — `100cqi` against `page-bleed`, which `main`
 * declares — because a tinted band that stopped at the 750px prose column
 * would draw a box around content that already breaks out of it.
 *
 * The padding is what makes it a band rather than a stripe: without it the
 * heading's own margin sits outside the paint and the tint starts mid-air.
 * `margin-top` is negative-free — the band supplies its own space above and the
 * heading's --space-heading-above-2 is cancelled inside it, so the gap between
 * the intro and the tint is one value and not two stacked.
 *
 * depends: --color-surface-sunken
 * depends: --width-container
 */
.about-band-alt {
  background: var(--color-surface-sunken);
  width: min(var(--width-container), 100cqi);
  margin-left: calc(50% - min(var(--width-container), 100cqi) / 2);
  padding: var(--space-2xl) var(--space-lg);
  border-radius: var(--radius-md);
  margin-top: var(--space-2xl);
}
.about-band-alt > .about-values-heading { margin-top: 0; }

/* Inside the band the grid must not break out a SECOND time — it is already at
   the container width. Left to itself it would compute its own 50% against the
   band and hang off the edge. */
@media (min-width: 768px) {
  .about-band-alt > .about-values-grid {
    width: auto;
    margin-left: 0;
  }
}

.about-values-grid {
  display: grid;
  gap: var(--space-xl);
  align-items: center;
  margin-top: var(--space-2xl);
}
.about-values-grid figure { margin: 0; }
.about-values h3:first-child { margin-top: 0; }

/*
 * ⚠ THE MEASURE CAP IS DECLARED HERE BY NAME, and it has to be.
 * `.page > p` matches DIRECT CHILDREN ONLY, so it does not reach a paragraph
 * inside `.about-values`. M015 found exactly this trap on this page: no error,
 * no warning, a passing build, and running text at 91 characters against a cap
 * that exists because M004 found 99 was a readability defect.
 *
 * `margin-inline: 0` is explicit rather than inherited, so the cap left-aligns
 * the text inside its column instead of centring a short measure in a wide one.
 *
 * depends: --width-measure
 */
.about-values p { max-width: var(--width-measure); margin-inline: 0; }


/*
 * ⚠ MEASURED, AND REPORTED RATHER THAN SMOOTHED (M016 Task 11).
 *
 * The owner specified three columns from 768 up, and that is what this is. The
 * cost is at the NARROW end of that range and it is a real one:
 *
 *     768   value column 185px   19–22 characters per line   Originality = 17 lines
 *    1024   value column 261px   (interpolated from the same fractions)
 *    1440   value column 325px   37–41 characters per line
 *     767   stacked, 544px       50–65 characters per line, at the 34rem cap
 *
 * 22 characters is a narrow measure — narrower, in the other direction, than
 * the 91-character line M015 fixed on this page. It is not a bug in this CSS:
 * at a 768px viewport the container is 720px, and three columns with two 32px
 * gaps cannot be wider than this whatever the fractions are. Making it better
 * would need EITHER a third breakpoint, which CLAUDE.md forbids by name, OR a
 * figure narrower than the value columns, which the ruling forbids.
 *
 * SO IT IS THE OWNER'S CALL, and it is recorded here with the numbers so that
 * the next milestone finds a measurement rather than an opinion.
 */
@media (min-width: 768px) {
  .about-values-grid {
    width: min(var(--width-container), 100cqi - var(--space-3xl));
    margin-left: calc(50% - min(var(--width-container), 100cqi - var(--space-3xl)) / 2);
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) minmax(0, 1fr);
    gap: var(--space-2xl);
  }
}

.about-faq {
  display: grid; gap: var(--space-xl) var(--space-2xl);
  margin-top: var(--space-lg);
}
.about-faq .faq-item h3 { margin-top: 0; }
.about-faq .faq-item p { max-width: var(--width-measure); }
.about-faq .faq-item p:last-child { margin-bottom: 0; }

@media (min-width: 768px) {
  .about-faq  { grid-template-columns: repeat(2, minmax(0,1fr)); }

  /* An ODD last item spans both columns, so three questions leave no empty
     cell and a fourth RELEASES the span with no CSS edit. Do not hard-code
     the third item. */
  .about-faq .faq-item:last-child:nth-child(odd) { grid-column: 1 / -1; }

  /* The spanning item would otherwise be one narrow measure of text against
     half a column of empty ground. Its ANSWER flows into two columns so the
     full-width item matches the rhythm of the two beside it. */
  .about-faq .faq-item:last-child:nth-child(odd) .faq-body { columns: 2; column-gap: var(--space-2xl); }
  .about-faq .faq-item:last-child:nth-child(odd) .faq-body p { max-width: none; break-inside: avoid; }
  .about-faq .faq-item:last-child:nth-child(odd) .faq-body p:first-child { margin-top: 0; }
}

/* ================= M015: the homepage claims and about link ============== */

/*
 * OUR FAVORITES IS WARM ASH (M015 Task 9). The band carries the class in its
 * own template; nothing is painted here. Recorded so a reader looking for the
 * declaration finds this note instead of concluding the band has no ground.
 *
 * depends: .home-section-tint
 */

/*
 * THE THREE CLAIM ROWS.
 *
 * One row per owner claim, each with the photograph that evidences it. Below
 * the wide breakpoint every row is one column and stacks picture-then-claim.
 *
 * ⚠ THE LEFT/RIGHT ALTERNATION IS GRID COLUMN PLACEMENT, NOT DOM ORDER, and it
 * is the ONE place on this site where visual order and DOM order differ. It is
 * a deliberate, narrow exception to the no-CSS-reordering rule and the reason
 * is in home.njk beside the markup: alternating the MARKUP instead is what
 * breaks the mobile stack, putting two copy blocks back to back with their
 * pictures orphaned. Nothing here moves TEXT relative to other text — what
 * moves is one figure relative to its own paragraph, inside one row.
 *
 * depends: .brand-row
 */
/*
 * ⚠ THE VERTICAL VOID IN THIS BAND IS A FIGURE-HEIGHT PROBLEM AND IS STILL
 * OPEN (M017 Task 4). Measured, reported, and deliberately NOT fixed.
 *
 * The owner ruled the band bland. Most of that was the subline's type, which is
 * fixed above. The rest is this: `align-items: center` centres a ~90px block of
 * text against a figure that is the full half-column at 4:3, so the two lines
 * float in a large empty area. The figure sets the row height and the text
 * cannot come close to filling it.
 *
 *     vp     figure h   text h    void ABOVE and BELOW the text, each side
 *     1440   432.00     89.52     171.24
 *     1024   345.38     85.30     130.04
 *      768   249.38     106.75    71.32   (rows 2 and 3: 51.55, 65.36)
 *      390   stacked — the figure is above the text, so there is no void
 *
 * THE TYPE CHANGE ABOVE MOVES THIS BY ~3px A SIDE AND THAT IS THE POINT: two
 * lines of text cannot fill 432px at any size this design would accept. The
 * only levers that close it are a shorter figure, a narrower image column or a
 * different aspect — ALL OF WHICH ARE CHANGES TO THE PHOTOGRAPH'S RENDERED
 * SIZE, which M017's ruling placed out of scope, and which would also move the
 * `sizes` attribute and R23 with them.
 *
 * `align-items` is not the lever either: centre, start and end redistribute the
 * same 342px, they do not reduce it.
 *
 * SO IT IS REPORTED WITH ITS NUMBERS RATHER THAN SMOOTHED, and it is the
 * owner's call. depends: .brand-row
 */
/*
 * 🔴 M018 TASK 4 — THE BAND IS REBUILT ON THE OLD WooCommerce SITE'S STRUCTURE,
 * ON THE OWNER'S RULING OF 2026-09-01. THIRD STYLING PASS ON THIS BAND: M016
 * shipped it, M017 restyled it, M018 rebuilt it. If a FOURTH is ever proposed,
 * it should be proposed knowing two have already missed.
 *
 * WHAT CHANGED: small roughly-square figures with a generous radius, text
 * centred in a narrow column, and space put AROUND the images and BETWEEN the
 * rows. NOT ONE CHARACTER OF THE COPY CHANGED.
 *
 * THE DIAGNOSED CAUSE WAS THE CROP, NOT THE SIZE. The old version rendered
 * SCENE crops — a horizon, context, empty space inside the frame — at half the
 * column. A scene crop has a composition to get wrong, so it fails when it is
 * small; a TEXTURE crop has none, so it survives being small and gains from it.
 * The photographs are re-cropped to 1:1 texture regions in imagePolicy.json,
 * where the arithmetic and the rejected tighter crop are recorded.
 *
 * 🔴 M017 TASK 4'S SPACING FIX IS NOT REVERSED. The claim-to-evidence gap is
 * still --space-sm and the type is still M017's. The breathing room the owner
 * asked for is the ROW GAP and the space around the figure, which is what the
 * reference actually does. The vertical void INSIDE the text column is measured
 * before and after and did not grow — see PROJECT-STATE.MD.
 *
 * ⚠ THE LEFT/RIGHT ALTERNATION IS STILL GRID COLUMN PLACEMENT AND STILL THE ONE
 * DOCUMENTED EXCEPTION. There is no `order:` and no `row-reverse` here. It is
 * not done in DOM order because alternating the MARKUP is what breaks the
 * mobile stack: it puts two copy blocks back to back and orphans their
 * pictures. Nothing here moves TEXT relative to other text.
 *
 * NO NEW GROUND, NO TINT, NO PANEL, NO EMBER, NO HEADING-LEVEL CHANGE.
 * depends: .brand-row
 */
/*
 * 🔴 M023 TASK 2 — THE FOURTH STYLING PASS ON THIS BAND, AND THE WARNING ABOVE
 * IS HONOURED RATHER THAN IGNORED. The note beginning "If a FOURTH is ever
 * proposed" is M018's, it is right, and this pass is proposed knowing two have
 * already missed. It is an OWNER RULING of 2026-09-03 with four parts, and it
 * changes SPACING, TYPE SIZE, LAYOUT and CROP — NOT ONE CHARACTER OF THE COPY.
 * R32 still holds every word: it rejoins each claim with its evidence and fails
 * the build on any drift, and putting two claims beside each other is a grid
 * change that R32 cannot even see.
 *
 * (1) TIGHTEN. The row gap was --space-7xl (96px), which is the rung used
 *     BETWEEN HOMEPAGE SECTIONS — so three claims inside one band were spaced as
 *     far apart as the band is from its neighbours. It is now --space-4xl
 *     (48px). The figure-to-text gap goes --space-2xl -> --space-lg stacked and
 *     --space-4xl -> --space-2xl beside.
 *
 * (2) THE PAIR. Rows 2 and 3 — "Fire Roasted. Slow Smoked." and "Small batch.
 *     Made fresh." — sit SIDE BY SIDE above the 768px breakpoint instead of
 *     stacked. Row 1 keeps the wide figure-beside-copy treatment and spans both
 *     columns. On a phone all three still stack in DOM order, unchanged.
 *
 * ⚠ THE DOM-ORDER EXCEPTION SHRINKS TO ONE ROW AND IS NOT WIDENED. Only row 1
 *   still places a figure across from its own copy by grid column, which is the
 *   documented narrow exception. Rows 2 and 3 are now figure-above-its-own-claim
 *   in both DOM and visual order at every width, so they are not an exception at
 *   all any more. NOTHING here moves text relative to other text.
 */
/*
 * 🔴 M024 TASK 1 — THE FIFTH STYLING PASS, AND IT REVERSES M023'S PAIR. OWNER
 * RULING, 2026-09-04, MADE ON THE RENDERED PAGE: the three rows STACK
 * VERTICALLY and the image and the text ALTERNATE sides down the column.
 * M023 Task 2's part (2) — rows 2 and 3 side by side above 768px — IS UNDONE.
 * THAT IS THE POINT OF THIS TASK AND IT IS NOT A DEFECT REPORT. M018's warning
 * that "a fourth should be proposed knowing two have already missed" is now on
 * its fifth and is still the right warning; this pass is an owner looking at
 * what shipped ten hours earlier and preferring the other arrangement.
 *
 * ROW 1'S ORIENTATION IS THE REPO'S AND IS KEPT. M023 shipped row 1 as
 * COPY LEFT / FIGURE RIGHT (`.brand-row:first-child .brand-copy { grid-column: 1 }`),
 * so that is what row 1 still is. Only rows 2 and 3 move: row 2 becomes figure
 * left / copy right, row 3 returns to copy left / figure right.
 *
 * ⚠ THE DOM-ORDER EXCEPTION WIDENS BACK FROM ONE ROW TO TWO, AND THAT IS
 *   STATED RATHER THAN BURIED. M023 shrank it to row 1 alone; alternating
 *   again means rows 1 and 3 place a figure across from its own copy by grid
 *   column, and row 2 does not (its figure-then-copy visual order IS its DOM
 *   order). It is still the same narrow exception this file has documented
 *   since M015 and it is still the ONLY place on this site where visual and
 *   DOM order differ.
 *
 * 🔴 THE MECHANISM IS GRID COLUMN PLACEMENT AND NOTHING ELSE. There is no
 *   `order:`, no `flex-direction: row-reverse`, no `grid-auto-flow: dense` and
 *   no `direction: rtl` here or anywhere in this file. Every one of those
 *   reorders the ACCESSIBILITY TREE or the SPEECH ORDER, which would put a
 *   photograph in front of the claim it is evidence for. `grid-column` moves a
 *   box and leaves the tree alone.
 *
 *   THE PROPERTY THAT MATTERS, AND IT IS CHECKABLE: the DOM order inside every
 *   row is `figure -> .brand-copy`, identically, in all three rows — the
 *   template emits them in that order and nothing here changes it. So the
 *   stacked order below 768px is picture-then-its-own-claim three times over,
 *   and the accessibility tree reads the same at every width. NOTHING MOVES
 *   TEXT RELATIVE TO OTHER TEXT.
 */
.brand-rows { display: grid; gap: var(--space-4xl); }
.brand-row  { display: grid; gap: var(--space-lg); align-items: center; }

/* The figure is a small square, centred in whatever column it lands in, so the
   generous space the owner asked for is AROUND the image rather than inside the
   text. 320px is a structural dimension, not a scale step (R27), and it is
   DERIVED rather than copied from the reference: it is the largest square that
   closes the band's vertical void at 1440 without pushing the 768 row out of
   proportion. See PROJECT-STATE.MD for the measured void at all three widths. */
.brand-row figure { margin: 0; max-width: 320px; margin-inline: auto; }
.brand-row figure img { display: block; border-radius: var(--radius-lg); }

/* Text centred in a NARROW column. 26rem is narrower than --width-measure
   (34rem) on purpose: these are three short statements, not running text, and
   the narrow column is what gives the claim and its evidence a shared axis. */
.brand-row .brand-copy { text-align: center; }
.brand-row .brand-line { margin: 0 auto; max-width: 26rem; }

/*
 * THE CLAIM TYPOGRAPHY IS NOT RESTATED HERE, IT IS MOVED.
 *
 * The first version of this block re-declared it, and two of its
 * declarations named tokens that DO NOT EXIST (--weight-bold,
 * --font-display). R27 could not see that: it objects to LITERAL colours,
 * sizes and radii, and an undefined custom property is neither — the
 * declaration is simply dropped and the element inherits. THE BROWSER
 * MEASUREMENT IS WHAT FOUND IT, reporting the claim at weight 400 where the
 * brand block had always set --weight-medium. So the rules that shipped in
 * M004-A/M004-B are RENAMED from `.home-brand` to `.home-claims` above,
 * further up this file beside their own derivations, and nothing about the
 * setting changes. Only the container did.
 */

@media (min-width: 768px) {
  /* THE BAND IS ONE COLUMN AGAIN. `.brand-rows` keeps only the base
     `display: grid; gap: --space-4xl` declared above, so that 48px is now a
     ROW gap between three stacked rows rather than the row-and-column gap of
     M023's pair. M023's `grid-template-columns` and `column-gap` are DELETED
     rather than overridden — a rule that no longer describes the layout is the
     stale-artifact defect, and the same argument M015 made when it deleted
     `.brand-lines`. */

  /* EVERY ROW is figure-beside-its-own-copy, and they ALTERNATE down the page.
     Odd rows put the copy left and the figure right; the even row swaps them.
     The gap is M023's tightened --space-2xl, which is the figure-to-text rung
     BESIDE, kept as the starting point and then re-measured against copy 2.4x
     to 9.6x longer — see the numbers in PROJECT-STATE.MD. */
  .brand-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2xl); }
  .brand-row figure      { grid-column: 2; grid-row: 1; }
  .brand-row .brand-copy { grid-column: 1; grid-row: 1; }
  .brand-row:nth-child(even) figure      { grid-column: 1; }
  .brand-row:nth-child(even) .brand-copy { grid-column: 2; }
}

/* ------------------------------- footer -------------------------------- */

.site-footer {
  border-top: var(--border-width) solid var(--color-border);
  background: var(--color-surface-sunken);
  padding: var(--space-3xl) 0 var(--space-lg);
  margin-top: var(--space-4xl);
  font-size: var(--text-sm);
}
.footer-cols {
  display: grid; gap: var(--space-xl);
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.footer-brand {
  font-family: var(--font-heading); color: var(--color-text-strong);
  font-size: var(--text-xl);
  margin: 0 0 var(--space-3xs);
  display: flex; align-items: center; gap: var(--space-2xs);
}
/* The mark again, smaller. Same inline SVG, same currentColor, no second file. */
.footer-mark { display: block; width: 32px; flex: none; }
.footer-mark svg { display: block; width: 100%; height: auto; }
/*
 * The tagline, relocated from the header (3.2). It was already in this block as
 * `.muted` small print; it is now the footer's lead line at body size, which is
 * the difference between keeping a string and giving it its job back.
 */
.footer-tagline { color: var(--color-text); margin: 0; }
/*
 * D1 — DECIDED: the footer links stay under 44px, and are not to be re-flagged.
 *
 * Every other control on the site clears the 44px touch target; these do not,
 * and an audit will keep reporting them. The architect has ruled that they stay
 * as they are until the footer is looked at as a whole, because the two fixes
 * available here both make it worse: growing the rows turns a compact link list
 * into a tall stack of buttons, and the invisible ::after expansion used
 * elsewhere would overlap between adjacent rows in a list this tight, so a tap
 * near a boundary would open the wrong page — which is worse than a small
 * target, not better.
 *
 * WHAT M003-A CHANGED ABOUT THIS RULING, because the mitigation it rested on
 * is no longer true as written. It used to read "every footer link is a
 * duplicate of a primary nav link, and those DO clear 44px". After the header
 * rebuild, Home, Cart and Deals are in this list and NOT in the primary nav, so
 * for those three the footer link is no longer a duplicate of a 44px control.
 *
 * The ruling still stands, because none of the three is reached only from here:
 * Home is the logo, which is a 40px mark inside a lockup well over 44px tall;
 * Cart is the header cart control, which clears 44px; and Deals is the band at
 * the top of /shop/, which is the largest link target on the site and the whole
 * reason Deals could leave the nav at all. The claim is narrower than it was —
 * "nothing here is the only route to anywhere" — and it is still true.
 */
.site-footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3xs); }
.site-footer a { color: var(--color-text-strong); text-decoration: none; }
.site-footer a:hover { color: var(--color-accent); text-decoration: underline; }
/*
 * `.copyright` IS DELETED (M023 Task R4, owner ruling 2026-09-04). It read
 *     .copyright { margin: var(--space-2xl) 0 0; font-size: var(--text-xs); }
 * and styled the footer line "© 2026 Mitten Made Spice", which is off the site.
 *
 * Recorded rather than removed silently, because the rule and the markup went
 * together and a selector reappearing without its element — or an element
 * reappearing without its selector — is how half a decision comes back.
 *
 * THE FOOTER LOST EXACTLY 54.4px AT BOTH 390 AND 1440: the 22.4px line plus its
 * 32px --space-2xl top margin. That the two widths lost the same amount is the
 * check that this margin was the whole of its box.
 */

/* --------------------------- 404 + reduced motion ----------------------- */

.notfound { max-width: var(--width-prose); text-align: center; padding: var(--space-4xl) 0; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none !important; animation: none !important; }
  .product-card a:hover, .post-card a:hover, .deal-card a:hover { transform: none; }
}

/* ===========================================================================
   M003-D — FEATURED PRODUCTS, THE INTENT DOORS, AND THE GIFT
   ===========================================================================
   Three additions, and one constraint runs through all of them: the ember
   budget. CLAUDE.md allows the accent on no more than two or three elements
   per page, and the homepage now carries four product cards and three doors
   that all want to be pressed. Every one of those seven controls is
   `.button-quiet` — surface fill, strong-border rule, strong ink, no accent
   anywhere — so the whole of this work costs the budget ONE element: the
   "see all the spices" link. tools/count-ember.mjs measures it in a browser,
   because no build rule can. */

/* --- featured products ---------------------------------------------------
   Four across on desktop, two below the wide breakpoint, one on a phone. It
   is its own grid rather than .product-grid because that one is `auto-fill
   minmax(220px, 1fr)` and reflows to any column count — right for a shop
   listing of seventeen, wrong for a chosen four, which must stay four. */
/* The featured band takes the page's existing heading rhythm. Every other h2 on
   this homepage is centred over its section; a left-aligned one reads as a
   different page spliced in.

   The doors are no longer in this rule because they no longer have a heading:
   M004-A Part 5.3 removed "Where would you like to start?" outright rather
   than rewording it, and nothing replaced it. */
.home-featured > .container > h2 {
  text-align: center;
  margin-top: 0;
  margin-bottom: var(--space-3xl);
}

.featured-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-lg);
}
/*
 * FOUR CARDS, ONE BASELINE GRID (M004-K Part 4).
 *
 * The card was a flex column with `margin-top: auto` on the form, which lined
 * the BUTTONS up and nothing else. That is enough while every product name
 * fits on one line, and all four featured names happen to at 1200px and above
 * — which is exactly why this looked correct for two milestones. At 390px the
 * grid is two columns, "Fire Roasted Tomato" wraps and "Smoked Árbol" does
 * not, and the two prices in that row sat 20px apart.
 *
 * Centring the text makes that unmissable rather than merely untidy: a ragged
 * left edge reads as a list, a ragged CENTRE reads as a mistake.
 *
 * So the card is a subgrid of four rows — media, title, price, control — and
 * every card in a row shares the same four tracks. A name that wraps grows the
 * title track for the whole row, so all four prices and all four buttons stay
 * on one line each. The <a> wraps three of those rows and is itself a subgrid,
 * which is why it can be a link around three tracks without collapsing them
 * into one.
 *
 * `row-gap: 0` because a subgrid inherits its parent's gaps by default, and
 * this parent's 24px gap is the space BETWEEN cards. Inside a card the spacing
 * is the elements' own margins, as it always was.
 */
.featured-card {
  display: grid;
  grid-row: span 5;
  grid-template-rows: subgrid;
  row-gap: 0;
  text-align: center;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--color-surface);
}
.featured-link {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
  row-gap: 0;
  color: inherit;
  text-decoration: none;
}
.featured-link:hover .featured-title { color: var(--color-accent-hover); }
.featured-media { aspect-ratio: 4 / 5; overflow: hidden; background: var(--color-surface-sunken); }

/*
 * THE FEATURED BLURB (M016 Task 8, MOVED AND WEIGHTED IN M017 Task 2).
 *
 * 🔴 OWNER RULING 2026-08-31: the blurb sits BELOW the Add to cart button.
 * THIS REPLACES M016 Task 8's "below the price and above the Add to cart
 * button". The move is made in the DOM in featured-products.njk — there is no
 * `order:` and no `row-reverse` anywhere in this card, and there must not be.
 *
 * It is now track 5 of the card's subgrid and the form is track 4; the card
 * still spans 5. The blurb shares its row across all four cards, so a blurb
 * that wrapped would grow the track for the whole row and every card would
 * still end level — the same property the title track has, and the reason this
 * band is a subgrid at all.
 *
 * MUTED INK, NOT ACCENT. A superlative is the most tempting thing on this page
 * to paint ember, and the featured band already spends the budget on four quiet
 * buttons. It is set small, muted and letter-spaced so it reads as a label
 * rather than as a fifth call to action.
 *
 * SEMIBOLD, AND THE COLOUR IS UNTOUCHED (owner ruling: "stand out a bit more,
 * possibly bold"). Weight was the only device authorised and the only one used
 * — no colour change, no size increase, no uppercase, no extra tracking, no
 * badge. --weight-semibold is a REAL cut of the variable Inter already in the
 * artifact, not a synthesised bold; the measurement is beside the @font-face.
 *
 * R28 IS UNAFFECTED BECAUSE THE INK DID NOT MOVE: #726b66 on the bone card is
 * 5.00:1 against a 4.5:1 minimum. Weight does not change the required ratio —
 * WCAG only LOWERS it, to 3:1, for large text, and 14.4px semibold is not
 * large text (that needs 18.66px bold). So the requirement stays 4.5:1 and the
 * pair R28 already pins is the same pair.
 *
 * MARGIN: it is the last thing in the card, so its bottom margin is the card's
 * own bottom padding. THE TOP MARGIN IS NOT DECORATION — with the inherited
 * spacing removed the blurb sat flush against the button, 0px measured, which
 * is precisely the reading the ruling warns about: a line of text touching a
 * button looks like that button's own helper text. One small step buys the
 * separation back without reopening the dead space that was just closed.
 *
 * depends: --color-text-muted
 * depends: --weight-semibold
 * depends: .featured-card
 */
.featured-blurb {
  margin: var(--space-xs) var(--space-sm) var(--space-sm);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-wide);
  color: var(--color-text-muted);
}
.featured-media picture, .featured-media img { width: 100%; height: 100%; object-fit: cover; }
.featured-title {
  font-size: var(--text-lg);
  margin: var(--space-sm) var(--space-sm) var(--space-3xs);
}
.featured-price {
  margin: 0 var(--space-sm) var(--space-sm);
  color: var(--color-text-muted);
}
/* The form is the card's fourth subgrid row, so four cards with different
   title lengths line their buttons up — and so do their prices, which is what
   `margin-top: auto` on a flex column could never do. */

/*
 * THE DEAD SPACE IN THE CARD WAS INHERITED, NOT WRITTEN HERE (M017 Task 2).
 *
 * OWNER RULING: too much empty space between the price, the blurb and the
 * button, and below the button. Measured at 390/768/1440 before the fix, the
 * two big gaps were 44px between the blurb and the form and 45px between the
 * button and the card edge — and NEITHER came from a rule about this card.
 *
 * Both came from the SHARED `.add-to-cart` rule, which this compact form also
 * matches and which was written for the product page:
 *   - `margin: var(--space-lg) 0 var(--space-sm)` — 24px above, 16px below;
 *   - `gap: var(--space-xs)` — 12px, which the EMPTY `.add-status` live region
 *     still claims as a gap slot, so it paints 12px of nothing under every
 *     button on a card that has never been clicked.
 *
 * 🔴 THE OVERRIDE IS LOCAL AND MUST STAY LOCAL. `.add-to-cart` is also used by
 * all 17 product pages (17 plain, 4 compact, counted in the artifact). Editing
 * the shared rule to fix this band would move every product page's buy area,
 * which is not what was asked for and is not visible from here.
 *
 * `.add-status` keeps its place in the DOM — it is `role="status"
 * aria-live="polite"`, and a live region has to be present before the text
 * arrives or the announcement is lost. So the GAP is removed rather than the
 * element, and the status re-earns its space only when it has some.
 */
.featured-card .add-to-cart-compact {
  padding: 0 var(--space-sm);
  margin: 0;
  gap: 0;
}
.featured-card .add-to-cart-compact .add-status:not(:empty) { margin-top: var(--space-xs); }
.featured-card .add-to-cart-compact .button-quiet { width: 100%; }
.featured-all { text-align: center; margin-top: var(--space-2xl); }

/* --- the intent doors ----------------------------------------------------
   Three equal panels. No price comparison, no badge and nothing struck
   through: the visual weight is on the LABEL, because that is the part a
   visitor matches themselves against.

   SURFACE HISTORY, because this band has now moved three times and each move
   was a consequence of the one above it: bone under M004-A (the review above
   was ash), warm ash under M004-B (the review had gone bone), and bone again
   under M004-K, where the owner set the whole page alternating and put the ash
   back on the review. See intent-doors.njk for why bone is also the better
   ending, which M004-B got backwards.

   THE LABELS ARE QUESTIONS AGAIN (M004-K Part 6, owner ruling). M004-A had
   rewritten them into statements on the grounds that a question presumes the
   decision. That reasoning was right about the section HEADING it also
   deleted, and wrong about these three: "Stocking up?" asks which situation
   applies and can be answered no, whereas "Where would you like to start?" can
   only be answered with a size. The heading stays deleted. See intent.json.

   ALIGNMENT: these were left-aligned under M004-A and centred by owner ruling
   in M004-B; M004-K centred the featured and shop cards to match, so every
   card on the site is now centred. The original objection is recorded beside
   `.door { text-align: center }` below, because it names the one thing to
   watch — a door whose answer grows past two lines. */
/*
 * 🔴 M023 TASK 4 — TIGHTER, WIDER, AND EQUAL IN SIZE BY MEASUREMENT.
 * OWNER RULING, 2026-09-04: "reduce the margins, make them VISUALLY EQUAL IN
 * SIZE. Widening them to achieve that is authorised."
 *
 * WHAT WAS ACTUALLY UNEQUAL, measured before anything was changed rather than
 * taken from the ruling: at 1440 the three cards were ALREADY the same box —
 * 384 x 325.2 each — because a grid item stretches. What was unequal was at 390,
 * where they stack and each is its own row: 325.2, 325.2 and 298.8, a 26.4px
 * spread caused by one description wrapping to two lines where the others wrap
 * to three. And INSIDE the cards at both widths the price line floated at a
 * different height per card, because only the CTA was pinned.
 *
 * SO TWO DECLARATIONS DO IT, AND NEITHER IS A FIXED HEIGHT:
 *   `grid-auto-rows: 1fr` makes every ROW of the grid the height of its tallest
 *   card. At 1440 there is one row and it changes nothing; at 390 there are
 *   three and it equalises all of them. A min-height would have been a literal
 *   R27 could not accept and a number that goes stale the next time the copy
 *   changes; this is derived from the content every build.
 *
 *   `.door-desc { flex: 1 }` gives the description the slack, so the price and
 *   the button sit on a shared baseline across the row instead of only the
 *   button doing. `.door-cta { margin: auto 0 0 }` below is now redundant on
 *   the happy path and is KEPT: it is what still pins the button if the
 *   description is ever absent, which a fifth deal with no description would be.
 *
 * TIGHTER AND THEREFORE WIDER, which is the widening the ruling authorises:
 * gap --space-lg -> --space-sm (24px -> 16px) and padding --space-xl ->
 * --space-lg (28px -> 24px). At 1440 the card goes 384 -> 389.3 wide.
 */
.door-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-auto-rows: 1fr;
  gap: var(--space-sm);
}
.door {
  display: flex;
  flex-direction: column;
  padding: var(--space-lg);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
}
/*
 * CENTRED (M004-B Part 7) — OWNER RULING, and it reverses the note above.
 *
 * M004-A argued against exactly this: a door carries a label, a two-line
 * answer, a price line and a control, and centring four things of four
 * different widths gives four centre points and a ragged left edge on the
 * answer, which is the part anybody actually reads. That reasoning still
 * describes a real risk and it is recorded here rather than deleted — the
 * answers are the one element to watch if a future door gets a longer line.
 *
 * The owner has ruled for centred and the ruling is what ships. It is also
 * more defensible now than it was then: with the price line given weight
 * below, each card reads as label / answer / PRICE / control down a single
 * axis rather than as four ragged blocks.
 */
.door { text-align: center; }
.door-label { font-size: var(--text-3xl); margin: 0 0 var(--space-xs); }

/*
 * THE PRODUCT NAME (M005 Task 1). It was the first third of the price line —
 * "Sample Special · $9.99 · 3 × 1 oz bag" — set at --text-sm in muted grey,
 * where the name of the product read as a caption on a number. It has its own
 * row now, directly under the question it answers.
 *
 * It takes the strong ink and medium weight that .door-amount already carries,
 * at body size: it has to read as a NAME rather than as a second heading, so it
 * must not compete with .door-label above it (--text-3xl) and must not sink
 * back into the description below it (body size, body ink). Weight, not size,
 * is what separates it — the same device .door-amount uses one row down, which
 * is why this adds no new R28 pair: --color-text-strong on --color-surface is
 * already in the card.
 * depends: --color-text-strong
 */
.door-name {
  margin: 0 0 var(--space-xs);
  font-weight: var(--weight-medium);
  color: var(--color-text-strong);
}

/* The deal description, straight from deals.json. Was .door-a, which named the
   authored `answer` field the door used to carry; the field is gone and the
   class is renamed rather than left describing something that no longer
   exists. Same ink, same ground, same spacing rung. */
/* M023 Task 4: `flex: 1` gives this element the card's slack, so the price and
   the button below it share a baseline across every card in the row rather than
   floating at whatever height the description happens to end. See the note on
   .door-grid for why this and grid-auto-rows are the whole of the fix. */
.door-desc { margin: 0 0 var(--space-sm); flex: 1; }

/*
 * THE PRICE IS THE DECISION (Part 7).
 *
 * The whole line was --text-sm in muted grey, so the number a visitor is
 * choosing on was simultaneously the smallest and the faintest thing in the
 * card, sitting between a deal name they have not learned yet and a pack
 * breakdown that only matters once they care. The line keeps its quiet size;
 * the AMOUNT steps up to body size, the strong ink and medium weight.
 *
 * It is emphasis, not promotion. There is no second number to compare it to,
 * no strike-through and no saving — R38 and the standing brand rule both
 * forbid that, and all three doors are treated identically because the owner
 * ruled against promoting one of them.
 */
/* M005 Task 1: the quiet --text-sm/muted setting that used to style the name
   and the pack breakdown around the amount is GONE WITH THEM. Nothing but
   .door-amount sits in this paragraph now, so the wrapper carries spacing
   only; leaving a font-size and a colour here would be styling no text. */
.door-price {
  margin: 0 0 var(--space-lg);
}
.door-amount {
  font-size: var(--text-md);
  font-weight: var(--weight-medium);
  color: var(--color-text-strong);
}
.door-cta { margin: auto 0 0; }

/* --- the gift ------------------------------------------------------------ */
.gift-lead { max-width: var(--width-measure); color: var(--color-text-muted); }
.gift-message {
  margin-block: var(--space-2xl);
  max-width: var(--width-measure);
}
.gift-message label { display: block; font-weight: 600; margin-bottom: var(--space-3xs); }
.gift-message-hint { margin: 0 0 var(--space-2xs); font-size: var(--text-sm); color: var(--color-text-muted); }
.gift-message textarea {
  width: 100%;
  padding: var(--space-2xs);
  font: inherit;
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  resize: vertical;
}
.gift-message-count {
  margin: var(--space-3xs) 0 0;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  min-height: 1.2em;   /* reserved, so the count appearing cannot shift the page */
}
/* What is and is not in the box. Facts, set quietly — this is the section a
   buyer needs to have read, not the one that should shout. */
.gift-facts {
  list-style: none;
  margin: var(--space-2xl) 0 0;
  padding: var(--space-lg);
  max-width: var(--width-measure);
  background: var(--color-surface-sunken);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.gift-facts li + li { margin-top: var(--space-2xs); }

/*
 * 767.98px, NOT 768px — EXACTLY ONE RULE MAY APPLY AT A BOUNDARY.
 * depends: --breakpoint-768
 *
 * `max-width: 768px` and `min-width: 768px` BOTH match at exactly 768, so this
 * block and the six `min-width: 768px` blocks above all applied at once. The
 * homepage rendered in a state that existed at no other width: the category
 * band already three desktop columns while the featured grid was still 2-up and
 * the doors were still stacked full width. Measured: 7,510px at 767, 5,368px at
 * 768, 4,061px at 769 — 1,307px of difference across one pixel, at the exact
 * width an iPad reports in portrait.
 *
 * 768 IS GIVEN TO THE DESKTOP SIDE, deliberately. The documented breakpoint on
 * this project is "768px, at which the product page goes two columns" — that is
 * the `min-width: 768px` family, six blocks and the older meaning. This block
 * is the newcomer that created the clash, so it is the one that moves. It is
 * also the smaller change and it leaves 769 — which was already correct —
 * untouched.
 *
 * 0.02 rather than 0.01 or 1: it is below the smallest step any current browser
 * resolves a media query at, and it cannot collide with a fractional viewport
 * width the way `767px` would leave a real gap on a 767.5px screen.
 *
 * ⚠ THE `sizes` ATTRIBUTES IN THE TEMPLATES STAY AT `768px` AND MUST NOT FOLLOW
 * THIS. R23 parses them with /max-width:\s*(\d+)px/ — INTEGERS ONLY — so
 * `767.98px` there would not match, R23 would silently stop seeing that
 * breakpoint, and the rule would go on passing while checking less than it
 * thinks. That is the R11/R38 apostrophe failure exactly. The cost of leaving
 * them is that at exactly 768 an image may request one rung more than its box
 * needs, which is the safe direction and one pixel wide.
 */
@media (max-width: 767.98px) {
  /*
   * TWO COLUMNS ALL THE WAY DOWN, and this is the one place the featured grid
   * deliberately parts company with /shop/.
   *
   * The first version dropped to a single column below 560px, matching the
   * shop grid. Measured at 390px that put four 4:5 photographs at full width
   * one under the other and took the homepage from 4,314px to 8,175px — the
   * featured band alone was most of the page. A shop listing is a place you
   * scroll on purpose; the homepage taster is passed through on the way to
   * something else, and it must not cost four screens to walk past.
   *
   * At 390px each card is ~168px wide, which is larger than the 84px gallery
   * thumb and about the size of a shop card on a phone. Nothing is too small
   * to see; there is simply less scrolling to do.
   */
  .featured-grid { grid-template-columns: repeat(2, 1fr); }
  .door-grid { grid-template-columns: 1fr; }
}

/* ---------------------------------------------------------------------------
   NAVIGATION FURNITURE IS NOT AN ACCENT (M003-D Part 7.2)
   ---------------------------------------------------------------------------
   A product page measured FIVE ember elements and a multi-category one SIX,
   against a budget of two or three. Four of them were links that exist to say
   where you are, not to be pressed: the breadcrumb's "Home" and its category,
   and the "Categories:" line at the foot. M003-D made that worse by design —
   giving a product two categories gives it two more ember links — which is
   what surfaced a count that had been over budget since the palette landed.

   The fix is not less ember, it is ember in the right place. These links keep
   their underline, their 44px target and their focus ring; they simply stop
   competing with the one control on the page that the visitor is meant to
   press. Product pages go 5 -> 2, and a multi-category page 6 -> 2, which is
   BELOW the budget rather than at it.

   Deliberately scoped to these two contexts. Body links in captured copy stay
   accent-coloured: there the colour is what distinguishes a link from the
   sentence around it, and nothing else would. */
.breadcrumb a,
.product-cats a {
  color: var(--color-text-muted);
  text-decoration: underline;
}
.breadcrumb a:hover,
.product-cats a:hover { color: var(--color-text-strong); }

/* ---------------------------------------------------------------------------
   PRODUCT CARDS SHOW THE FRAME THEY WERE NORMALISED INTO (M003-D)
   ---------------------------------------------------------------------------
   .card-media is 1/1 and is shared by product, post and deal cards. That was
   right when the product images were captured WooCommerce photographs of
   assorted shapes — a square with object-fit: cover was the only thing that
   made a mixed set look like a grid.

   It is wrong for the owner's own shots. Those are normalised to 4:5 with the
   jar at 78% of the frame height, so cropping them back to a square removes a
   fifth of that height and the jar ends up at ~97% of the card — lid against
   the top edge, base against the bottom. The grid still looked consistent,
   because every card cropped identically, but it looked consistently cramped,
   and the margin the normalisation exists to create was being thrown away by
   the stylesheet at the last step.

   Scoped to the product card so post and deal cards keep the square. */
.product-card .card-media { aspect-ratio: 4 / 5; }

/* ---------------------------------------------------------------------------
   THE RECIPES GRID AND POST PAGES (M014)
   ---------------------------------------------------------------------------
   DESIGNED AT 390 FIRST. Most visitors are on phones, so two columns is the
   design and three at 768 is the adaptation. That is the opposite of how the
   rest of this stylesheet grew, and it is deliberate for this section: a feed
   of square photographs is the one place where the phone layout is the primary
   one rather than the narrow case of a desktop layout.

   THE COLUMN COUNT IS EXPLICIT, NOT `auto-fill`. .post-grid used
   `auto-fill minmax(220px, 1fr)`, which at 390 resolves to ONE column and makes
   the page as tall as the number of posts times the width of the screen. A feed
   is browsed by scanning, so two-up at 390 is the right shape and it has to be
   stated rather than emerge from a minimum width.

   EXACTLY ONE QUERY MATCHES AT THE BOUNDARY. The `max-` side is 767.98px and
   the `min-` side is 768px, which is the rule M012 established after the
   homepage rendered at exactly 768 in a state that existed at no other width.
   768 belongs to the DESKTOP side. */
.recipe-grid {
  list-style: none; margin: var(--space-xl) 0 0; padding: 0;
  display: grid; gap: var(--space-lg);
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
  .recipe-grid { grid-template-columns: repeat(3, 1fr); }
}

/* Media / title / date, shared across the row, so a two-line caption title in
   one card does not push its date below its neighbour's. Same subgrid idiom and
   the same `row-gap: 0` reason as .product-card: a subgrid inherits its
   parent's gap and this parent's gap is the space BETWEEN cards. */
.recipe-card { display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 0; }
.recipe-card a {
  text-decoration: none; color: inherit;
  display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 0;
  text-align: center;
  border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md);
  overflow: hidden; height: 100%; background: var(--color-surface);
  transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.recipe-card a:hover { border-color: var(--color-accent); transform: translateY(-2px); }
.recipe-card .card-title {
  font-size: var(--text-sm); line-height: var(--leading-tight);
  margin: var(--space-xs) var(--space-xs) var(--space-3xs);
}
.recipe-date {
  margin: 0 var(--space-xs) var(--space-sm);
  font-size: var(--text-xs); color: var(--color-text-muted);
}

.recipes-lead, .recipes-empty { max-width: var(--width-measure); color: var(--color-text-muted); }

/* THE POST PAGE.
   The figure is NOT capped to the measure. --width-measure caps RUNNING TEXT at
   about 72 characters, and a photograph is not running text — capping it would
   leave a 34rem picture in a 47rem column with the caption beneath it at a
   different width, which reads as a layout fault. The caption paragraphs are
   capped, because they are running text and that is exactly what the token is
   for. */
.recipe-post .recipe-figure { margin: var(--space-lg) 0 0; }
.recipe-post .recipe-figure img {
  width: 100%; height: auto; border-radius: var(--radius-md); display: block;
}
.recipe-caption { margin-top: var(--space-lg); }
.recipe-caption p { max-width: var(--width-measure); white-space: pre-line; }
.recipe-post .recipe-date { margin: var(--space-2xs) 0 0; }
.recipe-source, .recipe-back { margin-top: var(--space-lg); }
