/*
 * 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);

  /*
   * THE CORE VALUES SCRIM (M040, raised again in M041) — OWNER RULINGS,
   * 2026-09-24. The band has now shipped 62%, 72% and 76% in three
   * milestones, and EVERY ONE OF THOSE WAS OWNER-APPROVED IN ITS TURN. Each
   * is a supersession, not a correction.
   *
   * 🔴 THIS IS A SECOND SCRIM VALUE AND M038 ARGUED AGAINST HAVING ONE. That
   * argument is recorded above the band's ::before and it still stands on its
   * own terms: a site with one scrim cannot have two drift apart. It is
   * overruled here by an owner ruling made on the picture, not by a taste
   * call — the owner saw 62% on the band, saw the local text halos proposed
   * as the alternative, and ruled the flat 72% instead; M041 then deleted the
   * photograph's blur and took the shade to 76% to pay for the sharper ground.
   *
   * 🔴 WHY IT IS NOT A CHANGE TO --color-hero-scrim. That token is painted in
   * TWO places — .home-hero-media::after and this band — so raising it here
   * would darken the HERO as a side effect of a ruling about /about/. The
   * hero's 62% is a separate measurement on a separate photograph, re-affirmed
   * against a proposed reduction in M037 (see heroVideo.json's contrastNote),
   * and R39 pins it to hero.json's scrimAlpha so it cannot move quietly. The
   * ruling was about the band. Only the band moves.
   *
   * 76% is a measurement as much as 62% and 72% were, and this is what each
   * one buys. Pure white under the text — which the unblurred photograph
   * really does contain, at every viewport's cover crop — composites to:
   *
   *     0.62   rgb(115 111 109)   4.74:1 against bone
   *     0.72   rgb( 92  88  85)   6.71:1
   *     0.76   rgb( 83  79  76)   7.76:1   <- shipped
   *
   * That is the FLOOR for any ink anywhere on the photograph, not an average.
   * Bone body copy measures worst 7.76:1 and p99 7.83 at 390, 7.86 at 1440,
   * and clears AA on 100.0% of the band at all five measured viewports. The
   * cost is the photograph's own light and shade coming through at 24%
   * instead of 28%, about 86% of what 72% showed and 63% of what 62% did; the
   * owner has seen that and chose it, twice.
   *
   * 🔴 THE 76% AND THE MISSING BLUR ARE ONE RULING, NOT TWO. Do not lower this
   * back towards 72% without putting the blur back, and do not reason about
   * either in isolation: a blurred ground has no speculars left to survive, an
   * unblurred one is carrying the lamp filament straight into the type. R66
   * composites THIS declaration over the built derivative on every build and
   * fails below 4.5:1, which is the check M040 recorded as missing.
   *
   * ⚠ NO HALOS, NO TEXT-SHADOW, NO GRADIENT. Flat across the whole band. Local
   * text halos were the alternative put to the owner and were ruled too
   * abrupt; a gradient was never on the table. Do not reintroduce either as a
   * way of buying the photograph back.
   *
   * depends: --color-surface-inverse
   */
  /*
   * 🔴 M043 §6 — 76% BECOMES 62%, AND THAT IS NOT A WEAKENING. OWNER RULING,
   * 2026-09-25, OPTION A OF THREE RENDERED.
   *
   * The problem the ruling is answering: at 0.76 with no blur the owner loves
   * the photograph and still cannot read the text. Measured on a replica of the
   * live band, the texture behind the body copy — its local standard deviation
   * — was 4.21. A scrim can only make a picture darker; it cannot make it
   * calmer, and what makes small type hard to read on a photograph is not the
   * average level, it is the variation under the stems.
   *
   * SO THE BAND GETS LIGHTER AND EACH VALUE GETS ITS OWN FROSTED CARD. Between
   * the cards the photograph is SHARPER and BRIGHTER than it has ever been;
   * under the text it is darker than it was at 0.76, because the card's own
   * 38% composites with the 62% behind it:
   *
   *     1 - (1 - 0.62)(1 - 0.38) = 0.7644   against the old flat 0.76
   *
   * The blur removes the texture; the ALPHAS carry the contrast floor. That
   * separation is the whole design and it is why R66 was rewritten to check the
   * COMPOSITE rather than this one number — a rule that went on reading 0.62
   * alone would report this band as having been made 14 points less legible on
   * the exact commit that made it more.
   *
   * ⚠ THE HERO'S SCRIM IS A DIFFERENT TOKEN AND DOES NOT MOVE (M041). The
   * kicker and "Our Core Values" stay on this scrim alone, with no card behind
   * them, which is why 0.62 still has to clear AA by itself.
   *
   * depends: --color-surface-inverse
   */
  --color-values-scrim: color-mix(in srgb, var(--color-surface-inverse) 62%, transparent);

  /*
   * THE FROSTED CARD (M043 §6). Alphas, blur and radius as tokens, because
   * R66 reads the alphas back out of this block and composites them, and
   * because R27 will not accept any of them written literally in a rule.
   *
   * ⚠ THE OWNER'S SPEC SAID `rgb(22 17 14 / .38)` AND THIS SHIPS
   * --color-surface-inverse (#1d1713) AT 38%. The two darks are seven levels
   * apart on red and five on blue; at 38% alpha over a photograph that is under
   * two levels of difference, and it keeps the band inside the palette R28
   * already classifies rather than introducing a second warm charcoal that
   * looks like a mistake beside the first. THE ALPHAS ARE SHIPPED EXACTLY —
   * they are what the contrast floor is made of, and they are what was ruled on.
   */
  --alpha-values-card: 0.38;
  /*
   * WITHOUT backdrop-filter THE CARD IS THE ONLY THING BETWEEN THE TEXT AND THE
   * PHOTOGRAPH, so it carries more. 0.50 composites to 1 - 0.38 x 0.50 = 0.81,
   * which is DARKER than the blurred path and darker than the old flat 0.76 —
   * the fallback is not a degraded version, it is a heavier one, because it has
   * no blur to calm the texture and must buy legibility with level alone.
   */
  --alpha-values-card-fallback: 0.50;
  --blur-values-card: 16px;
  --saturate-values-card: 1.1;
  --color-values-card: color-mix(in srgb, var(--color-surface-inverse) 38%, transparent);
  --color-values-card-fallback: color-mix(in srgb, var(--color-surface-inverse) 50%, transparent);
  /* A hairline of bone at 8%, which is what separates two adjacent cards over a
     dark part of the photograph. It is not a border in the R28 sense — nothing
     is read against it — and it is registered in AA_EXEMPT with that reason. */
  --color-values-card-edge: color-mix(in srgb, var(--color-text-inverse) 8%, transparent);
  --radius-values-card: 20px;
  --radius-values-card-phone: 16px;
  /*
   * 28 / 26 / 30 and 20 / 18 / 22, the owner's own figures from the rendered
   * option. Only the 28 and the 20 are on the space scale; the rest are not,
   * and they are declared here as named values rather than rounded onto the
   * scale, because the scale exists to stop numbers being invented and these
   * were chosen. The asymmetry is deliberate: the extra pixels at the bottom
   * balance the optical weight of a centred paragraph above a card edge.
   */
  --space-values-card: 1.75rem 1.625rem 1.875rem;
  --space-values-card-phone: 1.25rem 1.125rem 1.375rem;

  /* ------------------------------------------------------------------ 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;

  /* =========================================================================
   * M042 — OUR FAVORITES: THE SCROLL STORY'S OWN TOKENS
   * =========================================================================
   * Six of the eight numbers below have a phone value as well as a desktop
   * one, and both live in this block: the section's phone treatment is
   * redefined in a `:root` inside the 820px query at the foot of this file, so
   * every value is a token everywhere it is used and R27 never meets a literal.
   *
   * 🔴 820px IS THIS SECTION'S BREAKPOINT AND IT IS NOT THE SITE'S 768. The
   * owner's canvas scopes the whole section — type, grid, tile radius, line-up
   * pitch — to 820, and a section whose type switched at one width and whose
   * grid switched at another would be incoherent in the 52px band between
   * them. It is deliberate, it is confined to this section, and nothing else in
   * this stylesheet moves at 820.
   *
   * WHAT THE CANVAS ASKED FOR AND WHAT THE SCALE GAVE, stated rather than
   * quietly rounded, because CLAUDE.md's R27 rationale is about exactly this
   * kind of drift:
   *     eyebrow desktop   14.4px asked, 14.4px shipped   (--text-sm, exact)
   *     eyebrow phone     12.0px asked, 12.8px shipped   (--text-2xs)
   *     blurb label       11.5px asked, 11.5px shipped   (--text-3xs, exact)
   *     product name      20.0px asked, 20.0px shipped   (--text-2xl, exact)
   *     price             15.0px asked, 15.2px shipped   (--text-md)
   *     heading/rule gap  18px asked,   20px shipped     (--space-md)
   * The heading has no near neighbour on the scale at either size, so it gets
   * a token of its own rather than three-quarters of a step of drift.
   */
  --text-favorites-heading: 3.25rem;      /* 52px */
  --text-favorites-eyebrow: var(--text-sm);
  --radius-favorites-tile: 20px;
  --radius-favorites-slot: 10px;
  --favorites-pitch: 170px;               /* slot centre to slot centre */
  --favorites-slot-w: 150px;              /* the slot itself; the gap is the difference */
  --favorites-rule-w: 56px;
  --favorites-pin: 270vh;
  --width-favorites-grid: 1280px;
  /* Derived by measurement, not chosen: the widest two-column grid whose final
     frame fits the stage a phone actually shows. See the 820px block. */
  --width-favorites-grid-phone: 252px;
  /* The rule under the heading: --color-text-strong at 28%, built the way
     --color-hero-scrim and --color-values-scrim are built rather than written
     out as a fifth literal.
     depends: --color-text-strong */
  --color-favorites-rule: color-mix(in srgb, var(--color-text-strong) 28%, transparent);

  --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
 * .favorites-card (`.featured-card` until M042 renamed it) 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); }

/*
 * Suggested Uses (M032). It shares .ingredients' size deliberately: the two
 * are sibling labelled lines under the description, and giving the owner's
 * guidance its own scale would rank it against the jar's ingredient list for
 * no reason anybody could state.
 *
 * NO COLOUR IS DECLARED, so it inherits body text and spends nothing from the
 * ember budget — product pages still measure 2. The measure cap is the same
 * --width-measure the description already uses; the longest value here runs to
 * 54 characters, but capping the box is what stops it setting a longer line
 * than the prose directly above it at 1440.
 */
.product-uses { font-size: var(--text-sm); max-width: var(--width-measure); }

/*
 * 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` was here and is deleted with the line it styled (M032 Task 1). It
   pulled that paragraph up under the price with a negative top margin. A
   selector kept after its only element is gone is not free: it is the thing
   that makes a retired block reappear looking correct.

   The retired string is NOT quoted here. This stylesheet is a shipped asset,
   and a comment naming it would put the withdrawn wording back into what the
   site serves — which is the R2 shape. The wording, and the reason it went,
   are in site.json's `backorderNoticeRetiredNote`, which ships nowhere. */

/* ---------------------- 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); }

/*
 * THE SIZE CONTROL — Small Jar or Refill (M036).
 *
 * 🔴 IT IS A <select> AND IT CARRIES ALMOST NO CSS, WHICH IS THE POINT. The
 * owner ruled on 2026-09-14 for a box field showing "Select one", so the
 * control is now the house `.field` shape — the same box, border, radius and
 * `--tap-min` floor as the order form's payment select, inherited from
 * `.field input, .field select, .field textarea` above. A size control that
 * looked like a thing of its own is exactly what the ruling moved away from.
 *
 * THE RADIO-BOX RULES THAT STOOD HERE ARE DELETED, NOT COMMENTED OUT. They
 * styled `.variant-choice`, `.variant-options`, `.variant-option`,
 * `.variant-box`, `.variant-name`, `.variant-unit` and `.variant-price`, none
 * of which any page renders now — and dead CSS on this site is not inert, it is
 * a finding: `audit-design.mjs` reports rules that matched nothing, and R22
 * exists because artifacts nobody references are how stale things survive. The
 * reasoning they carried (the visible radio, `:has(:checked)` as enhancement,
 * the 44px input) is kept in variants.json's `supersededControlNote`, which is
 * where the superseded ruling lives.
 *
 * `appearance` IS LEFT ALONE, for the reason written against the payment
 * select: stripping the native arrow means drawing one, and a select that does
 * not look like a select is a control people do not try to open.
 */
.variant-field { width: 100%; margin-bottom: 0; }
/*
 * THE SELECT IS SIZED TO ITS OWN TEXT (owner ruling, 2026-09-14): "it only
 * needs to be as long as the text itself is".
 *
 * This OVERRIDES the shared `.field input, .field select, .field textarea`
 * rule, which sets width 100% so a column of checkout fields lines up. That is
 * right on /cart/, where the controls are a form; it is wrong here, where the
 * control is one row in a product column and a full-width box reads as an
 * input to be filled rather than a short list to be picked from.
 *
 * 🔴 `max-width: 100%` IS LOAD-BEARING AND IS NOT DECORATION. `width: auto` on
 * a select sizes it to its WIDEST OPTION, which is content this template does
 * not control - a longer product name or a third size makes it wider, and at
 * 360px it would push past the viewport and give the whole page a horizontal
 * scrollbar. That is the exact defect `measure-overflow.mjs` exists for, and
 * the reason the cap is here rather than left to be discovered.
 */
/*
 * ⚠ `.field.variant-field`, NOT `.variant-field` — AND THE DOUBLED CLASS IS
 * LOAD-BEARING, NOT A TYPO. There are TWO later rules setting this select to
 * 100%: the shared `.field input, .field select, .field textarea` box and a
 * second `.field select { width: 100% }` beside the payment control. Both are
 * specificity (0,1,1), the same as `.variant-field select`, and both sit
 * LATER in this file - so the plain form lost on source order and the select
 * measured 576px at 1440 and 320px at 360, the full column, exactly as before.
 * FOUND BY MEASURING IT: the rule was written, the build passed, and the box
 * was unchanged. Doubling the class takes it to (0,2,1) and it wins on
 * specificity rather than on position, so moving either block cannot undo it.
 *
 * 🔴 AND SPECIFICITY WAS NOT ENOUGH ON ITS OWN. `.field` is `display: grid`, so
 * this select is a GRID ITEM, and a grid item stretches to its track by
 * default - `justify-items` is `stretch`. `width: auto` on a stretched item is
 * the track width, so the box did not move: 576px at 1440, 320px at 360, the
 * full column, with the correct rule applying and winning. `justify-self:
 * start` is what actually sizes it to its content, and it is the second thing
 * MEASURING caught that reading the stylesheet did not.
 */
.field.variant-field select { width: auto; max-width: 100%; justify-self: start; }


/*
 * `.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);
}

/*
 * The size under a single line's product name (M036). Same ink, same size and
 * same offset as .bundle-contents directly above: both answer "what is this
 * row", both sit under the name, and giving them two appearances would imply a
 * distinction between a deal's contents and a jar's size that does not exist.
 */
.line-variant {
  display: block; margin-top: var(--space-3xs);
  font-size: var(--text-xs); color: var(--color-text-muted); font-weight: var(--weight-normal);
}

/*
 * THE DEAL NUDGE IS A CHARCOAL BAND (owner ruling, 2026-09-14). He asked for
 * "a pop up banner of a certain noticable but complimentary color", and chose
 * charcoal over ember once the measurement below was put to him.
 *
 * 🔴 IT IS NOTICEABLE BY CONTRAST, NOT BY HUE, AND THAT IS A MEASURED DECISION
 * RATHER THAN A TASTE ONE. `/cart/` ALREADY PAINTS SIX EMBER ELEMENTS when this
 * nudge is showing — double the "no more than two or three per page" budget —
 * and nothing had ever reported it, because `count-ember.mjs` walks every page
 * with an EMPTY localStorage, so the nudge is `hidden` and uncounted and the
 * cart page has only ever been measured in a state no shopper reaching it is
 * in. Adding orange to the one page already worst over budget was the one
 * thing the palette could not absorb.
 *
 * It is also the answer this palette already gave once: `.deals-band` is
 * charcoal for the same reason, written down as "a band of ember behind the
 * whole thing is the fastest available route to a rib shack".
 *
 * WHAT THIS SWAP COSTS IN EMBER: the ember border and the ember link both go,
 * so a nudging cart drops from SIX ember elements to FOUR. Still over the
 * documented budget — the remainder is the header's Shop button, a Remove
 * button and the order form's submit, none of which is this milestone's to
 * move — but it is the first time that number has been measured at all.
 *
 * THE LINK IS BONE, NOT EMBER, AND THAT IS FORCED. `--color-accent` measures
 * 1.9:1 on this charcoal; it is a 4.5:1 ink for LIGHT grounds only. Painting
 * the call to action ember here would be an accessibility failure introduced
 * by reflex, which is the exact trap `--color-text-inverse-muted` was created
 * to avoid in M003-A. Bone on charcoal is 16.92:1 and is already pinned in
 * AA_PAIRS as the deals band's own pairing, so this adds NO pair R28 has not
 * already judged.
 *
 * depends: --color-surface-inverse, --color-text-inverse
 */
.nudge {
  margin: var(--space-lg) 0;
  padding: var(--space-sm) var(--space-md);
  background: var(--color-surface-inverse);
  color: var(--color-text-inverse);
  border-radius: var(--radius-md);
  font-size: var(--text-lg);
  display: flex; flex-wrap: wrap; gap: var(--space-2xs) var(--space-sm); align-items: baseline;
}
/*
 * UNDERLINED AT REST, unlike `.deals-band-list a` which underlines on hover.
 * That band's links are whole cards — the card IS the target and its shape
 * says so. This one is a few words inside a sentence, where the underline is
 * the only thing distinguishing a link from the prose around it, and colour
 * cannot do that job here because the sentence is already bone.
 *
 * The 44px tap target comes from `.nudge-link::after` further up, unchanged.
 */
.nudge-link {
  font-weight: var(--weight-medium);
  color: var(--color-text-inverse);
  text-decoration: underline;
}

/*
 * 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); }
/*
 * CITY AND ZIP SHARE A ROW, and the ratio is the point rather than the row.
 * depends: .field
 *
 * A ZIP is five characters and a city is not, so giving them equal halves would
 * leave the shorter field looking like it is waiting for more than it wants.
 * `2fr 1fr` sizes each control roughly to what goes in it, which is the same
 * argument the cart's monospaced order field makes: a field's width is a claim
 * about its contents.
 *
 * `.field` already carries the bottom margin, so this only supplies the column
 * gap. Below 560 it collapses to one column and the two fields stack in source
 * order — a 1fr ZIP inside a 350px column is not a small field, it is a cramped
 * one, and the pair is the last thing between a customer and the order button.
 */
.field-pair { display: grid; grid-template-columns: 2fr 1fr; gap: 0 var(--space-sm); }
@media (max-width: 560px) { .field-pair { grid-template-columns: 1fr; } }

@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; }
/*
 * M037 — THE HERO VIDEO, PAINTED OVER THE STILL.
 *
 * Absolutely positioned over the <picture> rather than replacing it, so the
 * first paint is the ordinary responsive still (which is frame 0 of this very
 * loop) and the video covers it when it starts. See home.njk for why there is
 * no `poster` attribute.
 *
 * NO z-index, DELIBERATELY. The scrim is `.home-hero-media::after`, and a
 * pseudo-element paints after every child in the same stacking context — so
 * the scrim lands over the video for free. Giving the video a z-index would
 * lift it above the scrim and the headline would sit on raw fire, which is a
 * contrast failure that no token pair can see.
 *
 * `object-fit: cover` for the same reason the still has it: the box's height
 * is `100svh - var(--header-h)` above 768 and 30rem below it, so the box is
 * never 16:9 and something has to give. What that crops at each width, and
 * that the flame survives the phone crop, is measured in heroVideo.json.
 */
.home-hero-video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  /* It is decorative and it is never a control: no pointer, no selection. */
  pointer-events: none;
}
.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 — .favorites-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 .favorites-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 select, .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; }
/*
 * THE FIRST <select> ON THIS SITE (the order form's payment method).
 * depends: --tap-min, --color-surface, --color-border-strong
 *
 * It takes the shared input box above rather than a look of its own, so the
 * form reads as one column of controls. Two things are its own:
 *
 * `appearance` is left ALONE deliberately. Stripping the native arrow means
 * drawing one, and a select that does not look like a select is a control
 * people do not try to open — the same class of defect as placeholder-as-label
 * two rules up. The platform's own affordance is the one everybody recognises.
 *
 * `--tap-min` matters more here than on a text input. A select is opened by
 * TAPPING it rather than by typing into it, so its box is the whole target;
 * the shared rule already sets that floor and this only records why it must
 * stay. The 16px floor from `font: inherit` also holds — iOS Safari zooms the
 * viewport for a sub-16px control on focus, and this one is on the checkout.
 */
.field select { width: 100%; }
.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
 */
/* ⚠ M038 RENAMED `.about-values` TO `.about-value` HERE AND EVERYWHERE BELOW.
   The old class was a COLUMN of three values; the band's cell is ONE value, so
   the plural stopped describing the box. This is exactly the rename the
   about.njk header warns about — "if a wrapper is ever added or renamed here,
   THE CAP GOES WITH IT" — and the measure cap moved with it in the same edit,
   further down this block. A rename that left the cap behind would not error:
   the selector would simply match nothing and the value bodies would run to
   the full column width with a passing build. */
.about-page .about-value,
.about-page .about-value 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-value 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. OWNER RULING, 2026-09-04.
 * 🔴 M030 TASK 1 — IT REACHES THE EDGE OF THE PAGE. OWNER RULING, 2026-09-08.
 *
 * The Core Values section takes warm ash while the intro above and the FAQs
 * below keep bone. M023 bled it to the CONTAINER edge — `min(--width-container,
 * 100cqi)` — which was already the full width of the page at every width up to
 * 1200 and stopped 112.5px short at 1440 and 672.5px short at 2560. Measured,
 * not assumed: at 390, 768 and 820 this rule already painted x=0 w=clientWidth,
 * so the ruling changes the wide end only.
 *
 * 🔴 THE MECHANISM IS `cqi`, AND `vw` IS THE TRAP IT EXISTS TO AVOID.
 * `100vw` is the VISUAL viewport and INCLUDES the classic vertical scrollbar;
 * `documentElement.clientWidth`, which every other box on this page is laid out
 * against, does not. `width: 100vw; margin-left: calc(50% - 50vw)` is therefore
 * one scrollbar too wide, hangs half of that past the right edge, and the page
 * grows a HORIZONTAL scrollbar to reach content that is only out there because
 * of the vertical one. That is not a hypothetical — it shipped on the homepage
 * for two milestones (M004-K) and it is why R42 fails the build on a viewport
 * unit in the inline axis, and why R42's own text names cqi as the replacement.
 *
 * `100cqi` is the inline size of the nearest query container, which is `main`
 * — `container-type: inline-size; container-name: page-bleed`. `main` is a
 * block box in normal flow with no inline margin or padding, so its inline size
 * IS the layout width, scrollbar already subtracted. THIS IS MEASURED ON EVERY
 * RUN rather than asserted here: tools/m030-measure.mjs prints mainW beside
 * clientWidth at all five viewports and they are equal at all five. A box that
 * is exactly the layout width cannot exceed the viewport, which is the property
 * `100vw` lacks and the whole reason this is not written the usual way.
 *
 * WHY THE WRAPPER IS STILL A WRAPPER. Painting the heading and the grid
 * separately would leave a bone stripe between them at exactly the height of
 * the heading's margin — M023's reason, re-checked and still true. The vertical
 * padding is what makes it a band rather than a stripe; the inline padding is
 * now a FLOOR on the gutter rather than the rail itself, which is
 * `.about-band-inner` below.
 *
 * `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.
 *
 * ⚠ `border-radius: var(--radius-md)` IS REMOVED, AND IT IS A JUDGEMENT CALL
 * WITH A COST, NOT A CONSEQUENCE OF THE RULING. The radius was a property of an
 * INSET PANEL — M023's band sat on the container rail with page ground either
 * side of it, and rounded corners are what an inset panel has. A band that
 * reaches the edge has no corners to round: the radius puts four notches of
 * bone into the screen edge, which reads as a rendering fault rather than a
 * decision. THE COST IS AT 390, where the band was ALREADY full width and has
 * been shipping those notches since M023 without complaint: this changes their
 * appearance at a width the owner did not rule on. It is recorded here so it
 * can be reversed in one line rather than re-derived.
 *
 * depends: --color-surface-sunken
 */
/*
 * 🔴 M038 — THE BAND IS A PHOTOGRAPH NOW, AND `.about-band-alt` IS GONE.
 *
 * OWNER RULING, 2026-09-19: the Core Values section becomes a full-bleed
 * tinted photographic band and moves to the TOP of the page. The warm-ash
 * tint that M023 introduced and M030 bled to the edge is replaced, not
 * tweaked, so the class that carried it is removed rather than left as a
 * second way to paint this box.
 *
 * ⚠ WHAT THAT COSTS, STATED BECAUSE IT UNDOES A RULING RATHER THAN EXTENDING
 * ONE. M023 Task R3's ruling was ALTERNATION — "Our Core Values carries the
 * OPPOSITE background to its neighbours" — down a page whose three sections
 * were INTRO, CORE VALUES, FAQs. Two things about that are now different: the
 * order is CORE VALUES, INTRO, FAQs, and the middle ground is a photograph
 * rather than the site's second surface. The alternation SURVIVES in the sense
 * the ruling was about — this section still reads as the opposite of its
 * neighbours, and more strongly than warm ash ever made it — but
 * --color-surface-sunken no longer appears on this page at all. That is a
 * consequence of the 2026-09-19 ruling and is recorded so it is not read as
 * M023 having been forgotten.
 *
 * THE FULL BLEED IS STILL `cqi` AND MUST NEVER BECOME `vw`. Nothing about that
 * changes because the paint changed. `100vw` is the VISUAL viewport and
 * INCLUDES the classic vertical scrollbar, so it is one scrollbar too wide,
 * hangs half of that past the right edge, and grows a horizontal scrollbar to
 * reach content that is only out there because of the vertical one. That
 * shipped on the homepage for two milestones (M004-K) and is why R42 fails the
 * build on a viewport unit in the inline axis. `100cqi` is the inline size of
 * `main`, scrollbar already subtracted.
 *
 * `isolation: isolate` so the scrim's stacking context is the band's own and
 * cannot interact with anything the page does above or below it.
 *
 * ⚠ NO `margin-top`. The band is the FIRST thing on the page now, and a top
 * margin here would leave a bone stripe between the header and the photograph
 * — the same stripe M023's wrapper exists to avoid, arriving from the other
 * direction. The band starts immediately below the opaque sticky header, which
 * is what the owner ruled on 2026-09-20 and what the homepage hero already
 * does.
 *
 * 🔴 M041 — THIS LINE WAS STILL POINTING AT --color-hero-scrim, AND M040's
 * OWN RECORD SAYS IT WAS RE-ANCHORED. It was not. M040 listed six places the
 * band's 62% was pinned and named "the `depends:` line above that rule" as
 * item 3; there are TWO `depends:` lines on this band — one above this rule,
 * one above the ::before that actually paints the token — and only the second
 * was moved. So for a whole milestone the band's own rationale declared a
 * dependency on a token it had stopped painting, and check-rationales.mjs
 * would have stayed silent through any future change to the scrim it DOES
 * paint. Reported here rather than fixed quietly, because "six, each named
 * with what happened to it" was the claim and one of the six did not happen.
 *
 * depends: --color-values-scrim
 */
/*
 * 🔴 THE 40px OF BONE BETWEEN THE HEADER AND THE BAND, REMOVED — AND IT WAS
 * FOUND BY MEASURING THE PIXELS, NOT BY LOOKING.
 *
 * `main` carries `padding: var(--space-3xl) 0 var(--space-5xl)`, so the band
 * began at y 109 under a header 69px tall: a 40px bone strip across the top of
 * the page, above a full-bleed photograph that is meant to be the first thing
 * on it. In a screenshot it reads as a margin somebody chose. It is not — it is
 * a prose page's top padding applying to a page that no longer opens with prose.
 *
 * THE HOMEPAGE ALREADY SOLVES THIS AND THIS IS THE SAME SOLUTION. `main:has(>
 * .home)` zeroes the block padding so the hero reaches the header, which is
 * exactly the arrangement the owner ruled for on 2026-09-20 — "the band starts
 * below the opaque header, as the homepage hero already does". A gap would have
 * been a third behaviour, neither his ruling nor the mockup.
 *
 * ⚠ TOP ONLY, NOT `padding-block`. The homepage zeroes both because it ends in
 * a full-bleed section too; /about/ ends in the FAQ, which still needs
 * --space-5xl beneath it before the footer. Zeroing both would jam the last
 * answer against the footer, which is the kind of thing that gets noticed a
 * milestone later.
 *
 * ANCHORED ON THE BAND, NOT ON `.about-page`. It is the full-bleed band that
 * makes the padding wrong, so any page that grows one gets the same treatment
 * without a second rule — and /about/ keeps its normal top padding if the band
 * ever leaves it.
 */
main:has(> .page > .about-values-band:first-child) { padding-top: 0; }

.about-values-band {
  position: relative;
  isolation: isolate;
  width: 100cqi;
  margin-left: calc(50% - 50cqi);
  margin-top: 0;
  padding: var(--space-4xl) var(--space-lg);
  overflow: hidden;
}

/*
 * THE PHOTOGRAPH. An <img> rather than a background-image, so it goes through
 * the ladder — see about.njk for why that is the whole reason a full-bleed
 * photograph is affordable here.
 *
 * `inset: 0` + `object-fit: cover` is the pair that makes it behave like a
 * background while staying a real responsive image. WITHOUT `object-fit` the
 * img would stretch to the band's aspect ratio and the kitchen would be
 * visibly squashed at 390, where the band is three times taller than it is
 * wide.
 *
 * ⚠ THE CROP IS NOT THE SAME PICTURE AT EVERY WIDTH, AND THAT IS MEASURED
 * RATHER THAN HOPED. `cover` centres, so at 2560 the band shows all 5045px of
 * the source's width and 53.4% of its height, while at 390 it shows 100% of
 * the height and only 18.0% of the WIDTH — a central strip that in this
 * photograph is the middle pendant lamp, the brightest object in the frame.
 * The phone therefore sees a far brighter ground than the desktop, which is
 * why every contrast figure for this band is per-viewport and why the 390 crop
 * is the one that governs. tools/m038-ground.mjs reproduces all of it.
 *
 * `z-index: -1` puts it behind the content inside the band's own isolated
 * stacking context, so it cannot slide behind the page.
 */
.about-band-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  z-index: -1;
}

/*
 * THE SCRIM. 🔴 M040 MOVED THIS BAND OFF --color-hero-scrim AND ONTO ITS OWN
 * --color-values-scrim AT 72%, ON AN OWNER RULING OF 2026-09-24 (evening);
 * M041 TOOK THAT TOKEN TO 76% ON A FURTHER RULING THE SAME DAY, TOGETHER WITH
 * DELETING THE PHOTOGRAPH'S BLUR. The token and the full reasoning are in
 * :root; what matters here is that the band no longer shares the hero's
 * number, and that this was the thing M038 spent a paragraph avoiding.
 *
 * M038'S ARGUMENT, KEPT BECAUSE IT WAS RIGHT AND WAS OVERRULED RATHER THAN
 * REFUTED. Swept on the real photograph at the real crops with
 * tools/m038-ground.mjs: 0.55 was the measured floor — bone cleared AA text on
 * 100% of the band at every viewport, and 0.50 dropped it to 98.9% at 390. The
 * band shipped 0.62 anyway, for two reasons that agreed: 0.55 left a 0.03
 * margin at the worst pixel, which is not a margin, and taking it would have
 * meant a SECOND scrim value on a site that had one. M040 pays that second
 * cost deliberately, in the other direction — the owner wanted the band
 * DARKER, and the hero must not follow it there.
 *
 * ⚠ SO THERE ARE NOW TWO SCRIMS AND THEY ARE ALLOWED TO DIFFER. The thing to
 * keep true is not that they match, but that each is measured on its own
 * photograph: R39 pins the hero's to hero.json, and R66 — new in M041 — pins
 * this one to about.json's valuesScrimAlpha and composites it over the built
 * values-kitchen derivative on every build. A future change that "tidies"
 * these back into one token is undoing a ruling, and now fails two rules
 * rather than none.
 *
 * 🔴 UNTIL M041 THERE WAS NO BUILD-TIME RULE ON THIS BAND AT ALL, and M040
 * recorded that gap rather than closing it: "R39 composites the hero's scrim
 * over the hero derivative on every build; the band has no equivalent, and
 * never has had one since M038." tools/m038-ground.mjs measured it, and a
 * tool a person runs in a milestone is not a guard. M041 closed it, and it
 * was M041's own ruling that made closing it urgent — the blur is gone, so
 * the ground under the type is the photograph's own speculars.
 *
 * ⚠ IT IS A PSEUDO-ELEMENT, NOT A BACKGROUND ON THE BAND. A background would
 * paint UNDER the <img>, which is itself inside the band; the tint has to be
 * between the photograph and the text, so it needs its own box.
 *
 * depends: --color-values-scrim
 */
.about-values-band::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-values-scrim);
  z-index: -1;
}

/*
 * EVERY INK ON THE BAND IS BONE, AND IT IS SET RATHER THAN INHERITED.
 *
 * 🔴 THE ICON COLOUR IS AN OWNER RULING, 2026-09-20, AND IT WAS DECIDED ON
 * MEASURED CONTRAST. Measured on this photograph at the real crops, the ember
 * token --color-accent #aa541f clears the 3.0:1 icon floor on only 48.6% of
 * the band at 390 and never reaches AA text anywhere on it. A lighter ember
 * near #f2b183 does clear the floor, but it measures 1.76:1 on bone — a
 * band-only colour, which is precisely the uncounted hex M004-B's ember budget
 * exists to prevent — and six ember icons would take /about/ from 1 ember
 * element to 7 against D13's budget of 2-3, independently of hue. Bone clears
 * 4.5:1 on 100% of the band at every viewport and keeps the count at 1.
 *
 * THE CONSEQUENCE IS THAT THE BAND IS MONOCHROME and the photograph does the
 * colour work. That was put to the owner as part of the ruling.
 *
 * 🔴 THE ICON STROKE IS SET EXPLICITLY AND IS NOT `currentColor`. M004-B's
 * trap: the mitten logo is an <a>, so currentColor inherited the link colour
 * and silently rendered ember on first build. This band contains links.
 */
.about-values-band { color: var(--color-text-inverse); }
.about-values-band .about-values-heading,
.about-values-band .about-value h2,
.about-values-band .about-value p,
.about-values-band .about-kicker { color: var(--color-text-inverse); }
.about-value-icon { stroke: var(--color-text-inverse); fill: none; }

/*
 * 🔴 THE BAND'S BODY TYPE IS BACK TO INTER 400 AT NORMAL TRACKING — OWNER
 * RULING, 2026-09-24 (evening, M040). THIS REVERSES M039.
 *
 * THERE IS NO RULE HERE ON PURPOSE. M039 set
 *
 *     .about-values-band .about-value p {
 *       font-weight: var(--weight-semibold);
 *       letter-spacing: -0.0125em;
 *     }
 *
 * and M040 DELETED both declarations rather than restating them as 400 and
 * `normal`. That is the whole point of the change: a declaration set to the
 * default still reads as a ruling, and the next person to touch this band
 * would have found a deliberate-looking `font-weight: 400` and wondered what
 * it was protecting. The band's paragraphs now take the body face the rest of
 * the site takes, by inheritance, because nothing says otherwise.
 *
 * WHY M039 RAISED IT, RECORDED SO THE ARGUMENT IS NOT RE-DERIVED FROM SCRATCH.
 * Bone body copy on a photograph is the one place on this site where text sits
 * on a ground that varies pixel by pixel. The scrim guarantees the CONTRAST
 * RATIO, but a ratio is not the whole of legibility: a 400-weight stem is about
 * one and a half device pixels at 16px, and against a ground moving underneath
 * it the eye can lose the stroke before it loses the contrast. More weight puts
 * more ink on the same ratio. That reasoning was sound and is not being called
 * wrong — it was answered a different way. M040 took the ground problem out at
 * the source by raising the band's scrim from 62% to 72%, which lifts the body
 * copy's worst pixel from 4.74:1 to about 6.7:1 and quiets the ground the
 * weight was compensating for. With the ground that much flatter the extra ink
 * is no longer buying legibility, and the owner preferred the lighter text.
 *
 * ⚠ THE TRACKING WENT WITH IT AND HAD TO. M039's -0.0125em was not cosmetic —
 * it PAID for the 2.5% that 600 sets wider than 400, and without it the band
 * grew at four of eight breakpoints. Deleting the weight while keeping the
 * tracking would have left the band NARROWER than it has ever shipped, tracking
 * tightened against a weight that is no longer there. The pair was introduced
 * together and is removed together.
 *
 * THE ONE HEIGHT CONSEQUENCE, MEASURED, NOT PREDICTED (tools/m039-band.mjs):
 * seven of the eight widths return to exactly the height they shipped at, and
 * 412 GROWS 26.39px, 1360.94 -> 1387.33, because M039's tracking had dropped a
 * line there that 400 does not need. That is the pre-M039 height rather than a
 * new one, and 390 — the width M010's rule governs — does not move.
 *
 * --weight-semibold IS STILL DECLARED and is still a real cut of the variable
 * Inter already in the artifact. It is simply not used here any more.
 */

/*
 * 🔴 KEYBOARD FOCUS ON THE BAND — OWNER RULING, 2026-09-20, AND IT IS A REAL
 * FAILURE BEING FIXED RATHER THAN A PRECAUTION.
 *
 * --color-focus #b65e2b was derived to be findable on this site's three
 * grounds: bone, warm ash and charcoal, worst ratio 3.90:1. A photograph is
 * not one of the three. Measured on this band's real crops it clears the 3.0
 * floor on only 61.5% of the ground at 390, so a keyboard user tabbing across
 * the band would lose the ring over roughly two-fifths of it. That is the
 * store-notice-bar failure the palette note warns about, arriving on a new
 * surface.
 *
 * THE FIX DOES NOT DEPEND ON THE RING'S OWN HUE, which is what the owner
 * ruled. A bone inner ring carries the contrast — 100% of the ground at every
 * viewport — and a charcoal outer ring sits behind it so the pair is findable
 * on a pale patch of photograph as well as a dark one. Two rings, so whichever
 * the ground is, one of them contrasts with it.
 *
 * `outline` draws the bone; `box-shadow` draws the charcoal OUTSIDE it, in
 * that order, because `outline-offset` would put page ground between them and
 * on a photograph there is no page ground to put.
 *
 * ⚠ SCOPED TO THE BAND. --color-focus is UNTOUCHED everywhere else on the
 * site, where it was derived and measured and still holds.
 */
.about-values-band :is(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 3px solid var(--color-text-inverse);
  outline-offset: 0;
  box-shadow: 0 0 0 6px var(--color-surface-inverse);
}

/*
 * THE KICKER (M038). Owner-approved locked copy, rendered uppercase.
 *
 * 🔴 THE UPPERCASE IS `text-transform` AND MUST STAY THAT WAY, for the reason
 * the value names below give: about.json holds the string in the case the
 * owner approved, the DOM text is unchanged, and R48 compares against the DOM.
 * Rewriting the data to caps would be an R48 violation that looked like a
 * styling choice.
 */
.about-kicker {
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-size: var(--text-sm);
  font-family: var(--font-heading);
  margin: 0 0 var(--space-sm);
}

/*
 * 🔴 M030 TASK 1 — THE RAIL, WHICH DOES NOT MOVE.
 *
 * The ruling is about the BACKGROUND. The text stays exactly where it was, and
 * "exactly" is measured: heading, first value paragraph and grid all sat at
 * x=24 at 390/768/820, x=136.5 at 1440 and x=696.5 at 2560 before this change
 * and sit there after it.
 *
 * ⚠ AN INNER `.container` WOULD HAVE MOVED IT, which is why this is not one.
 * `.container` is min(100% - --space-3xl, --width-container) centred, so inside
 * the band it lands on the container rail at x=112.5 at 1440 — 24px LEFT of
 * where the heading was, because M023's band sat ON the container rail and then
 * inset its content by its own --space-lg padding. The rail this page has is
 * the container rail MINUS that padding on each side, and that is what this
 * reproduces, from the same `cqi` the band is measured with:
 *
 *     min(100cqi, 1200) - 2 x 24   centred in the band
 *
 * 390 -> 342 centred in 390 = x 24.   1440 -> 1152 centred in 1425 = x 136.5.
 *
 * It can never be wider than the band's content box: min(100cqi, W) - 48 is at
 * most 100cqi - 48, which is that box exactly. So the inline padding above and
 * this width agree by construction rather than by coincidence, and the padding
 * is the floor that keeps type off the glass if this width is ever changed.
 *
 * depends: --width-container
 */
.about-band-inner {
  width: calc(min(100cqi, var(--width-container)) - var(--space-lg) * 2);
  margin-inline: auto;
}

/* ⚠ THESE TWO WERE `>` COMBINATORS AND HAD TO STOP BEING ONE. M030 puts
   `.about-band-inner` between the band and both of them, so a child combinator
   silently stops matching — no error, no warning, a passing build, and the grid
   breaking out a second time to 1200px inside a 1152px rail. Descendant
   selectors are correct here because the band contains exactly one of each. */
.about-values-band .about-values-heading { margin-top: 0; }

/*
 * 🔴 THE RULE BENEATH THE HEADING (M038). It is BONE at 40% rather than ember.
 *
 * The design canvas drew it in an orange that is not a token — #c76d30, which
 * is neither --color-accent #aa541f nor the brief's #e08a4a but sits roughly
 * between them, and which measures AA text on only 39.7% of this ground at
 * 390. Keeping an ember rule would also have taken /about/ to 2 ember elements
 * for a decorative line. Bone at 40% is the heading's own colour quietened,
 * needs no token, and cannot drift from the palette.
 */
.about-values-band .about-values-heading::after {
  content: "";
  display: block;
  width: 64px;
  height: 2px;
  margin: var(--space-md) auto 0;
  background: color-mix(in srgb, var(--color-text-inverse) 40%, transparent);
}

/*
 * 🔴 SIX VALUES, THREE ACROSS, `align-items: start` (M038).
 *
 * THE GRID NO LONGER BREAKS OUT, because the band it sits in is already full
 * bleed and `.about-band-inner` is already the rail. The M016/M030 rules that
 * widened it to the container and cancelled that widening inside the band are
 * both gone — two rules that existed only to undo each other.
 *
 * ⚠ `align-items: start` REPLACES `center`, AND THE CHANGE IS THE POINT. With
 * centring, a short cell floats down by exactly half the row's height
 * difference, so the six tops sat on six different lines whenever the bodies
 * were unequal. M018 measured that at 131.95 / 79.17 / 52.78 px and noted it
 * was moot only because the two columns had been made exactly equal. Six cells
 * in two rows cannot be made exactly equal the same way, so the alignment is
 * fixed instead of the copy. MEASURED, NOT EYEBALLED: tools/m038-values-band.mjs
 * reports "tops level: YES" at every viewport and every configuration.
 *
 * THE ROW SPREAD IS WHAT TASK 1a WAS ABOUT and it is 0.00 px at 1024, 1440 and
 * 2560 on the shipped order and copy. A grid row is as tall as its tallest
 * cell, so pairing long with long and short with short is what removes the
 * ragged whitespace; the numbers for both orders are in PROJECT-STATE.MD.
 */
.about-values-grid {
  display: grid;
  gap: var(--space-xl);
  align-items: start;
  margin-top: var(--space-2xl);
}

/*
 * THE CELL. Icon, name, body, centred.
 *
 * 🔴 THE ICON IS INLINE WITH THE NAME ON A PHONE AND ABOVE IT ON A DESKTOP,
 * AND THAT IS DELIBERATE RATHER THAN A RESPONSIVE ACCIDENT. Stacking the icon
 * above the name at 390 costs roughly 400px of page height across six values,
 * on the page that is already the tallest on the site. It is the one place the
 * two layouts differ on purpose and it is NOT a defect to reconcile.
 */
/*
 * 🔴 M043 §6 — EACH VALUE IS A FROSTED CARD. OWNER RULING, 2026-09-25.
 *
 * The icon, the name and the body sit inside it; the kicker and "Our Core
 * Values" stay on the band itself, on the bare 0.62 scrim. Between the cards
 * the photograph is sharp and lighter than it has ever been, which is the half
 * of this the owner actually asked for — he liked the picture and could not
 * read the words.
 *
 * ⚠ backdrop-filter IS THE TEXTURE FIX AND NOT THE CONTRAST FIX. Under a card
 * the dark coverage is 1 - (1 - 0.62)(1 - 0.38) = 0.764, marginally MORE than
 * M041's flat 0.76, so the floor is carried by the two alphas and would hold if
 * the blur never rendered. That is what makes the @supports block below a
 * genuine fallback rather than a hope.
 */
.about-value {
  text-align: center;
  padding: var(--space-values-card);
  border-radius: var(--radius-values-card);
  background: var(--color-values-card);
  -webkit-backdrop-filter: blur(var(--blur-values-card)) saturate(var(--saturate-values-card));
  backdrop-filter: blur(var(--blur-values-card)) saturate(var(--saturate-values-card));
  border: 1px solid var(--color-values-card-edge);
}

/*
 * NO backdrop-filter: THE CARD GETS HEAVIER, NOT LIGHTER.
 *
 * ⚠ THE QUERY IS `not (backdrop-filter: blur(1px))` AND NOT `not
 * (backdrop-filter)`. A bare property query is false in browsers that parse the
 * property and support no filter FUNCTIONS, which is the state this has to
 * catch; a value query asks whether the thing that is actually used works.
 */
@supports not (backdrop-filter: blur(1px)) {
  .about-value { background: var(--color-values-card-fallback); }
}

/*
 * BELOW 768 THE CARDS TIGHTEN AND CLOSE UP (M043 §6).
 *
 * The gap goes 28 -> 16 and the padding comes in, and the two are one decision:
 * six cards stacked in a single column at 390 add their padding SIX TIMES to a
 * page that is already the tallest on the site, so the band's growth is paid
 * for twice unless the space between them gives some back. M010's "/about/ must
 * not get taller at 390" is knowingly overridden by this milestone — the band
 * grows about 336px — and that override is recorded in PROJECT-STATE.MD rather
 * than softened here.
 */
@media (max-width: 767px) {
  .about-value {
    padding: var(--space-values-card-phone);
    border-radius: var(--radius-values-card-phone);
  }
  .about-values-grid { gap: var(--space-sm); }
}
.about-value h2 {
  margin: 0 0 var(--space-sm);
  font-size: var(--text-h3);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.about-value p { margin-bottom: 0; }

/*
 * 🔴 THE UPPERCASE VALUE NAMES ARE `text-transform` AND MUST NEVER BE EDITED
 * DATA. about.json holds them as "Quality", "Responsibility" and so on. Done
 * in CSS the DOM text is unchanged and R48 passes; done by rewriting the data
 * it is six R48 violations in one commit that would look like a styling
 * choice. R48 compares against the rendered DOM text, which `text-transform`
 * does not touch.
 */
.about-value-head {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
}
.about-value-icon {
  display: block;
  width: 30px;
  height: 30px;
  flex: none;
  margin: 0;
}

@media (min-width: 768px) {
  /* Above the name, centred, from 768 up. Below it, the flex row above keeps
     the icon beside the name — see the note on .about-value. */
  .about-value-head { display: block; }
  .about-value-icon {
    width: 56px;
    height: 56px;
    margin: 0 auto var(--space-sm);
  }
}

/*
 * ⚠ 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-value`. 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.
 *
 * ⚠ M038 RENAMED THE WRAPPER AND THIS RULE MOVED WITH IT. `.about-values` was
 * a column of three values and is now `.about-value`, one cell. Had this
 * selector been left behind it would have matched nothing — no error, no
 * warning, a passing build, and the exact defect the paragraph above
 * describes, reintroduced by a rename.
 *
 * `margin-inline` is `auto` here rather than `0`: the M023 centring ruling
 * gives every value body centred text, and a centred measure pinned to the
 * left of a wider column reads as a mistake. That pairing is set in the
 * centring block above and is repeated here only as the cap's own default.
 *
 * depends: --width-measure
 */
.about-value p { max-width: var(--width-measure); margin-inline: auto; }


/*
 * THREE ACROSS FROM 768 UP (M038, replacing M016 Task 11's asymmetric three).
 *
 * ⚠ THE OLD RULE WAS `1fr 1.5fr 1fr` — two value columns either side of a
 * WIDER FIGURE COLUMN, which was M016 Task 11's ruling. The figure is retired
 * (owner ruling 2026-09-20), so the 1.5 has nothing to describe and the three
 * columns are equal. Recorded as a replacement so the asymmetry does not look
 * like something a milestone lost.
 *
 * ⚠ THE `width` AND `margin-left` BREAK-OUT IS ALSO GONE, and its removal
 * deletes a matched pair rather than one rule. The grid used to widen itself
 * to the container from inside the 750px prose column, and a SECOND rule
 * inside the band cancelled that widening again because the band was already
 * on the rail. Both are unnecessary now: `.about-band-inner` is the rail and
 * the grid simply fills it.
 *
 * ⚠ MEASURED, AND THE NARROW-MEASURE COST IS RE-REPORTED RATHER THAN DROPPED.
 * M016 recorded that at 768 a value column is 185px and runs 19-22 characters
 * per line. With the figure gone the three columns are equal and WIDER than
 * the two outer ones were: at 768 the rail is 720px and three equal columns
 * with two 32px gaps give 218.7px. Better, and still narrow. The floor is
 * arithmetic, not this stylesheet — three columns inside 720px cannot be wide
 * — and fixing it would need a third breakpoint, which CLAUDE.md forbids by
 * name. It stays the owner's call and it stays recorded with numbers.
 */
@media (min-width: 768px) {
  .about-values-grid {
    grid-template-columns: repeat(3, 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);
}
/*
 * 🔴 M031 TASK 1 - THE COLUMNS ARE CENTRED IN THE BAND, NOT PINNED TO ITS TOP.
 *
 * `align-items` was `normal`, which for a grid child is `stretch`: all three
 * columns measured 79.53 tall at 1440 and none of them held 79.53 of anything.
 * The nav held 23.77 - one line of links - and the other two were taller, so
 * the links sat at the CEILING of a track with 55.76px of nothing under them.
 * That hole is what the owner was looking at.
 *
 * MEASURED AS A NUMBER RATHER THAN JUDGED FROM A SCREENSHOT. The eye reads a
 * row of columns off the CENTRE of each column's ink, so the metric is the
 * spread between the three content centres: 19.88 at 1440 and 2560, 4 at 768
 * and 820, and 0 is the three blocks sitting on one line.
 *
 * ⚠ IT IS DELIBERATELY NOT SCOPED TO 768. Below that the grid is ONE column
 * and every row holds exactly one item, so each item is already the height of
 * its own row and `center` has nothing to move. MEASURED IN ISOLATION rather
 * than reasoned: this declaration applied on its own left 390 identical to the
 * hundredth - footer 314.19, tracks 58.13 / 55.53 / 79.53, contact slack 16.
 * A media query that changes nothing is a claim someone later has to
 * re-derive. 390 DOES lose 16px in this milestone, and it is the margin rule
 * below that takes them, not this line.
 *
 * depends: --space-xl
 */
.footer-cols {
  display: grid; gap: var(--space-xl);
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  align-items: center;
}

/*
 * AND THE TRAILING MARGIN COMES OFF, because centring a box that ENDS IN 16px
 * OF NOTHING centres the nothing too. The contact block's last <p> carries the
 * base 16px bottom margin, and a grid item establishes its own formatting
 * context so that margin is inside the item's box rather than collapsing out
 * of it: the block measured 79.53 tall around 63.53 of ink. Centred by the box
 * it would have sat 8px HIGH of the other two - `align-items: center` on its
 * own would have taken the spread from 19.88 to 8, not to 0.
 *
 * The selector is the last child of a footer column, so it is the trailing
 * margin and nothing else; the gap between the two paragraphs is untouched.
 */
.footer-cols > * > :last-child { margin-bottom: 0; }

/*
 * 🔴 M030 TASK 2 — THE LINK COLUMN TAKES THE ROOM THE ROW NEEDS.
 *
 * This is the SECOND declaration the owner's horizontal-footer ruling forced,
 * and it is here because the first one alone produced a defect rather than the
 * ruling. `repeat(auto-fit, minmax(200px, 1fr))` gives three EQUAL columns, so
 * at 1440 the links had 381px to run in — and seven links measuring 423px with
 * their gaps wrapped to "Home Shop About Recipes Contact Cart" and then
 * "Privacy" ALONE ON A SECOND LINE, with 1200px of footer and most of the
 * right-hand third of it empty. An orphaned link beside empty ground is not a
 * horizontal footer; it is a vertical footer that ran out of column.
 *
 * MEASURED, NOT GUESSED: 2 wrapped lines at 1440 and 2560 and THREE at 768
 * before this, against 1 at 1440 and 2560 after it. The row it produces is the
 * thing the ruling asked for.
 *
 * The two end columns size to their content and the LINKS take what is left,
 * which is the whole change. Nothing moves in the DOM and nothing is placed:
 * these are track sizes, not `order:` and not `grid-row`. The reading order is
 * still brand, links, questions, left to right, exactly as the markup has it.
 *
 * IT IS SCOPED TO 768 AND UP because below that the grid is already ONE column
 * — auto-fit cannot fit two 200px tracks in a 350px container at 390 — and
 * three content-sized tracks at 390 would put all three blocks side by side in
 * a space that holds one. 768 belongs to the desktop side, as everywhere else
 * in this file.
 *
 * `minmax(0, 1fr)` rather than `1fr` for the middle: a bare `1fr` has an
 * automatic MINIMUM of min-content, so the longest single link would set a
 * floor and the track could refuse to shrink below it.
 */
/*
 * 🔴 M031 TASK 2 - THE LINKS ARE CENTRED IN THE TRACK M030 GAVE THEM.
 *
 * The track is 721.84 wide at 1440 and the seven links fill 423.04 of it, ALL
 * of the slack on the right: they ended at 848.27 and the contact block began
 * at 1175.08, so 326.81px of nothing sat between "Privacy" and "Questions?".
 * Left-aligned links in a track far wider than they need is what that reads as.
 *
 * TWO MECHANISMS WERE BUILT AND MEASURED, NOT REASONED ABOUT. The other was
 * `grid-template-columns: auto minmax(0, max-content) auto` with
 * `justify-content: space-between`, letting the middle track hug its content
 * and spreading the remainder. IT PRODUCES THE IDENTICAL PAINTED RESULT at
 * 1440 and 2560 - links 574.64 -> 997.67, 177.41px to the contact block - and
 * it held the wrapped line counts too.
 *
 * THIS ONE IS CHOSEN ON RISK, SINCE THE OUTCOME WAS A TIE. M030 sized these
 * tracks deliberately, to stop seven links wrapping with "Privacy" orphaned on
 * a second line beside an empty right-hand third, and the other candidate
 * changes exactly that declaration. `justify-content` on the <ul> touches no
 * track at all, so the width the links wrap inside is bit-for-bit what M030
 * set and the defect it fixed CANNOT come back through this line. Measured
 * wrapped lines are 2 / 2 / 2 / 1 / 1 before and after, unchanged.
 *
 * SCOPED TO 768 AND UP, for the reason the track sizing is. Below that the grid
 * is one column and the brand block and the contact block are both flush left;
 * centring only the middle one measured links at 43.17 -> 346.83 against text
 * starting at 20, which is a third alignment nobody asked for.
 */
@media (min-width: 768px) {
  .footer-cols { grid-template-columns: auto minmax(0, 1fr) auto; }
  .site-footer ul { justify-content: center; }
}

/*
 * 🔴 M031 TASK 3 - "Questions?" AND THE ADDRESS SIT ON ONE LINE. LAYOUT, NOT
 * COPY: both strings are kept, character for character, and no rule covers
 * them anyway - the footer's contact block is markup in base.njk and is in
 * neither about.json (R48) nor brand.json (R32) nor deals.json (R17).
 *
 * The block was two paragraphs, so the column ran to two lines while the nav
 * ran to one, and the 16px between them was what made the band uneven.
 *
 * 🔴 IT IS SCOPED TO 1100 AND THE SCOPE IS THE WHOLE POINT. On one line the
 * block's max-content goes from 137.42 to 221.25, and this is an `auto` track:
 * an auto track reaches its max-content BEFORE a flexible one gets anything,
 * so those 83.83px come straight out of the nav in the middle. UNSCOPED IT
 * REINTRODUCED THE DEFECT M030 EXISTS TO HAVE FIXED - measured 3 wrapped lines
 * at 768 and at 820, with "Privacy" ALONE ON THE THIRD LINE at 820.
 *
 * 1100 IS NOT A ROUND NUMBER CHOSEN BY EYE. The nav needs 423.04 for one line,
 * the brand block takes 284.73 and the two gaps 56, so one line survives from
 * container 985.02 up - viewport 1025.02. A breakpoint on its own cliff edge
 * is one font metric away from being wrong, so it stands 75px clear of it, and
 * the sweep either side is in the commit.
 *
 * BELOW 1100 NOTHING CHANGES, AND NOTHING NEEDS TO. There the nav wraps to two
 * lines itself, so a two-line contact block is the even reading and the
 * one-line version would be the odd one. Task 1 already put the three columns
 * on one centre line at those widths, spread 0.
 *
 * IT IS A FOURTH BREAKPOINT WIDTH, and the set was 560, 768 and 960. It takes
 * no `max-` partner, exactly as 960 takes none (M027), so no boundary has two
 * rules meeting at it and the 0.02px pairing rule has nothing to pair.
 */
@media (min-width: 1100px) {
  .footer-contact { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2xs); }
  .footer-contact p { margin: 0; }
}
.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; }
/*
 * 🔴 M030 TASK 2 — THE FOOTER LINKS RUN HORIZONTALLY. OWNER RULING, 2026-09-08.
 *
 * They were `display: grid` with a --space-3xs gap, which is a one-column grid
 * and therefore a vertical stack: seven links, seven lines, at every width from
 * 390 to 2560. They are now a wrapping flex row.
 *
 * WRAP, NEVER OVERFLOW. `flex-wrap: wrap` is the whole of that guarantee — the
 * links break to a new line rather than pushing the footer past the viewport,
 * and the six-viewport overflow sweep is what proves it rather than this
 * comment. Measured wrapped lines: 2 at 390, 2 at 768 and 820, 1 at 1440 and
 * 2560. Nothing overflows at any of them.
 *
 * THE GAP IS TWO VALUES AND BOTH ARE LARGER THAN WHAT THEY REPLACE, which is
 * the point. Stacked, the declared gap was --space-3xs (4px) and the MEASURED
 * distance between two link boxes was 10.76px, the 4px plus the line box's
 * leading. In a row there is no leading to help: the horizontal distance is
 * exactly the column gap. --space-md (20px) across and --space-2xs (8px) down
 * measure 20px and 14.76px between nearest neighbours, so no link is closer to
 * another link than it was before. Had the old 4px been carried across it would
 * have been 4px between adjacent targets, and that is the failure this ruling
 * could have shipped.
 *
 * depends: --space-md
 * depends: --space-2xs
 */

/*
 * 🔴 D1 IS RETIRED. OWNER RULING, 2026-09-08 (M031 Task 4). The footer links
 * now reach --tap-min and this note is the record of the decision it replaces,
 * kept rather than deleted so nobody re-derives the argument a third time.
 *
 * D1 READ: the footer links stay under 44px and are not to be re-flagged. It
 * rested on two supports and BOTH ARE NOW GONE.
 *
 * The first was "growing the rows turns a compact link list into a tall stack
 * of buttons". M030 killed that one: there is no stack, the links are a
 * wrapping row on an owner ruling.
 *
 * 🔴 THE SECOND WAS WRONG AS REASONING, NOT MERELY STALE, AND THE MEASUREMENT
 * IS WHAT SAYS SO. M030 concluded that going horizontal made the ::after
 * mitigation WORSE - "a horizontal neighbour 20px away is closer than a
 * stacked one, so an expansion to 44px would overlap it". That is true only of
 * expanding HORIZONTALLY. The neighbours are beside each other now, not above,
 * so the VERTICAL axis is the free one and it is the axis a link is short on.
 * Going horizontal made the expansion possible; it did not obstruct it.
 *
 * WHAT WAS BUILT INSTEAD: 44px of height, 0px of width, and --space-lg between
 * wrapped rows to hold the second line clear. Measured hit region 18.05-18.44
 * before and 44.98 after at all five widths, and the worst separation between
 * any two links is 2.78 at 390, 2.79 at 768 and 820, 18.65 at 1440 and 2560 -
 * no overlap anywhere, taken on each axis independently.
 *
 * The 44.98 against a declared 44 is not a rounding: a link with NO ::after at
 * all probes 18.27 against a painted 17, so hit testing snaps to enclosing
 * layout bounds and reads ~1px large in every dimension. It was checked by
 * measuring a build without the rule rather than assumed.
 *
 * WHAT M003-A CHANGED ABOUT THE OLD RULING is kept because it is still the
 * reason nothing was urgent. The mitigation 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.
 * None of the three is reached only from here - Home is the logo, Cart is the
 * header control, Deals is the band at the top of /shop/ - so the narrower
 * claim, "nothing here is the only route to anywhere", held until the owner
 * ruled. It is no longer load-bearing.
 */.site-footer ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap;
  /*
   * 🔴 M031 TASK 4 - THE ROW GAP GROWS FROM --space-2xs TO --space-lg, AND IT
   * IS THE TAP TARGET THAT NEEDS IT, NOT THE LOOK.
   *
   * The 44px hit box below is 27px taller than the 17px link it wraps, so two
   * wrapped lines need 27px of clearance between their painted boxes or the
   * two hit boxes intersect. Measured clearance is 6.77 of line-box leading
   * plus the declared row gap: at --space-2xs that is 14.76 and the boxes
   * would have overlapped by 12.24px. --space-md would have given 26.77 and
   * MISSED BY 0.23px, which is the kind of margin that is not a margin.
   * --space-lg gives 30.77 and clears by 3.77.
   *
   * It costs 16px of footer height at 390, 768 and 820 and NOTHING at 1440 and
   * 2560, where the links are one line and there is no second row to clear.
   * The column gap is untouched at --space-md.
   *
   * depends: --space-lg
   * depends: --space-md
   * depends: --tap-min
   */
  gap: var(--space-lg) var(--space-md);
}

/*
 * 🔴 M031 TASK 4 - THE FOOTER LINKS REACH --tap-min. OWNER RULING, 2026-09-08.
 * D1 IS RETIRED; see the note above it.
 *
 * The idiom is the one already used by .breadcrumb a, .product-cats a,
 * .link-button and .nudge-link: an invisible, out-of-flow ::after centred on
 * the link. The painted control and the layout are exactly as they were and
 * only the area that answers a finger changes.
 *
 * 🔴 IT DIFFERS FROM THAT IDIOM IN ONE DECLARATION, DELIBERATELY. The shared
 * rule carries `min-width: var(--tap-min)` and grows the box on BOTH axes.
 * Here the box is pinned to the link's own width - `left: 0` and `width: 100%`
 * with no min-width - because the horizontal neighbour is 19.68px away at 1440
 * and a target grown sideways to 44px would swallow the link beside it and
 * open the wrong page. VERTICAL ONLY. The row gap above is what buys the room.
 *
 * depends: --tap-min
 */
.site-footer a { position: relative; }
.site-footer a::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
  width: 100%;
  height: var(--tap-min);
}
.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. */

/* --- Our Favorites (M003-D Part 3, REBUILT IN M042) -----------------------
   Four across on desktop, two at or below 820px. 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 and must stay in the order intent.json
   gives them.

   🔴 WHAT M042 DELETED FROM THIS BLOCK, AND WHY IT IS NOT COMING BACK.
   `.featured-card .add-to-cart-compact` and its three companions are GONE,
   along with the blurb's subgrid track. The owner removed Add to cart from this
   section on 2026-09-25, which reverses M004-K and M017 on these buttons. The
   rule that governed them is preserved as a quotation rather than as dead CSS,
   because it is still the correct reasoning about a shared class and the next
   person to style a compact form on this site needs it:

     "🔴 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."

   THE 17 PRODUCT PAGES STILL CARRY `.add-to-cart` AND ARE UNTOUCHED. What
   changed is that nothing on the homepage matches `.add-to-cart-compact` any
   more, so the shared rule now has one kind of consumer and no local override.

   🔴 THIS BAND IS BONE AND IT IS THE SECOND THING ON THE PAGE. It was warm ash
   in fourth position from M015 until M042. It sets no background of its own —
   bone is the page — exactly as `.home-claims` does, and `.home-section-hero`
   now carries `.home-section-tint` in its place. The sequence is measured on
   the rendered page by `node tools/m042-measure.mjs bands`; no comment in this
   file is evidence of it, including this one.

   depends: .home-section-tint
   depends: --color-surface */

/* The section is `overflow: clip` because the line-up is 1850px wide inside a
   1440px stage and drifts a further 160px. `clip` rather than `hidden` so it
   creates no scroll container — `hidden` would let a focused card scroll the
   section internally and take the rest of the page off screen. */
.home-favorites { overflow: clip; }

.favorites-inner {
  width: min(100% - var(--space-3xl), var(--width-favorites-grid));
  margin-inline: auto;
  position: relative;
}

/* --- the heading group ---------------------------------------------------
   Eyebrow, heading and rule move together under one transform. See
   featured-products.njk for why they are one wrapper and not three. */
.favorites-head { transform-origin: center center; will-change: transform; }

/* about.json's locked kicker. UPPERCASE IS `text-transform` AND MUST STAY THAT
   WAY: about.json holds the string in the case the owner approved, R48 and R67
   both compare against the DOM, and rewriting the data to caps would be a
   locked-copy violation that looked like a styling choice. */
.favorites-eyebrow {
  margin: 0 0 var(--space-sm);
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-family: var(--font-heading);
  font-weight: var(--weight-medium);
  font-size: var(--text-favorites-eyebrow);
  color: var(--color-text-muted);
}

/* 🔴 THE REAL 500 FACE, NEVER SYNTHESISED BOLD. Outfit ships one cut on this
   site and `--weight-medium` IS that cut, so this renders the drawn letterforms
   rather than a browser's smeared approximation of a heavier one. The open item
   about synthesised bold elsewhere on this page is not reintroduced here. */
.favorites-heading {
  margin: 0;
  text-align: center;
  font-family: var(--font-heading);
  font-weight: var(--weight-medium);
  font-size: var(--text-favorites-heading);
  letter-spacing: -0.02em;
  line-height: 1.05;
  color: var(--color-text-strong);
}

/* Each word rises out of its own mask. The mask needs `overflow: hidden`, which
   would clip a descender — "Our Favorites" has none, but the string is
   intent.json's and the owner can reword it — so the box is grown by one step
   and pulled back out of flow by the same amount. Layout is unchanged; the clip
   line simply sits below the baseline instead of on it. */
.favorites-word {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-block-end: var(--space-2xs);
  margin-block-end: calc(var(--space-2xs) * -1);
}
.favorites-word-inner { display: inline-block; will-change: transform; }

.favorites-rule {
  width: var(--favorites-rule-w);
  height: 2px;
  margin: var(--space-md) auto 0;
  background: var(--color-favorites-rule);
  transform-origin: center center;
}

/* --- the grid ------------------------------------------------------------ */
.favorites-grid {
  list-style: none;
  margin: var(--space-3xl) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-2xl);
}

/* `transform-origin: 0 0` is load-bearing and is not a style choice: the module
   solves each card's translation for the top-left corner of its own media box,
   and any other origin makes that arithmetic wrong by half a card. */
.favorites-card { transform-origin: 0 0; will-change: transform; }

/* THE WHOLE TILE IS THE LINK (owner ruling, 2026-09-25). No card border and no
   card background — the photograph is the tile. */
/*
 * 🔴 THE SHARED BASELINE SURVIVES THE REDESIGN, AND IT IS AN OWNER RULING
 * RATHER THAN A DETAIL. M004-K Part 7: every card on this site centres its name
 * and its price, and the card grids "also had to grow a shared baseline so a
 * title that wraps cannot knock the row out of line". That is exactly what
 * happens here at 390, where "Fire Roasted Herb Mix" takes two lines and
 * "Smoked Árbol" takes one.
 *
 * The old card bought the baseline with a subgrid. This one buys it with a
 * flex column and `margin-top: auto` on the price, which costs NOTHING in
 * height — and height is the binding constraint on this section, since the
 * final frame has to fit one 100svh stage. A min-height on the title would
 * have cost 17px a row at 390 and put the frame back over the stage.
 */
.favorites-link {
  display: flex;
  flex-direction: column;
  height: 100%;
  text-decoration: none;
  color: inherit;
}
.favorites-media {
  display: block;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: var(--radius-favorites-tile);
  background: var(--color-surface-sunken);
}
.favorites-media picture,
.favorites-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.favorites-media img { transition: transform 900ms ease; }
.favorites-link:hover .favorites-media img { transform: scale(1.04); }
.favorites-link:hover .favorites-title { color: var(--color-accent-hover); }

/* THE CAPTION READS BLURB, NAME, PRICE — in the DOM, never with `order:`.
   See featured-products.njk: this OVERRIDES M017 Task 2's "below the button". */
.favorites-caption {
  display: flex;
  flex-direction: column;
  flex: 1 0 auto;
  text-align: center;
  margin-top: var(--space-sm);
}
/*
 * 🔴 THE THREE CAPTION LINE HEIGHTS ARE SET EXPLICITLY, AND THEY ARE A FIT
 * CONSTRAINT RATHER THAN A TYPOGRAPHIC PREFERENCE.
 *
 * The brief requires the final frame to fit inside one 100svh stage at
 * 390x844. Measured before this change it was 907.8px against 844 — and 120px
 * of the excess was line boxes: the body's 1.65 running-text line height
 * applied to an 11.5px label, a 20px name and a 15px price, none of which is
 * running text. A three-line caption inherited 1.65 twice over in a grid two
 * rows deep, which is four line boxes' worth of leading nobody asked for.
 *
 * These are captions, so they get caption leading. `node
 * tools/m042-measure.mjs story` photographs the result and the fit probe in
 * PROJECT-STATE.MD records the before and after.
 */
.favorites-blurb {
  display: block;
  font-family: var(--font-body);
  font-weight: var(--weight-semibold);
  font-size: var(--text-3xs);
  line-height: 1.4;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.favorites-title {
  display: block;
  margin-top: var(--space-2xs);
  font-family: var(--font-heading);
  font-weight: var(--weight-medium);
  font-size: var(--text-2xl);
  line-height: 1.25;
  color: var(--color-text-strong);
}
/* Tabular figures so four prices in a row align on the decimal. Every product
   is $9.99 today, which is exactly when a column of digits that does not line
   up is most obviously wrong. */
.favorites-price {
  display: block;
  /* `auto` and not a scale step: this is the baseline, not a gap. The 8px the
     other two carry is the gap, and it still applies when a row's titles happen
     to be the same height. */
  margin-top: auto;
  padding-top: var(--space-2xs);
  font-family: var(--font-body);
  font-size: var(--text-md);
  line-height: 1.3;
  color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
}

.favorites-all { text-align: center; margin-top: var(--space-2xl); }

/* =========================================================================
   THE SCROLL STORY, AND EVERY RULE BELOW IS BEHIND `.is-armed`
   =========================================================================
   🔴 NOT ONE DECLARATION IN THIS BLOCK APPLIES UNTIL favorites-story.js ADDS
   THE CLASS. That is Rule 1 of the brief expressed in CSS: with scripting off,
   with reduced motion, or after any error in the module, none of this exists
   and the section above is what ships. Putting the pin in the stylesheet and
   having the script undo it would invert the failure mode — the broken state
   would become the default.

   THE ROWS ARE `display: none` UNTIL ARMED, which is also what keeps a no-JS
   visitor from paying for them: 22 `loading="lazy"` images inside a
   `display: none` box are never fetched. */
.favorites-rows { display: none; }

.home-favorites.is-armed {
  height: var(--favorites-pin);
  padding-block: 0;
  position: relative;
}
/*
 * 🔴 THE STAGE SITS UNDER THE STICKY HEADER, NOT BEHIND IT, AND THE
 * MEASUREMENT IS WHY THAT SENTENCE EXISTS.
 *
 * The brief says the stage is 100svh and the final frame must fit inside it.
 * Both are true here and neither was enough: `.site-header` is `position:
 * sticky`, and at 390 it is TWO ROWS TALL — 129px of an 844px screen. A stage
 * pinned at `top: 0` therefore put the eyebrow and most of the heading behind
 * the header for the entire length of the pin. Measured, not noticed: the fit
 * probe reported 114.7px of the frame clipped above.
 *
 * `--favorites-header-h` is written once by favorites-story.js, at arm time,
 * from the header's own measured height — the only geometry that module ever
 * writes, and it writes it before a single frame runs. It is not read from a
 * token because the header's height is a CONSEQUENCE of how many rows the nav
 * wraps to at that width, which no token knows; and it is not hardcoded per
 * breakpoint because that is the same number written down in two places, which
 * is the defect this repository has caught four times.
 *
 * The fallback of 0px is deliberate: if the module cannot find a sticky header
 * it behaves exactly as the brief describes.
 */
.home-favorites.is-armed .favorites-stage {
  position: sticky;
  top: var(--favorites-header-h, 0px);
  height: calc(100svh - var(--favorites-header-h, 0px));
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.home-favorites.is-armed .favorites-rows {
  display: block;
  position: absolute;
  inset: 0;
  /* Decoration cannot be clicked, hovered or dragged. `aria-hidden` handles the
     accessibility tree; this handles the pointer. */
  pointer-events: none;
}

/* The row is full width and centres its own slots, so the module's transform is
   a pure offset and never has to carry a -50% of its own. The bottom row's half
   pitch is applied HERE, inside the same transform the module writes to,
   through a custom property — so the static offset stays in the stylesheet
   beside the pitch that defines it and the module never learns it exists. */
.favorites-row {
  position: absolute;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  gap: calc(var(--favorites-pitch) - var(--favorites-slot-w));
  will-change: transform;
  transform: translate3d(calc(var(--row-x, 0px) + var(--row-offset, 0px)), var(--row-y, 0px), 0);
}
.favorites-row[data-favorites-row="1"] { --row-offset: calc(var(--favorites-pitch) / 2); }

/* The rows sit above and below the heading, which starts at the stage's centre.
   `top` is not a spacing property and these are structural offsets rather than
   scale steps — the same judgement R27's own rationale makes about a 48px
   thumbnail and a 44px touch target. */
.favorites-row[data-favorites-row="0"] { top: calc(50% - 298px); }
.favorites-row[data-favorites-row="1"] { top: calc(50% + 110px); }

.favorites-slot {
  flex: 0 0 auto;
  width: var(--favorites-slot-w);
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: var(--radius-favorites-slot);
  will-change: transform, opacity;
}
.favorites-slot picture,
.favorites-slot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ⚠ THE BELT AND BRACES ON REDUCED MOTION. favorites-story.js returns before it
   arms anything when `prefers-reduced-motion: reduce` is set, so this should be
   unreachable. It is here because "should be" is the phrase that precedes every
   defect in this repository's history: if the class ever arrives by some other
   route, a visitor who asked for no motion still gets the final frame, with no
   pin, no rows and no sway. */
@media (prefers-reduced-motion: reduce) {
  .home-favorites.is-armed { height: auto; padding-block: var(--space-5xl); }
  .home-favorites.is-armed .favorites-stage { position: static; height: auto; display: block; }
  .home-favorites.is-armed .favorites-rows { display: none; }
}

/* --- 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 moved to its own 820px query in M042 — see the foot of this
     file. The doors still switch here, at the site's own breakpoint. */
  .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); }


/* ===========================================================================
   M042 — OUR FAVORITES BELOW 820px
   ===========================================================================
   🔴 THIS IS THE ONE PLACE IN THIS STYLESHEET THAT BREAKS AT 820 RATHER THAN
   768, and it is confined to one section. The owner's canvas scopes the whole
   of Our Favorites — type, grid, tile radius, line-up pitch and pin height — to
   that width. Splitting it across the site's 768 would leave a 52px band in
   which the heading was phone sized and the grid was still four across.

   The redeclared `:root` is how every value stays a token: R27 forbids a
   literal font-size, radius or spacing value outside `:root`, and a section
   that redefines six of them on a breakpoint would otherwise have to write six
   literals. The custom properties are redeclared; not one rule below sets a raw
   length that R27 governs.
   ========================================================================= */
@media (max-width: 820px) {
  /*
   * 🔴 THESE OVERRIDES ARE ON `.home-favorites` AND NOT ON A SECOND `:root`,
   * AND THE MUTATION SUITE IS WHAT MADE THAT NECESSARY.
   *
   * The first version redeclared them in a `:root` inside this query, which is
   * the standard pattern and looked entirely correct. It also broke R27's own
   * mutation case: R27 decides the design system has been deleted by matching
   * the FIRST `:root { ... }` in the file, so a second one in a media query
   * satisfied that check and the case reporting "the :root token block deleted
   * entirely" came back FAILED FOR THE WRONG REASON. A rule that can no longer
   * tell whether the token block exists is a rule that has quietly stopped
   * covering the thing it was written for.
   *
   * Scoping them to the section is also simply more correct: every one of these
   * tokens is used by this band and by nothing else, so the section is where
   * they belong. They cascade to every descendant exactly as they did.
   */
  .home-favorites {
    --text-favorites-heading: 2.125rem;   /* 34px */
    --text-favorites-eyebrow: var(--text-2xs);
    --radius-favorites-tile: 16px;
    --radius-favorites-slot: 8px;
    --favorites-pitch: 108px;
    --favorites-slot-w: 96px;
    --favorites-rule-w: 44px;
    --favorites-pin: 230vh;
  }

  /*
   * TWO COLUMNS ALL THE WAY DOWN, and this is the one place the favourites grid
   * deliberately parts company with /shop/. The reasoning is M004-K's and is
   * unchanged by the redesign, only re-measured: the first version dropped to a
   * single column below 560px, matching the shop grid, and 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 favourites 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.
   *
   * ⚠ IT MATTERS MORE NOW THAN IT DID. The final frame has to FIT INSIDE ONE
   * 100svh STAGE at 390x844, which a single column could not do at any type
   * size. The column count is now a constraint, not a preference.
   */
  /*
   * 🔴 THE PHONE TILES ARE CAPPED, AND THIS IS THE ONE PLACE M042 CHANGES A
   * SIZE THE OWNER WOULD NOTICE. It is recorded here and in PROJECT-STATE.MD
   * rather than absorbed quietly.
   *
   * THE ARITHMETIC. At 390 the container is 350px, so two columns with a 16px
   * gap give 167px tiles and a 4:5 photograph 209px tall. Two rows of that,
   * plus the heading group, the captions and the pill, measured 815.3px. The
   * stage a visitor can actually see is 844 - 129 = 715px, because of the
   * sticky header above. 815.3 does not go into 715.
   *
   * WHAT WAS TRIED FIRST, so nobody repeats it: caption leading (-59px), four
   * unspecified margins (-21px) and the name and price type (-12px). All of it
   * is kept and none of it was enough; it bought the frame its fit inside 100svh
   * and left it 100px short of fitting the part of 100svh that is not header.
   *
   * WHAT IS NOT TOUCHED. Every number the canvas states for phones — 12px
   * eyebrow, 34px heading, 44px rule, two columns, 16px column gap, 20px row
   * gap, 16px corners, 4:5 — is exactly as specified. What changed is the tile
   * WIDTH, which the canvas never states: it fell out of the container width,
   * and the container width was never a design decision.
   *
   * ⚠ THE ALTERNATIVE IS AN OWNER DECISION AND IT IS OPEN. A shorter mobile
   * header would give the tiles their 167px back with room to spare. That is a
   * change to the site's navigation, not to this section, so it is reported
   * rather than made.
   */
  .favorites-grid {
    grid-template-columns: repeat(2, 1fr);
    column-gap: var(--space-sm);
    row-gap: var(--space-md);
    margin-top: var(--space-xs);
    max-width: var(--width-favorites-grid-phone);
    margin-inline: auto;
  }

  /*
   * 🔴 EVERYTHING BELOW EXISTS TO MAKE THE FINAL FRAME FIT ONE 100svh STAGE AT
   * 390x844, AND EVERY VALUE IT TOUCHES IS ONE THE OWNER DID NOT SPECIFY.
   *
   * The canvas gives the phone treatment six numbers — eyebrow 12px, heading
   * 34px, rule 44px, two columns, 16px column gap, 20px row gap, 16px corners.
   * NOT ONE OF THEM IS CHANGED HERE, and neither is the tile size, which is the
   * thing a visitor actually sees. What is reduced is the product NAME and
   * PRICE type, which the canvas specifies for desktop only, and four internal
   * margins it never mentions. Measured: 907.8px before, and the figure after
   * is in PROJECT-STATE.MD.
   *
   * ⚠ THE 129px STICKY HEADER IS A SEPARATE PROBLEM AND IS NOT SOLVED HERE. At
   * 390 `.site-header` is two rows tall and `position: sticky`, so it covers
   * the top 129px of a 844px stage. The frame fits 100svh, which is what the
   * brief asks; it does not fit the 715px a visitor can actually see. Closing
   * that gap needs roughly 117px tiles, which is smaller than a shop card on
   * the same phone — an owner decision, recorded in PROJECT-STATE.MD as open
   * rather than taken here.
   */
  .favorites-eyebrow { margin-bottom: var(--space-2xs); }
  .favorites-caption { margin-top: var(--space-2xs); }
  .favorites-title { font-size: var(--text-lg); }
  .favorites-price { font-size: var(--text-sm); }
  .favorites-all { margin-top: var(--space-sm); }
  /* 16px, which is also CLOSER to the canvas's 18px than the 20px the desktop
     rule uses — the scale has no 18 and this section has to choose a side of it
     twice. */
  .favorites-rule { margin-top: var(--space-sm); }

  /* The rows sit closer to the heading, in proportion with the smaller tiles. */
  .favorites-row[data-favorites-row="0"] { top: calc(50% - 210px); }
  .favorites-row[data-favorites-row="1"] { top: calc(50% + 90px); }
}
