/* ============================================================================
   One Ocean — main stylesheet
   ============================================================================

   Structure:
     1. Design tokens        ← EMPTY until Phase 1. Do not fill from memory.
     2. Base / reset
     3. Accessibility utilities
     4. Layout primitives
     5. Chrome (nav, footer)
     6. Sections             ← one banner-commented block per section, Phase 3

   No build step. This file ships as authored and is cache-busted by its mtime
   (see ooc_asset_ver in functions.php), so an edit is live on the next request
   with no version bump.
   ========================================================================= */


/* ----------------------------------------------------------------------------
   1. DESIGN TOKENS
   ----------------------------------------------------------------------------
   Source: Figma file KDzdMwrHDQ84s8t6bkF1tq ("OneOcean").
   Read 2026-07-29 via get_variable_defs against frames 100:5838 (desktop home),
   226:2032 (mobile home), 186:76 (services V2), 230:5104 (about), 130:661
   (footer), plus the file's Text styles panel. Provenance per value is recorded
   in FIGMA.md.

   HARD RULE 1 — never code from a guess. A colour, size, font, radius or
   breakpoint that has not been READ FROM THE SOURCE does not get written here.
   Not from the old Lovable site, not from a screenshot, not from a reasonable
   assumption about what a premium seafood brand looks like.

   Two reasons this is a rule and not a preference:
     - Token NAMES lie. On a prior build a token called `bg` was #090909,
       near-black; the section was built light and had to be rebuilt.
     - A plausible-but-wrong palette is worse than none, because it looks
       finished and nobody re-checks it.

   So this block holds ONLY what has been read. Everything still unread is
   listed at the bottom as an explicit TODO rather than filled with a plausible
   value. Names below mirror the Figma style names exactly — the semantic layer
   (ink / surface / accent / border) is deliberately NOT mapped yet, because
   which literal serves which role is a design question, not a rename.
   -------------------------------------------------------------------------- */

/* ----------------------------------------------------------------------------
   1a. WEBFONTS
   ----------------------------------------------------------------------------
   Self-hosted, not loaded from fonts.gstatic.com — one less third-party
   request, no visitor IP handed to Google (a GDPR point that has cost European
   sites real money), and it works offline for local fidelity runs.

   `unicode-range` is what makes two files cheaper than one: the browser fetches
   latin-ext ONLY if the page actually uses a character in that range. Do not
   "simplify" by dropping it — that forces both downloads on every page.

   Only Regular 400 exists in this family, and only h1–h5 use it. There is no
   italic and no bold: if a design node ever needs one, the browser will
   synthesise it (badly) — treat that as a spec question, not a CSS fix.

   `font-display: swap` shows fallback text immediately rather than blank text.
   ⚠️ The fidelity gate must therefore `await document.fonts.ready` before
   measuring, or it will occasionally screenshot Georgia and score a type miss
   that does not exist.
   -------------------------------------------------------------------------- */

/* latin-ext — accented characters only. */
@font-face {
  font-family: "Instrument Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/InstrumentSerif-Regular-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

/* latin — the common case. */
@font-face {
  font-family: "Instrument Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/InstrumentSerif-Regular-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 ------------------------------------------------------------- */
  /* Figma style names kept verbatim. */
  --ooc-light:        #F2E6D5;   /* "light"     — 100:5838, 226:2032 */
  --ooc-light-2:      #FFF9E9;   /* "light 2"   — all probed frames */
  --ooc-navy-blue:    #002765;   /* "navy blue" — all probed frames */

  /* Interaction states, read off the Button COMPONENT_SET (194:7) — the design
     DOES define hover and pressed, for both button types. These four hexes are
     not in the colour-styles panel; they exist only as variant fills. */
  --ooc-navy-hover:   #003880;   /* Type=Primary,   State=Hover   186:20 */
  --ooc-navy-press:   #001845;   /* Type=Primary,   State=Pressed 186:25 */
  --ooc-cream-hover:  #F2EDDE;   /* Type=Secondary, State=Hover   191:4  */
  --ooc-cream-press:  #E0D9C9;   /* Type=Secondary, State=Pressed 191:6  */

  /* -- Type: families -----------------------------------------------------
     Display is RESOLVED. Body is still a placeholder.

     ✅ --ooc-font-display — real. Instrument Serif Regular 400, SIL Open Font
        License, self-hosted from assets/fonts/ (see the @font-face block in
        §1a). Permanent; nothing further is owed. Georgia leads the fallback
        chain because it is the closest metric-ish serif if the woff2 fails.

     ⚠️ --ooc-font-sans — FONT-PLACEHOLDER, still a system stack.
        grep -rn FONT-PLACEHOLDER  finds every line that has to change.

     Why the body face is still a placeholder even though a Suisse zip arrived:
     the supplied file was the Swiss Typefaces FREE TRIAL, and the trial webfonts
     are deliberately crippled — measured 2026-07-30 with fontTools, they carry
     a–z, A–Z and accents but **only the digit 0 (1–9 absent)** and almost no
     punctuation (no : ; ! ? ' " ( ) & @ # % © and no em/en dash). Four of five
     real strings from the design cannot render: "+1 347-782-6833",
     "©OneOcean 2026", both street addresses, and anything with a straight
     apostrophe. Each missing glyph silently falls back to another font
     mid-string, so line widths measure wrong AND look broken. Unusable for
     fidelity work, so the trial files were deleted rather than kept as a
     licence risk. Its EULA also forbids public display either way.

     Still owed: Suisse Int'l on a WEB licence, FOUR upright weights —
     Regular 400, Book 450, Medium 500, Semibold 600. No italics.
     See FIGMA.md → "Fonts".

     The swap is one value here plus a @font-face block. Nothing else in this
     stylesheet should need to change — every section must consume
     var(--ooc-font-display) / var(--ooc-font-sans), never a literal family.

     EXPECT BODY LINE COUNTS AND WRAP POINTS TO MOVE ON SWAP. That is the font
     changing, not a regression — do NOT tune sizes, leading or tracking to
     make the placeholder look right, or the real face will land wrong. Re-run
     the gate after the swap and treat the deltas as the new baseline.
     ---------------------------------------------------------------------- */

  --ooc-font-display: "Instrument Serif", Georgia, "Times New Roman", Times, serif;

  /* LIGATURES ON DISPLAY TYPE. Not decoration — the design renders "refined"
     and "fish" with a joined f_i, and without this the browser draws a separate
     f and a dotted i.

     ⚠️ THE REASON IS letter-spacing, NOT the feature being off by default.
     Browsers switch ligatures OFF whenever letter-spacing is non-zero, and
     every display node in this file carries tracking (-2px). Measured in
     Chrome, "refined" at 100px:

         letter-spacing: 0                        243.20   ligated
         letter-spacing: 0,   liga off            243.81   not ligated
         letter-spacing: -2px                     229.81   NOT ligated (243.81-7*2)
         letter-spacing: -2px + common-ligatures  229.81   still not ligated
         letter-spacing: -2px + "liga" 1          231.20   ligated  (243.20-6*2)

     So `font-variant-ligatures: common-ligatures` does NOT bring them back —
     only font-feature-settings does. Do not "modernise" this to the
     font-variant syntax; it silently reverts the fix.

     "dlig" is carried because that is what the Figma nodes declare
     (100:6581, 100:6582, 228:3488, 228:3489, 228:3454). Our subset has no dlig
     feature at all — verified with fontTools, the woff2 exposes only ccmp/liga/
     locl — so it is inert today and correct the day a fuller cut is installed.

     This was invisible to every geometry probe: the word occupies nearly the
     same box either way. It surfaced only from diffing the render against the
     Figma render pixel-for-pixel, which is why step 3 of the measure cycle is
     not optional.

     Paired with each `font-family: var(--ooc-font-display)` rather than set
     globally, because the body face wants a different list (the buttons declare
     "salt"/"ss01"/"ss04"/"ss06", which the placeholder does not have). */
  --ooc-font-display-features: "liga" 1, "dlig" 1;

  /* FONT-PLACEHOLDER → Suisse Int'l (4 upright weights, see above).
     A neo-grotesk system stack, the closest basic match to Suisse Int'l.
     Note no system font has a real 450 cut, so --ooc-weight-book will be
     rounded by the browser until the licensed font lands. */
  --ooc-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
                   Helvetica, Arial, sans-serif;

  /* -- Type: scale -------------------------------------------------------- */
  /* Desktop values, from the Text styles panel. Mobile does NOT redefine these
     as variables — mobile sizes are set per text node and must be measured per
     section, not derived from this scale. */
  /* ⚠️ h3's tracking is the ONLY one Figma stores as a PERCENT (-1%), not
     pixels — so it is -0.01em, i.e. -0.64px at 64px, NOT -1px. Reading the
     number without its unit produces a 0.36px-per-character drift that the
     gate would flag as a type miss with no obvious cause. */
  --ooc-h1-size: 130px;  --ooc-h1-lh: 130px;  --ooc-h1-ls: -2px;
  --ooc-h2-size: 100px;  --ooc-h2-lh: 100px;  --ooc-h2-ls: -2px;
  --ooc-h3-size:  64px;  --ooc-h3-lh:  68px;  --ooc-h3-ls: -0.01em;
  --ooc-h4-size:  48px;  --ooc-h4-lh:  48px;  --ooc-h4-ls: -1px;
  --ooc-h5-size:  32px;  --ooc-h5-lh:  36px;  --ooc-h5-ls: 0;
  --ooc-body-m-size: 16px;  --ooc-body-m-lh: 20px;  --ooc-body-m-weight: 600;
  --ooc-body-s-size: 14px;  --ooc-body-s-lh: 20px;  --ooc-body-s-weight: 450;

  /* Body weights actually used by text nodes — NOT just the two the named text
     styles declare. Measured across 2,778 text segments on HP + IPs +
     Animation. `Medium 500` appears 712 times and has no text style at all. */
  --ooc-weight-regular:  400;
  --ooc-weight-book:     450;
  --ooc-weight-medium:   500;
  --ooc-weight-semibold: 600;

  /* -- Container ---------------------------------------------------------- */
  /* Reference frames: desktop HP V1 = 1440, mobile HP Mobile = 393 (NOT 375).
     Gutter derived by measuring the left/right inset of every non-hidden
     grandchild of both frames — 20px is the modal value at both widths, giving
     a 1400px desktop content column and 353px on mobile. */
  --ooc-container-max:    1440px;
  --ooc-container-pad:      20px;   /* mobile  @393 */
  --ooc-container-pad-lg:   20px;   /* desktop @1440 */

  /* -- Chrome heights ------------------------------------------------------ */
  /* Desktop: the `main nav` component (211:3041) is 1440×80 — measured.
     Mobile: there is NO nav frame in the design, so this is DERIVED per Hard
     rule 2 and the derivation is recorded here. The logo (226:2206) is 56 tall
     at y=20 and the trigger (226:2265) is 44 tall at y=26 — both centre on
     y=48, so a 96px header with centred items reproduces both exactly. */
  /* Dropdown chevron shape (100:5905) as a mask, so currentColor colours it. */
  --ooc-chevron: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M12.5%208L8.5%2012L4.5%208%22%20stroke%3D%22%23000%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22square%22%2F%3E%3C%2Fsvg%3E");

  /* Scroll-cue arrow (100:5982 / 228:2276) as a mask, so it takes currentColor. */
  --ooc-arrow: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%203v14M4%2011l6%206%206-6%22%20stroke%3D%22%23000%22%20stroke-width%3D%221.5%22%2F%3E%3C%2Fsvg%3E");

  /* Attribute separator (228:3791) as a mask, so it takes currentColor. */
  --ooc-attr-sep: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2010%208%22%3E%3Cpath%20d%3D%22M4.85613%200C5.9842.106753%207.01272.240298%207.9512.851512C8.99869%201.5341%209.75945%202.53235%209.95141%203.67901C10.1339%204.73293%209.79736%205.80527%209.02714%206.6256C7.88485%207.8294%206.78759%207.92004%205.12393%208C4.1973%207.79466%203.26591%207.59245%202.34876%207.36136C1.38658%207.11959.628226%206.82927.201644%205.9698C-.227307%205.10698.0902462%204.14557.573705%203.35937C1.46242%201.92155%203.07159.503205%204.85613%200Z%22%2F%3E%3C%2Fsvg%3E");

  /* Carousel pagination blob (228:3893, the first of its four paths) as a mask.
     ⚠️ NOT the same blob as --ooc-attr-sep: the control points differ and this
     one carries a y offset. Reusing the separator here would be close enough to
     look right and wrong enough to be a defect. */
  --ooc-carousel-dot: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2012%22%3E%3Cpath%20d%3D%22M6.68462%200.119027C8.31506-0.211%2010.5191%200.155788%2011.9268%201.08873C13.498%202.13062%2014.6392%203.65432%2014.9271%205.40454C15.2008%207.01323%2014.696%208.65002%2013.5407%209.90214C11.8273%2011.7396%2010.1814%2011.878%207.68589%2012C6.29594%2011.6866%204.89886%2011.3779%203.52313%2011.0252C2.07987%2010.6562%200.942339%2010.213%200.302466%208.90115C-0.340961%207.58417%200.135369%206.1167%200.860558%204.91665C2.19363%202.722%203.31332%201.27411%206.68462%200.119027Z%22%2F%3E%3C%2Fsvg%3E");

  /* Numbered-badge blob (100:6754 / 230:3908) as a mask, so it takes a colour.
     The SAME blob as the carousel's drag cue (122:292) uniformly scaled — 77/77.6816
     and 61/61.4034 — but that one ships as an SVG file because it carries a second
     navy arrow path and a mask can only hold one colour. Desktop draws it 77x61 and
     mobile 60x48, a NON-uniform scale (x by 60/77, y by 48/61), so size it
     `100% 100%` and never `contain`. Design fills it `light 2` #FFF9E9. */
  --ooc-badge-blob: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2077%2061%22%3E%3Cpath%20d%3D%22M34.3144%200.605053C42.684%20-1.07259%2053.9979%200.791924%2061.2242%205.53438C69.2899%2010.8307%2075.1477%2018.5761%2076.6258%2027.4731C78.0309%2035.6506%2075.4396%2043.9709%2069.509%2050.3359C60.7133%2059.6763%2052.2645%2060.3796%2039.4542%2061C32.3192%2059.4067%2025.1475%2057.8378%2018.0854%2056.0447C10.6766%2054.1689%204.83734%2051.9162%201.55266%2045.2475C-1.75027%2038.5529%200.694896%2031.0932%204.41753%2024.993C11.2606%2013.8369%2017.0084%206.47674%2034.3144%200.605053Z%22%2F%3E%3C%2Fsvg%3E");

  --ooc-nav-h:    96px;
  --ooc-nav-h-lg: 80px;

  /* -- Fluid display ramp -------------------------------------------------- */
  /* Linear interpolation between the two reference widths, so each expression is
     EXACT at 393 and at 1440 and proportional between (Hard rule 3). Verified:
     h1 = 1.936rem + 6.877vw → 58.00px @393, 130.00px @1440.

     The ramp is per-step because the design's scale is NOT uniform — 130→58 is
     ×0.45 while 48→40 is ×0.83. One global multiplier would be wrong.

     Bounds are `rem`, not `px`: a px-bounded clamp() ignores the reader's
     browser font-size entirely, which fails WCAG 1.4.4. Keeping a rem term in
     the preferred value preserves user scaling and still lands on the designed
     px at both references. */
  --ooc-fluid-h1:       clamp(3.625rem, 1.936rem + 6.877vw, 8.125rem);  /*  58 → 130 */
  --ooc-fluid-h2:       clamp(3.125rem, 1.952rem + 4.776vw, 6.25rem);   /*  50 → 100 */
  --ooc-fluid-product:  clamp(2.5rem,   2.312rem + 0.764vw, 3rem);      /*  40 →  48 */
  --ooc-fluid-h5:       clamp(1.75rem,  1.656rem + 0.382vw, 2rem);      /*  28 →  32 */
  --ooc-fluid-legal:    clamp(0.75rem,  0.703rem + 0.191vw, 0.875rem);  /*  12 →  14 */
  --ooc-fluid-wordmark: clamp(7.75rem,  0.337rem + 30.18vw, 27.5rem);   /* 124 → 440 */
  /* body M (16) and body S (14) are NOT fluid — identical at both references. */

  /* An ALIAS, not a new ramp. The h4 token is 48px and the file's only measured
     48→mobile pair is the product name (48 → 40, docs/mobile-first.md §2), so a
     second 48px heading must ride the same ramp or the two would disagree at
     393 for no reason. Named for the type token rather than the section that
     first needed it; `--ooc-fluid-product` stays as the existing consumers'
     name so this is additive. */
  --ooc-fluid-h4:       var(--ooc-fluid-product);                       /*  40 →  48 */

  /* Contact details value — Figma 234:7943 / 234:7952, the h3 token at 64/68.
     ⚠️ THE MOBILE END IS DERIVED, NOT READ (Hard rule 2): no inner page has a
     mobile frame. 32 is not a guess — it is the largest step in the file's OWN
     measured scale (the h5/card step, 32 at both widths) at which the longest
     REAL value still fits the mobile card. The stacked card's inner width at
     393 is 353 − 2×20 = 313, and Instrument Serif runs ≈0.42em average.

     The longest value is now "info@oneoceanusa.com", 20 characters (2026-08-06:
     the three personal addresses were replaced by one general mailbox), giving
     313 / (20 × 0.42) ≈ 37.3px. The original derivation used the 22-character
     "steven@oneoceanusa.com" → ≈33.9px. THE ANSWER IS UNCHANGED AND THE MARGIN
     ONLY GREW: 32 clears both, and the next step up (40) clears neither. Kept at
     32 rather than re-derived upward, because a shorter address is not a reason
     to grow type the design never drew. Re-gate if a mobile inner-page frame
     ever lands, or if a longer address is ever published here. */
  --ooc-fluid-detail:   clamp(2rem,     1.2493rem + 3.0564vw, 4rem);    /*  32 →  64 */

  /* An ALIAS, exactly as `--ooc-fluid-h4` is one. The product detail page's
     "You May Also Like" (223:881) is the h3 token at 64/68/-1%, the same token
     the contact detail value above rides. Its mobile end is derived from a
     different constraint than that card's, but a type token that resolved to
     two different sizes at 393 would be a token in name only — so the second
     consumer rides the first one's ramp, and the name says which token it is
     rather than which section wanted it. */
  --ooc-fluid-h3:       var(--ooc-fluid-detail);                        /*  32 →  64 */
}

/* Read 2026-07-31 off the Contact frame, which is the first node in the file to
   apply either of them. Both were on the "STILL UNREAD" list below until now. */
:root {
  /* The `White` colour style. Distinct from `light 2` (#FFF9E9) and NOT
     interchangeable with it: the form card 234:7747 is pure #FFFFFF sitting on
     a #F2E6D5 page, and the details card's own heading 234:7791 is #FFFFFF
     while the phone/email values beside it are #FFF9E9. Two creams and a white,
     all in one 690px card — probed at 100% modal share rather than eyeballed,
     because at that separation they are indistinguishable by eye and a fills
     dump reports only whichever one you happened to open. */
  --ooc-white: #FFFFFF;           /* `White` — 234:7747, 234:7791 */

  /* The input hairline. Figma names it `dark-900-o10`; it is BLACK at 10%, not
     a tint of the navy, so it warms/cools with whatever it sits on rather than
     matching the text. Reproducing it as navy-at-10% was the obvious wrong
     move — it reads visibly bluer against #FFFFFF. */
  --ooc-hairline: rgba(0, 0, 0, 0.1);   /* 234:7885 stroke */
}

/* ⛔ STILL UNREAD — do not invent these. Each is a real Figma style that none
   of the probed frames applies at frame level; read it from the node that uses
   it, at the section build that needs it, then add it above with its node:

     Navy/Primary, Navy/Dark, Red/Accent,
     Cream/Base, Cream/Warm, Cream/Light,
     Gray/Dark, Gray/Medium, Gray/Light      (colour styles)
     --ooc-radius                              (no probed node reported one;
                                                the chrome uses 6px buttons and
                                                a 12px drawer card, both read
                                                straight off their nodes)

   ✅ `White` came off the list 2026-07-31 — see the block above. */


/* ----------------------------------------------------------------------------
   2. BASE
   -------------------------------------------------------------------------- */

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

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

body {
  margin: 0;
  /* Horizontal overflow is a gate failure at every breakpoint. Catching it at
     the root makes an offending child obvious instead of silently scrolling
     the whole page. */
  overflow-x: hidden;

  /* The document's base face. Without this every element that does not set its
     own font-family inherits the BROWSER DEFAULT — the first capture of the
     header measured `font-family: Times` on the nav links for exactly this
     reason, while every other property matched the design. A missing base font
     is invisible in review (Times is not obviously "wrong", just wrong) and
     silently poisons every type measurement, so it belongs here at the root
     rather than being repeated per component. */
  font-family: var(--ooc-font-sans);
  font-size: var(--ooc-body-m-size);
  line-height: var(--ooc-body-m-lh);
  /* The PAGE background. HP V1 (100:5838) is filled #F2E6D5 and most sections
     have NO fill of their own — they show this through. Defaulting sections to
     `light 2` made section 2 visibly too pale against the design. */
  background: var(--ooc-light);
}

img, picture, video, canvas, svg { display: block; max-width: 100%; }

/* Wide content (tables, code) scrolls inside its own container — the page body
   must never scroll horizontally.

   ⚠️ `display: block` MAKES A TABLE STOP LAYING OUT AS A TABLE. table-layout
   goes inert, and every column percentage then resolves against the shrink-to-
   fit content width instead of the table's — /services' 1400-wide grid
   measured 711 with four columns at 186/116/224/186. This is a safe DEFAULT
   for a table nobody designed (one pasted into a prose page, where overflowing
   the viewport is the worse failure), and it is wrong for a table that has its
   own scroll container. `.data-table__table` opts back out; see the note
   there. Anything else that ships a designed table must do the same. */
table { display: block; overflow-x: auto; }

a { color: inherit; }

button { font: inherit; }


/* ----------------------------------------------------------------------------
   3. ACCESSIBILITY UTILITIES
   ----------------------------------------------------------------------------
   Design-neutral, so they are safe to define before tokens exist.
   -------------------------------------------------------------------------- */

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

/* Visible on keyboard focus so the skip link actually works. */
.skip-link:focus {
  position: fixed;
  top: 0; left: 0;
  z-index: 9999;
  width: auto; height: auto;
  margin: 0; padding: 0.75rem 1rem;
  clip-path: none;
  white-space: normal;
}

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


/* ----------------------------------------------------------------------------
   4. LAYOUT PRIMITIVES
   ----------------------------------------------------------------------------
   The content column. Values are token-driven and therefore inert until
   Phase 1 — see the note in section 1.

   FLUID RULE (Hard rule 3): sections must be pixel-exact at the two Figma
   reference widths and PROPORTIONAL between them. Express in-section geometry
   as a % of this content box (plus aspect-ratio canvases), never as fixed px
   carried across the whole breakpoint range. The symptom of breaking this is
   dead space around ~860px, which is why the gate checks 860 and 1100 as well
   as 375 and 1440.

   ── DESKTOP COMPOSITION CAP: 1440. Approved by Vitaliy 2026-07-31. ─────────
   The mirror of the 560px MOBILE cap approved the same day, decided for the
   same reason and with the same status: the file has artboards at 393 and 1440
   and NOTHING ELSE, but the theme renders every width.

   Below 1440 the fluid rule governs. ABOVE 1440 the design specifies nothing,
   and the measured consequence of leaving that undecided was the page splitting
   into two layouts that fight each other — five sections froze at 1440 while
   four kept tracking the viewport. At 2560 the products grid measured 2520 wide
   directly above a carousel frozen at 1440: a 540px edge mismatch on each side,
   product cards inflated 453 -> 827 under a heading the `clamp()` ramp had
   already stopped at 100px, and 1,249px of extra page height bought with no
   extra content.

   THE RULE: above 1440 the CONTENT stops and centres; only the fills the design
   itself draws edge-to-edge keep bleeding. Concretely —

     bleeds  the hero video, `products` #FFF9E9, `site-footer` #FFF9E9
     caps    every content column, at var(--ooc-container-max)

   `newsletter` is NOT in the bleed list on purpose: 100:6850 is a 1400-wide
   card with a 20px radius inset in the page, not a band, so it caps like
   content. Sections with an empty fills array (numbered-list, coverage) have
   nothing to bleed, so capping them is invisible apart from the alignment it
   buys.

   ⚠️ THE BLEED LIST HAS GROWN THREE TIMES SINCE. scripts/check-composition-cap
   .mjs is the authority — read BLEEDS there, not this line. Two of the entries
   are NOT fills and are worth knowing about:

     `statement` / `collage`  empty fills arrays, bleeding on Vitaliy's call
                              2026-08-02 ("background taking full width"), the
                              texture having been a 1440 island either side.
     `carousel`   2026-08-02  the section's fills are empty, but `gallery`
                              100:6692 is x0 w1440 — drawn at the FULL frame
                              width, against a header at x20 w1400. The file
                              distinguishes them, so this one IS design-backed
                              even though the section fill is not.

   In every case the CONTENT still freezes; that half of the rule has never been
   relaxed and is what TARGETS in the script asserts.

   ⚠️ DERIVED, NOT READ (Hard rule 2), exactly like the 560 mobile cap. 1440 is
   in the file as a FRAME width; "hold it as a ceiling" is an engineering call,
   not a measurement. If the designer ever supplies a wide artboard or states a
   preferred >1440 behaviour, this becomes a read value and every section that
   caps here must be re-gated. Recorded in FIGMA.md -> "Also decided".

   ⚠️ The gate cannot see a violation of this rule at 1440/1100/860/393 — every
   width it captures is at or below the cap, which is exactly why the split
   above shipped at 100%. e2e captures 1920 as well; keep it there.
   -------------------------------------------------------------------------- */

.container {
  width: 100%;
  max-width: var(--ooc-container-max);
  margin-inline: auto;
  /* padding-inline, not `padding: y 0` — a shorthand here silently clobbers
     the horizontal gutter, which cost a debugging session on a prior build. */
  padding-inline: var(--ooc-container-pad);
}

@media (min-width: 1025px) {
  .container { padding-inline: var(--ooc-container-pad-lg); }
}

/* A light-leading page must clear the fixed nav; a dark full-bleed hero sits
   underneath it. The body class is set once in PHP (ooc_nav_variant) so this
   never needs a per-template override. */
body.nav-light main { padding-top: var(--ooc-nav-h); }

@media (min-width: 1025px) {
  body.nav-light main { padding-top: var(--ooc-nav-h-lg); }
}


/* ----------------------------------------------------------------------------
   5. CHROME
   ----------------------------------------------------------------------------
   Header  — Figma desktop `main nav` 211:3041 (1440×80) · mobile logo 226:2206
             + trigger 226:2265 · drawer 230:5103
   Footer  — Figma desktop 130:661 (1440×604) · mobile 230:4332 (393×846)

   MOBILE FIRST: everything below the media query is the 393 design. The single
   `min-width: 1025px` block is the 1440 design. Authoring it the other way round
   is what leaks desktop values into the middle band and produces the ~860px dead
   space the gate checks for.

   Trap banked from prior builds, live in this file: a scrolled-state
   `backdrop-filter` on the header makes it a containing block for
   position:fixed children, which clips the drawer to the header's height. If a
   scrolled treatment is added later, disable the filter while the drawer is
   open. Related: state-dependent chrome has TWO sets of computed styles — probe
   the at-top state on a FRESH load (scroll classes linger), then the scrolled
   state separately.
   -------------------------------------------------------------------------- */

/* -- Shared button ---------------------------------------------------------
   Figma 100:5974 (header), 226:2265 (mobile trigger), 230:4608 (drawer close),
   230:5069 (drawer CTA). Identical treatment, three heights — so height is the
   only thing a context overrides.
   Note letter-spacing is −2 PERCENT here, not px → -0.02em.                  */
.ooc-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 44px;               /* mobile trigger 226:2265 — also the 44×44
                                     minimum tap target, so never shrink it */
  padding-inline: 20px;
  border: 0;
  border-radius: 6px;
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  font-family: var(--ooc-font-sans);
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-medium);
  line-height: var(--ooc-body-m-lh);
  letter-spacing: -0.02em;
  text-decoration: none;
  cursor: pointer;
  transition: background-color 120ms ease;
}

/* Secondary is the default treatment — it is what the header CTA, the mobile
   trigger, the drawer Close and the drawer CTA all use (all cream-on-navy). */
.ooc-button:hover  { background: var(--ooc-cream-hover); }
.ooc-button:active { background: var(--ooc-cream-press); }

/* Primary (194:7 Type=Primary) — navy fill, cream text, 48 tall. Not used by the
   chrome; defined here because it is half the component set and sections will
   need it, so the states live in one place rather than being rediscovered. */
.ooc-button--primary {
  min-height: 48px;
  background: var(--ooc-navy-blue);
  color: var(--ooc-light-2);
}
.ooc-button--primary:hover  { background: var(--ooc-navy-hover); }
.ooc-button--primary:active { background: var(--ooc-navy-press); }

/* NOT FROM THE DESIGN — the file defines no focus state for any control.
   Shipping without a visible focus ring is a WCAG 2.4.7 failure, so this is a
   deliberate, flagged addition using the design's own palette rather than an
   invention of new colour. Replace it if a focus state is ever designed. */
.ooc-button:focus-visible,
.site-header__link:focus-visible,
.site-nav a:focus-visible,
.site-nav__disclosure:focus-visible,
.site-footer a:focus-visible,
.site-drawer__menu a:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
  border-radius: 2px;
}

/* ✅ BOTH VARIANTS NOW EXIST — light RESOLVED 2026-07-31 from `main nav`
   234:7413 (the Contact frame's instance). This block previously said "ONLY THE
   DARK VARIANT EXISTS" and refused to invent the light colours; that refusal is
   discharged, because a node finally uses them.

   ⚠️ THE BAR IS STILL TRANSPARENT — that was the thing worth checking, and it
   is the opposite of what "light header" usually means. Sampling a blank strip
   of the rendered node (x560 y8, 120x24) reads **#F2E6D5 at 100% modal share
   over 2,880px** — the page colour showing through, not a fill of its own. So
   this is NOT a solid bar: it is the same transparent header with the ink
   inverted. Giving it a background would have been the obvious, wrong move.

   GEOMETRY IS IDENTICAL between the two instances — menu left 20 / top 32
   gap 24, logo 80x71 top 10 centred, actions right 20 / top 20 gap 24, CTA
   h42 radius 6 px20. Nothing below touches layout; every rule is paint only.

   The three paint deltas, read from the node:

     ink        #FFF9E9  ->  #002765   links, Login, and the logo (currentColor)
     CTA        secondary -> PRIMARY   navy fill + cream text, i.e. the exact
                                       swap of the dark bar's cream-on-navy
     chevron    no change              it is a mask on currentColor already, so
                                       it inverts for free — see the note in the
                                       desktop block

   ⚠️ THE MOBILE TRIGGER IS A DERIVATION (Hard rule 2), not a read. No mobile
   frame exists for ANY inner page, so nothing in the file shows this bar at
   393. `.site-header__toggle` is an `.ooc-button`, i.e. cream-on-cream, which
   would be invisible against a light page — the same failure this block was
   written to prevent, just one breakpoint over. It takes the primary treatment
   because that is what the desktop instance does to its sibling CTA. Flagged
   here rather than passed off as spec; re-gate it if a mobile inner-page frame
   ever lands.                                                                */

/* ⚠️ `nav-stuck` IS LISTED SEPARATELY, NOT FOLDED INTO `nav-light`.
   The sticky header first inverted by having main.js add `nav-light` to the
   body — reusing the treatment rather than restating it, which was right about
   the COLOURS and wrong about the class. `nav-light` also carries LAYOUT:
   `body.nav-light main { padding-top: var(--ooc-nav-h) }` reserves the bar's
   height on pages whose first section is not a full-bleed hero. Adding it
   mid-scroll therefore pushed the entire page down 80px in one frame — the
   residual jump Vitaliy reported after the position fix ("still jumping when
   white background appearing, but visually less then it was").
   So the ink rules name both states and the layout rule names only one. */
body.nav-light .site-header__brand,
body.nav-light .site-nav a,
body.nav-light .site-nav__disclosure,
body.nav-light .site-header__link,
body.nav-stuck .site-header__brand,
body.nav-stuck .site-nav a,
body.nav-stuck .site-nav__disclosure,
body.nav-stuck .site-header__link {
  color: var(--ooc-navy-blue);    /* I234:7413;100:5900 / ;136:1122 */
}

/* The CTA and the mobile trigger swap to the primary variant. Restating the
   three values rather than adding `.ooc-button--primary` in PHP: the variant is
   a property of the PAGE, not of the button, so a template that hard-coded the
   class would ship the wrong colour the moment a page's leading section
   changed. min-height is deliberately NOT restated — 42px (CTA) and 44px (tap
   target) are set elsewhere and must survive. */
body.nav-light .site-header__actions .ooc-button,
body.nav-light .site-header__toggle,
body.nav-stuck .site-header__actions .ooc-button,
body.nav-stuck .site-header__toggle {
  background: var(--ooc-navy-blue);   /* I234:7413;100:5974 */
  color: var(--ooc-light-2);          /* I234:7413;100:5975 */
}
body.nav-light .site-header__actions .ooc-button:hover,
body.nav-light .site-header__toggle:hover,
body.nav-stuck .site-header__actions .ooc-button:hover,
body.nav-stuck .site-header__toggle:hover  { background: var(--ooc-navy-hover); }
body.nav-light .site-header__actions .ooc-button:active,
body.nav-light .site-header__toggle:active,
body.nav-stuck .site-header__actions .ooc-button:active,
body.nav-stuck .site-header__toggle:active { background: var(--ooc-navy-press); }

/* -- Header ---------------------------------------------------------------- */
.site-header {
  /* ⚠️ FIXED AT ALL TIMES, and that IS the smoothness fix. This was
     `absolute`, switching to `fixed` when the sticky state engaged — and
     swapping the positioning scheme mid-scroll is a jump by construction: the
     bar leaves the document and is re-pinned to the viewport in one frame,
     with a drop-in animation replaying on top of it. Vitaliy: "it is jumping on
     scroll can you make it smoother."

     Fixed throughout, nothing MOVES at all. Only paint changes — a background
     fades in and the ink inverts — so there is no geometry to stutter. At
     scroll 0 fixed and absolute put the bar in exactly the same place, which is
     why every measured value on every page is unchanged. */
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 100;
  height: var(--ooc-nav-h);
  /* The fade. `background-color` and `box-shadow` here; the INK transitions are
     on the elements that carry colour, further down. */
  transition: background-color .28s ease, box-shadow .28s ease;
}

/* The colour inversion fades with the bar rather than snapping a frame later.
   Kept on one line per group so the property list cannot drift from the rules
   that actually set these colours (`body.nav-light …`). */
.site-header__brand,
.site-nav a,
.site-nav__disclosure,
.site-header__link {
  transition: color .28s ease;
}
.site-header__actions .ooc-button,
.site-header__toggle {
  transition: background-color .28s ease, color .28s ease;
}

@media (prefers-reduced-motion: reduce) {
  .site-header,
  .site-header__brand,
  .site-nav a,
  .site-nav__disclosure,
  .site-header__link,
  .site-header__actions .ooc-button,
  .site-header__toggle { transition: none; }
}

/* ---- STICKY ON SCROLL — DERIVED, no node. Vitaliy 2026-08-05 -------------
   "lets do header sticky on scroll it will have white background and invert
   font color and button color and logo color."

   ⚠️ THE FILE DRAWS NO SCROLLED STATE. `main nav` 211:3041 is one 1440x80
   symbol with a transparent bar; there is no second frame showing it over
   content. So this is a derivation, like the Products panel — recorded in
   FIGMA.md rather than passed off as read.

   ⚠️ IT INVERTS BY REUSING `nav-light`, NOT BY RESTATING IT. main.js adds the
   page's own light class when the header sticks, so the brand, the links, the
   disclosure chevron, the header link, the CTA and the mobile trigger all take
   the treatment ALREADY READ from `234:7413` — the Contact frame's light nav
   instance. Restating those colours here would be a second source of truth that
   drifts the first time one of them changes; the only thing this block adds is
   what the light instance has no opinion about, because it never sits over
   content: a fill and the fact of being fixed.

   The fill is `--ooc-light-2` #FFF9E9, not #FFFFFF. "White background" is the
   ask; #FFF9E9 is this site's white — every panel the design calls white is
   that value (`products`, `coverage`, `site-footer`), and a true #FFFFFF bar
   would be the only one on the page. */
body.nav-stuck .site-header {
  background: var(--ooc-light-2);
  /* The design gives the bar no border anywhere. A hairline is what stops it
     dissolving into a cream section scrolling underneath it — the same 1px
     navy the product cards rule themselves with, at low alpha so it reads as a
     shadow rather than a drawn line. */
  box-shadow: 0 1px 0 rgb(0 39 101 / .08);
}

/* ⚠️ THE DROP-IN ANIMATION IS GONE. It existed to cover the absolute -> fixed
   swap, and it was half the jump it was meant to hide: `both` fill mode meant
   it replayed on every re-stick, so scrolling up and down across the threshold
   re-ran a 220ms slide each time. With the bar permanently fixed there is
   nothing to slide in — it is already there, and only its paint changes. */

.site-header__inner {
  display: flex;
  align-items: center;            /* 96px tall + 56px logo → top 20 ✓
                                     96px tall + 44px trigger → top 26 ✓
                                     both exactly as designed, no offsets */
  justify-content: space-between;
  height: 100%;
  padding-inline: var(--ooc-container-pad);
  /* DESKTOP COMPOSITION CAP (§4). The bar itself is transparent over the hero,
     so there is no fill to bleed — only the logo/nav/CTA row, which `space-
     between` otherwise pushes to the viewport edges: measured 1440 → 2560 wide,
     tearing the nav away from the hero headline it is meant to sit above.
     Inert below 1440. */
  max-width: var(--ooc-container-max);
  margin-inline: auto;
}

/* The links and the Login/Contact pair are drawer-only on mobile. */
.site-header__primary,
.site-header__actions { display: none; }

.site-header__brand {
  display: inline-flex;
  align-items: center;
  color: var(--ooc-light-2);      /* the inlined SVG is currentColor */
  text-decoration: none;
}

.site-header__logo {
  display: block;
  width: 63px;                    /* mobile logo 226:2206 = 63×56 */
  height: 56px;
}
.site-header__logo svg { display: block; width: 100%; height: 100%; }

.site-header__toggle { flex: 0 0 auto; }

/* -- Mobile drawer — Figma 230:5103 ---------------------------------------- */
.site-drawer {
  position: fixed;
  inset: 0;
  z-index: 200;
  /* Design stacks TWO black rects at 20% and 30% (230:5075, 230:5076).
     Composited that is 1 − (0.8 × 0.7) = 0.44, so one layer is equivalent and
     avoids a second paint. */
  background: rgb(0 0 0 / 0.44);
}
/* `hidden` alone loses to `display: fixed`-style rules in some resets, so make
   the attribute authoritative. */
.site-drawer[hidden] { display: none; }

/* Scroll-lock the page behind the modal. Without this, momentum scrolling on
   iOS moves the document under the drawer. */
body.nav-open { overflow: hidden; }

.site-drawer__card {
  position: absolute;
  inset: 10px 10px auto 10px;     /* card is inset 10 and NOT full height */
  display: flex;
  flex-direction: column;
  /* ⚠️ The card is 607 tall as drawn (230:5103) and sized by its content, which
     was safe while the content was three links. Expanding Products adds seven
     rows: measured 870 against an 852 viewport, so the last products sat below
     the fold with nothing to scroll. `dvh` not `vh` for the same reason the
     hero uses it — `vh` is the LARGEST viewport on mobile and would leave the
     bottom under the URL bar. */
  max-height: calc(100dvh - 20px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 10px;
  border-radius: 12px;
  background: var(--ooc-navy-blue);
  color: var(--ooc-light-2);
}

.site-drawer__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-block-end: 50px;         /* Frame 2007 gap */
}
.site-drawer__logo { display: block; width: 63px; height: 56px; }
.site-drawer__logo svg { display: block; width: 100%; height: 100%; }

.site-drawer__menu {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Dividers are 1px rects in the design (230:5081 etc) with a 12px gap either
   side: divider, 12, item(52), 12, divider … Expressed as a border plus
   symmetric padding, one <li> is 1 + 12 + 52 + 12 = 77, and 4 items plus the
   closing border is 4×77 + 1 = 309 — the measured height of `menu` 230:5034. */
.site-drawer__menu > li {
  border-block-start: 1px solid var(--ooc-light-2);
  padding-block: 12px;
}
.site-drawer__menu > li:last-child {
  border-block-end: 1px solid var(--ooc-light-2);
}

/* ---- The drawer's Products disclosure — Vitaliy 2026-08-05 --------------
   "mobile menu when i click on products it wont open". It was a plain link and
   navigated, which is correct behaviour for a link and the wrong behaviour for
   a row with a catalogue behind it. The row is a BUTTON now and /products/ is
   the first entry inside, because a 52px touch target has no room for two
   controls doing different things — see inc/nav-dropdown.php.

   ⚠️ TEXT ONLY. The desktop panel is six 216px cards; stacked into a 393-wide
   drawer that is 607 tall to begin with, that is not a menu. "keep it simple
   for mobile" is taken literally: names, and the way back to the full page. */
.site-drawer__disclosure {
  /* Matches `.site-drawer__menu a` exactly — same face, size, leading and
     target — so the row does not read as a different KIND of thing just
     because it opens rather than navigates. */
  /* ⚠️ A BUTTON INHERITS NONE OF `.site-drawer__menu a`. Those properties are
     on the anchor selector, so the button rendered at the UA's default font in
     a 353x20 box — under the 44x44 tap floor this project checks, and visibly
     a different kind of row from the two beside it. Restated here rather than
     widening the anchor selector, so the link rule stays a statement about
     links. */
  display: flex;
  align-items: center;
  min-height: 52px;
  width: 100%;
  justify-content: space-between;
  gap: 12px;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 40px;
  font-weight: 400;
  line-height: 52px;
  letter-spacing: 0;
  text-align: start;
  cursor: pointer;
}
.site-drawer__disclosure:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
  border-radius: 2px;
}
.site-drawer__disclosure-icon {
  flex: none;
  width: 20px;
  height: 20px;
  background-color: currentColor;
  -webkit-mask: var(--ooc-chevron) center / 20px 20px no-repeat;
          mask: var(--ooc-chevron) center / 20px 20px no-repeat;
  transition: transform .18s ease;
}
@media (prefers-reduced-motion: reduce) {
  .site-drawer__disclosure-icon { transition: none; }
}
.site-drawer__disclosure[aria-expanded="true"] .site-drawer__disclosure-icon {
  transform: rotate(180deg);
}

.site-drawer__submenu {
  list-style: none;
  margin: 0 0 8px;
  padding: 0 0 0 20px;
  display: flex;
  flex-direction: column;
}
.site-drawer__submenu[hidden] { display: none; }
.site-drawer__menu .site-drawer__submenu a {
  /* ⚠️ SCOPED THROUGH `.site-drawer__menu` ON PURPOSE. `.site-drawer__submenu a`
     alone ties `.site-drawer__menu a` on specificity (0,1,1) and loses on source
     order, so these rendered at the top level's 40px/52 — measured — and a
     second rank that is the same size as the first is not a second rank. This
     selector is (0,2,1) and wins wherever it sits in the file.

     Smaller is also what keeps the card on screen: seven 52px rows plus the two
     other links is what pushed it to 870 in an 852 viewport. 44 keeps the tap
     target above the 44x44 floor this project checks for. */
  min-height: 44px;
  font-size: 24px;
  line-height: 44px;
}

.site-drawer__menu a {
  display: flex;
  align-items: center;
  min-height: 52px;
  font-family: var(--ooc-font-display);   /* 40px Instrument Serif, not the
                                             body face — 230:5036 */
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 40px;
  font-weight: 400;
  line-height: 52px;
  letter-spacing: 0;
  color: inherit;
  text-decoration: none;
}

.site-drawer__cta {
  margin-block-start: 120px;      /* SPACE_BETWEEN resolves to 120 in the
                                     587-tall inner (230:5102) */
  min-height: 52px;
  justify-content: space-between;
}
.site-drawer__cta-icon {
  flex: none;
  width: 30.363px;                /* I230:5069;186:17 */
  height: 24px;
}

/* ⚠️ THE DRAWER'S CHEVRON IS 20px WHERE THE DESKTOP NAV'S IS 16 (230:5041
   against 100:5905), and the row is `justify-between` with a 4px right inset
   rather than an 8px gap. Same per-item trigger as the desktop rule above —
   the design marks Products as the only dropdown — but it does NOT inherit it,
   because that rule is scoped to `.site-nav` and this menu is not in one. It
   rendered with no chevron at all until this was written. */
.site-drawer__menu .menu-item-has-children > a,
.site-drawer__menu .has-dropdown > a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-inline-end: 4px;        /* 230:5039 pr-[4px] */
}
.site-drawer__menu .menu-item-has-children > a::after,
.site-drawer__menu .has-dropdown > a::after {
  content: "";
  flex: none;
  width: 20px;                    /* 230:5041 */
  height: 20px;
  background-color: currentColor;
  -webkit-mask: var(--ooc-chevron) center / 20px 20px no-repeat;
          mask: var(--ooc-chevron) center / 20px 20px no-repeat;
}

/* -- Footer --------------------------------------------------------------- */
.site-footer {
  position: relative;
  /* `clip`, not `hidden`: the wordmark bleeds past both edges by design, and
     `hidden` would make this a scroll container and trip the overflow gate. */
  overflow: clip;
  /* First row is at y=50 on both breakpoints. The BOTTOM differs: mobile legal
     ends at 836 in an 846-tall footer (10), desktop legal ends at 584 in 604
     (20). Assuming one value for both left mobile 10px too tall. */
  padding-block: 50px 10px;
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  font-family: var(--ooc-font-sans);
}

.site-footer__inner {
  position: relative;
  z-index: 1;                     /* above the decorative wordmark */
  display: grid;
  gap: 40px;                      /* measured row gap: 138→178, 232→272, 418→458 */
  padding-inline: var(--ooc-container-pad);
  /* DESKTOP COMPOSITION CAP (§4). The footer's #FFF9E9 fill stays full-bleed —
     it is on .site-footer, not here — while the link columns stop at the 1400
     content column. Without this the desktop track is `auto auto auto 1fr` with
     Contacts justified to the end, so the right-hand column tracks the viewport:
     measured 1440 → 1920 → 2560, i.e. Contacts sitting 560px right of where
     130:716 puts it at 2560. Inert below 1440. */
  max-width: var(--ooc-container-max);
  margin-inline: auto;
}

/* Mobile: heading BESIDE its list, list column starting at x=148 (230:4419).
   148 + 205 = 353 = the mobile content column. */
.site-footer__group {
  display: grid;
  grid-template-columns: 148px 1fr;
  align-items: start;
}

.site-footer__heading {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
  letter-spacing: 0;
}

.site-footer__menu {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 14px;                      /* Frame 1991 itemSpacing */
}

.site-footer__body,
.site-footer__menu a,
.site-footer__address {
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-m-lh);
  letter-spacing: 0;
}

.site-footer__menu a { color: inherit; text-decoration: none; }

.site-footer__body { display: grid; gap: 14px; }

.site-footer__address {
  margin: 0;
  font-style: normal;             /* <address> italicises by default */
  /* 22px, NOT the 20px body-M leading. Read off 230:4380 / 230:4384, which are
     lineHeight PIXELS 22 while every other footer string is 20. Three wrapped
     lines × 22 = the node's measured 66px height; at 20px the group came out
     12px short and pushed everything below it up. The addresses are the only
     footer text with their own leading — do not "tidy" this to the token. */
  line-height: 22px;
}

/* Legal: mobile stacks the links and bottom-right-aligns the copyright
   (230:4405 VERTICAL gap 4 · 230:4404 at x268 → right edge 373). */
.site-footer__legal {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: end;
  /* Design separation from the last group is 212 (ends 580 → legal at 792), but
     .site-footer__inner already contributes its 40px grid row-gap, so the margin
     is 212 − 40. Setting the full 212 double-counted the gap and put legal at
     820 instead of 792. */
  margin-block-start: 172px;
  font-size: var(--ooc-fluid-legal);
}
.site-footer__legal-menu {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 4px;
}
.site-footer__legal-menu a { color: inherit; text-decoration: none; }
.site-footer__copyright { margin: 0; text-align: end; }

/* Decorative wordmark + image. Bleeds both edges on purpose. */
.site-footer__decor {
  position: absolute;
  inset-block-end: 0;
  inset-inline: 0;
  z-index: 0;
  display: grid;
  /* Pin the column to the container width. Without this the implicit column is
     sized by its widest item — the max-content wordmark, ~417px at mobile — and
     the image's percentage width then resolves against THAT, coming out 15px
     too wide and 12px too tall. The wordmark still overflows this column on
     purpose; that is the bleed. */
  grid-template-columns: minmax(0, 1fr);
  place-items: end center;
  pointer-events: none;
  /* DESKTOP COMPOSITION CAP (§4). This is what the decor image's percentage
     width resolves against, so uncapped it grew 580 → 773 → 1031 (40.28% of the
     viewport instead of 40.28% of the frame). Capping the CONTAINER rather than
     the image keeps one source of truth for both children.

     The wordmark deliberately still overflows this box — that is the bleed, and
     it needs no cap of its own: --ooc-fluid-wordmark is already clamp()ed to
     440px, so it measures a constant 1493.9px wide and simply re-centres. At
     1920 it spans the capped box ±27px, which is the same ±19.5px-ish bleed
     130:663 draws at 1440. Do NOT add overflow:clip here to "tidy" that — it
     would cut the bleed the design asks for. */
  max-width: var(--ooc-container-max);
  margin-inline: auto;
}
.site-footer__wordmark {
  display: block;
  grid-area: 1 / 1;
  width: max-content;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-wordmark);
  /* Box height matches the node so the glyphs sit where the design puts them:
     230:4334 is 110 tall at 124px (110/124 = 0.887). Desktop overrides. */
  line-height: 0.887;
  letter-spacing: -2px;           /* mobile 230:4334 */
  color: var(--ooc-navy-blue);
  /* ⚠️ THE WORDMARK HANGS OFF THE BOTTOM OF THE FOOTER. 230:4334 spans
     772–882 in an 846-tall footer, so 36px of it is clipped away. Bottom-
     aligning it inside the footer — which is what `place-items: end` does on
     its own — floats the whole word up into the link columns and it collides
     with the addresses. This negative margin is the clipping. */
  margin-block-end: -36px;
  /* ⚠️ THE WORDMARK IS 10% OPACITY (130:663 and 230:4334 both opacity 0.1).
     Its FILL is solid #002765 with paint-opacity 1, so reading the fill alone —
     as the first pass did — produces a solid navy slab that swallows the legal
     line sitting on top of it. Node opacity and paint opacity are different
     properties and only the paint one shows up in a fills dump. The gate did not
     catch it either, because nothing asserted opacity; it is asserted now. */
  opacity: 0.1;
}
.site-footer__decor-img {
  grid-area: 1 / 1;
  align-self: end;
  width: 71.4%;                   /* 280.6 / 393, exact — % of the viewport,
                                     not px, so it scales through the band */
  /* ⚠️ THE TWO BREAKPOINTS CROP THE SAME PHOTO DIFFERENTLY. 230:4335 is
     scaleMode CROP at 280.6x257.4 (ratio 1.09); 130:664 is FILL at 580x385
     (ratio 1.51). Letting `height: auto` derive from the export's own aspect
     gave a mobile image 66px too short and 66px too low. Pinning aspect-ratio
     per breakpoint and cover-fitting reproduces both crops from ONE asset —
     otherwise this needs a <picture> with two exports. */
  aspect-ratio: 280.6 / 257.4;
  height: auto;
  object-fit: cover;
  object-position: center;
  /* The image runs PAST the footer's bottom edge and is clipped, rather than
     sitting flush on it: 230:4335 spans 602.1–859.5 in an 846-tall footer. */
  margin-block-end: -13px;
}


/* "keep it simple for mobile" — Vitaliy 2026-08-05. Below 1025 the primary nav
   is not on screen at all (the drawer replaces it), and the drawer lists
   Products as a plain link to /products/, which is what it has always done and
   what it keeps doing. The disclosure button and the panel are rendered in the
   markup at every width — the panel because it is a sibling of the header inner
   rather than something conditionally printed, the button because the menu
   filter cannot know the viewport — so both are hidden here rather than left to
   inherit nothing. Mobile-first: the desktop block below is what turns them on. */
.site-nav__disclosure,
.site-nav__panel { display: none; }

/* ==== DESKTOP — the 1440 design ========================================== */
@media (min-width: 1025px) {

  .site-header { height: var(--ooc-nav-h-lg); }

  /* Links · logo · actions, in source order. The logo lands dead-centre
     because the outer columns are equal 1fr. */
  .site-header__inner {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: start;           /* each column carries its measured y */
  }

  .site-header__primary {
    display: block;
    margin-block-start: 32px;     /* `menu` 100:5898 y=32 */
  }
  .site-nav {
    display: flex;
    gap: 24px;                    /* 100:5898 itemSpacing */
    margin: 0;
    padding: 0;
    list-style: none;
  }
  /* ⚠️ THE DISCLOSURE IS COLOURED WITH THE LINKS, NOT BY INHERITANCE.
     The chevron used to be an `::after` ON the anchor, so the comment below —
     "it takes currentColor and therefore flips with the header treatment" —
     was true for free. Moving it onto a sibling <button> broke that silently:
     the cream is set on `.site-nav a`, `.site-header` itself computes black,
     and a <button> does not inherit `color` from an ancestor anyway (the UA
     gives it `buttontext`). Measured on the dark home hero — links
     rgb(255,249,233), the chevron rgb(0,0,0): a black mark on a video.
     Vitaliy: "submeny icon is black on desktop should be white". */
  .site-nav a,
  .site-nav__disclosure {
    font-size: var(--ooc-body-m-size);
    font-weight: var(--ooc-weight-book);
    /* 16px, NOT the 20px body-M leading. The nav text nodes (100:5900 etc.)
       set lineHeight PIXELS 16 — set solid, because a 20px leading would push
       the row 4px taller and off the measured y=32. Reaching for the token here
       was the first capture's other miss. */
    line-height: 16px;
    letter-spacing: -0.02em;      /* −2 PERCENT, not px */
    color: var(--ooc-light-2);
    text-decoration: none;
  }

  /* Dropdown chevron — Figma `icon` 100:5905, a 16x16 down-chevron beside
     "Products". Rendered as a MASK so it takes currentColor and therefore
     flips with the header treatment; an <img> would be stuck one colour.

     Two triggers, and the reason matters. The exhaustive node dump shows the
     design toggles this icon PER ITEM: 100:5905 (Products) is visible:true
     while 100:5901 (Services), 100:5909 (About Us) and 136:1123 (Login) are all
     visible:false. So Products is explicitly the one dropdown.

       .menu-item-has-children  WordPress adds this once an item really has
                                sub-items — the correct long-term trigger.
       .has-dropdown            an opt-in CSS class set on the menu item in
                                wp-admin, so the affordance the design shows can
                                render before the sub-items are decided.

     The chevron is therefore reproduced from the design rather than invented;
     what is still unknown is what the dropdown CONTAINS. */
  .site-nav .menu-item-has-children > a,
  .site-nav .has-dropdown > a { display: inline-flex; align-items: center; gap: 8px; }
  .site-nav .menu-item-has-children > a::after,
  .site-nav .has-dropdown > a::after {
    content: "";
    width: 16px;
    height: 16px;
    background-color: currentColor;
    -webkit-mask: var(--ooc-chevron) center / 16px 16px no-repeat;
            mask: var(--ooc-chevron) center / 16px 16px no-repeat;
  }
  /* ⚠️ …UNLESS THE PANEL IS LIVE, in which case the chevron belongs to the
     BUTTON. `has-dropdown--live` is set by inc/nav-dropdown.php only when the
     panel actually rendered. Without this the item draws the anchor's mask AND
     the button's icon — two chevrons 8px apart, each individually the right
     size, which is precisely what a geometry probe does not catch. */
  .site-nav .has-dropdown--live > a::after { content: none; }

  /* ---- The Products disclosure + panel — DERIVED, no node ---------------
     The file draws the chevron (`100:5905`) and nothing behind it; there is no
     open state for this menu anywhere in it. Asked for by Vitaliy 2026-08-05
     and approved as a derivation. Every value traces to something the file DOES
     draw except the two called out below. See FIGMA.md → "Also decided". */

  .site-nav .has-dropdown--live { display: flex; align-items: center; gap: 8px; }

  .site-nav__disclosure {
    /* Its own control, not the link. "Products" keeps going to /products/ for a
       reader, a crawler, and anyone who never opens the panel. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    padding: 0;
    border: 0;
    background: none;
    /* ⚠️ NO `color: inherit` HERE. It looks like the safe default and is the
       bug: it inherits from `.site-header`, which computes BLACK — the cream is
       set on `.site-nav a`, one level down. This rule also sits AFTER the one
       that colours the disclosure with the links, so `inherit` won and the
       chevron rendered black on the dark hero. The colour comes from that rule
       and from `body.nav-light`, exactly as the links' does. */
    cursor: pointer;
  }
  .site-nav__disclosure-icon {
    width: 16px;
    height: 16px;
    background-color: currentColor;
    -webkit-mask: var(--ooc-chevron) center / 16px 16px no-repeat;
            mask: var(--ooc-chevron) center / 16px 16px no-repeat;
    /* One asset, rotated — the same statement ooc_chevron_icon() makes about
       the accordion's open state, rather than a second file. */
    transition: transform .18s ease;
  }
  @media (prefers-reduced-motion: reduce) {
    .site-nav__disclosure-icon { transition: none; }
  }
  .site-nav__disclosure[aria-expanded="true"] .site-nav__disclosure-icon {
    transform: rotate(180deg);
  }

  .site-nav__panel {
    /* Turns the mobile-first `display: none` back on. The `[hidden]` rule below
       is more specific, so a closed panel still wins over this. */
    display: block;
    position: absolute;
    /* Anchored to `.site-header`, which is why the panel is a SIBLING of
       `.site-header__inner` — see nav.php. Full bleed, like every other band
       the design draws edge to edge. */
    inset-inline: 0;
    top: 100%;
    z-index: 20;
    background: var(--ooc-light-2);   /* #FFF9E9 — `products` / footer / coverage */
    /* The card's own rule, reused rather than a new divider. */
    border-block-start: 1px solid var(--ooc-navy-blue);
    box-shadow: 0 12px 24px rgb(0 39 101 / .06);
  }
  .site-nav__panel[hidden] { display: none; }

  .site-nav__panel-inner {
    /* ⚠️ DERIVED NUMBER 1 of 2: the 40px band padding. Nothing in the file
       states it — the design never draws this panel. */
    padding-block: 40px;
    /* ⚠️ CAP AT 1440 AND PAD INSIDE IT — not "cap at 1400", which is the same
       arithmetic done in the wrong order. `max-width: 1400` PLUS 20px of inline
       padding measured the cards at 40..1400, inset a further 20 from the
       column every other section uses, so the panel's cards did not line up
       with the /products grid directly beneath them. Capping the OUTER box at
       --ooc-container-max and padding within it puts the content at 20..1420,
       which is that column exactly — the same shape `.site-header__inner` and
       every section wrapper already use. */
    padding-inline: var(--ooc-container-pad);
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }

  .site-nav__panel-grid {
    display: grid;
    /* ⚠️ DERIVED NUMBER 2 of 2: six across. It is the count the design's own
       grids settle on (`100:6587` draws six distinct products), not a fit
       calculation — but a NAV panel holding exactly the catalogue is a choice
       the file never makes. `minmax(0, 1fr)` so a long product name cannot
       blow the track out. */
    grid-template-columns: repeat(6, minmax(0, 1fr));
    gap: 20px;                       /* the products grid's own gutter */
    list-style: none;
    margin: 0;
    padding: 0;
  }

  /* The card is `product-card.php` UNCHANGED — the same partial the home grid
     and /products render, so the panel cannot drift away from them. Only its
     scale is overridden here: at ~216 wide a 48px product title is absurd. */
  .site-nav__panel-item .products__card { padding-bottom: 12px; }
  .site-nav__panel-item .products__title {
    font-size: 24px;
    letter-spacing: -0.5px;
  }
  .site-nav__panel-item .products__attrs { gap: 6px; }
  .site-nav__panel-item .products__attr { font-size: 12px; }
  /* The hover "See More" overlay is a 453-wide card's affordance; at this size
     it covers the photograph it is meant to invite you into. The whole card is
     still a link. */
  .site-nav__panel-item .products__more { display: none; }

  .site-nav__panel-all {
    display: inline-block;
    margin-block-start: 24px;
    font-size: 14px;
    text-decoration: underline;
    text-underline-offset: 3px;
  }

  /* ⚠️ CENTRED, NOT AT THE DESIGN'S y=10. A DELIBERATE DIVERGENCE — Vitaliy
     2026-08-07: "on scroll sticky align logo vertically center."

     `100:5911` puts the logo at y=10 in an 80px bar, and it is 71px tall, so it
     ends at 81 — one pixel PAST the bar and 11px off centre. That is harmless in
     the file, where the header is transparent over the hero and no bar is drawn
     around it. The STUCK bar is a later, derived behaviour: it paints a real
     80px cream panel with a hairline, and against that the overhang and the
     off-centre sit are both visible.

     ⚠️ IT IS CENTRED IN EVERY STATE, NOT JUST WHEN STUCK, and that is the
     point. Moving it on stick would reintroduce exactly what the fixed-position
     rewrite removed — "it is jumping on scroll can you make it smoother". The
     header's whole principle is that NOTHING MOVES and only paint changes. So
     the logo sits 5.5px higher than the file at rest, over a transparent header
     on a video, where it is imperceptible — verified by eye at 1440.

     `align-self`, not a margin: the row is `align-items: start`, so this
     overrides for one column and stays correct if the bar height ever changes.
     Mobile is untouched — 96px bar, 56px logo, y=20 is already dead centre. */
  .site-header__brand { align-self: center; margin-block-start: 0; }
  .site-header__logo  { width: 80px; height: 71px; } /* 100:5911 = 80×71 */

  .site-header__actions {
    display: flex;
    align-items: center;
    justify-content: end;
    gap: 24px;                    /* Frame 2 100:5969 itemSpacing */
    margin-block-start: 20px;     /* Frame 2 y=20 */
  }
  .site-header__link {
    font-size: var(--ooc-body-m-size);
    font-weight: var(--ooc-weight-book);
    line-height: var(--ooc-body-m-lh);
    letter-spacing: -0.02em;
    color: var(--ooc-light-2);
    text-decoration: none;
  }
  .site-header__actions .ooc-button { min-height: 42px; }  /* 100:5974 h=42 */

  /* The drawer and its trigger are mobile-only. */
  .site-header__toggle,
  .site-drawer { display: none; }

  /* Footer: one row — Explore · Socials · Our Address, then Contacts pushed
     right (Frame 1994 130:666 is SPACE_BETWEEN with a 228 gap). */
  /* Desktop bottom is 20, not the mobile 10 (legal ends 584 in a 604 footer). */
  .site-footer { padding-block: 50px 20px; }

  .site-footer__inner {
    grid-template-columns: auto auto auto 1fr;
    column-gap: 120px;            /* Frame 1993 itemSpacing */
    row-gap: 0;
    /* Groups keep their intrinsic heights (Frame 1993 is MIN|MIN). The grid
       default `stretch` made all four the height of the tallest — 162px each
       instead of 162/94/142/156 — which measures as four wrong heights even
       though nothing moved on screen. */
    align-items: start;
    padding-inline: var(--ooc-container-pad-lg);
  }

  /* Heading ABOVE its list on desktop — the axis flip. */
  .site-footer__group {
    grid-template-columns: 1fr;
    gap: 20px;                    /* Frame 1990 itemSpacing */
  }
  /* Contacts sits at the right edge (menu 130:716 ends at x=1400) AND uses a
     14px label→list gap where the other three groups use 20. Both keyed off an
     explicit modifier rather than :last-of-type, which would break the moment a
     group is reordered or a client removes one. */
  .site-footer__group--contacts {
    justify-self: end;
    gap: 14px;
  }

  .site-footer__legal {
    grid-column: 1 / -1;
    grid-template-columns: auto 1fr;
    align-items: center;
    margin-block-start: 352px;    /* content ends 212, legal at 564 */
  }
  /* Legal links run horizontally on desktop (130:734, gap 10). */
  .site-footer__legal-menu {
    grid-auto-flow: column;
    justify-content: start;
    gap: 10px;
  }

  .site-footer__wordmark {
    letter-spacing: -4px;         /* 130:663 */
    line-height: 0.909;           /* 400 box at 440px */
    margin-block-end: -143px;     /* 130:663 spans 347–747 in a 604 footer */
  }
  .site-footer__decor-img {
    width: 40.28%;                  /* 580 / 1440, exact */
    aspect-ratio: 580 / 385;        /* 130:664 FILL crop — wider than mobile's */
    margin-block-end: -24px;        /* 130:664 spans 243.4-628.4 in a 604 footer */
  }
}


/* ----------------------------------------------------------------------------
   6. SECTIONS
   ----------------------------------------------------------------------------
   One banner-commented block per section type, added in Phase 3 alongside its
   ACF layout, renderer, seed row and smoke-test row (the five-part lockstep).
   -------------------------------------------------------------------------- */

/* == HERO ====================================================================
   Figma desktop 100:5874 (1440x800) - mobile 226:2034 (393x852).
   Mobile-first: the base block is the 393 design.

   Geometry is expressed as % of the viewport, not px, so it stays proportional
   through the fluid band (Hard rule 3). Both reference widths are exact.
   ========================================================================== */
.hero {
  position: relative;
  /* 852 IS the full mobile viewport, so the hero is a full-screen panel. `dvh`
     not `vh`: on mobile browsers `vh` is measured against the largest viewport
     and leaves the bottom of the hero under the URL bar. */
  min-height: 100dvh;
  padding-inline: var(--ooc-container-pad);
  /* Base colour behind the media (hero fill #F1EBE6) — visible only in the
     instant before the poster paints. */
  background: #F1EBE6;
  color: var(--ooc-light-2);
  overflow: clip;
}

/* -- Background media ----------------------------------------------------- */
.hero__bg { position: absolute; inset: 0; z-index: 0; }

/* -- The stage (no Figma node — see §4 and the note in hero.php) -----------
   The containing block for every overlay, capped at the composition width so
   the four percentage-placed children stop tracking the viewport above 1440.
   `.hero__bg` is a SIBLING and stays full-bleed; the video is one of the three
   fills the design bleeds edge to edge.

   inset-inline: 0 + max-width + margin-inline: auto is what centres an
   absolutely-positioned box — with both edges pinned, `auto` margins split the
   leftover space. Below 1440 the max-width is inert and this box is exactly the
   hero's padding box, which is what the children already resolved against, so
   nothing moves at either reference width. */
.hero__stage {
  position: absolute;
  inset-block: 0;
  inset-inline: 0;
  max-width: var(--ooc-container-max);
  margin-inline: auto;
  z-index: 1;
  /* Purely a coordinate space — it must never eat a click meant for the video
     or a child. Children that ARE interactive re-enable it below. */
  pointer-events: none;
}
.hero__stage > * { pointer-events: auto; }

/* Both are absolutely positioned so they OVERLAY rather than stack: the poster
   is always in the DOM and the video sits on top of it. That layering is what
   makes the reduced-motion rule below work — hiding the video genuinely does
   reveal the poster — and it lets the poster keep its per-breakpoint crop,
   which a <video poster> attribute could not. */
.hero__video,
.hero__poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The design oversizes the media and centre-crops it (1564x880 inside a
     1440x800 frame at -62,-40 with scaleMode FILL). `cover` + centre is exactly
     that, and it survives any viewport rather than only the two references. */
  object-fit: cover;
  object-position: center;
  display: block;
}

/* Scrim — 100:5878 is #000000 at NODE opacity 0.2. */
.hero__scrim {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0.2;
}

/* A looping background video is motion the reader did not ask for. Hiding it
   reveals the poster underneath, which is why the poster is a required field
   rather than a nicety. */
@media (prefers-reduced-motion: reduce) {
  .hero__video { display: none; }
}

/* -- Headline -------------------------------------------------------------
   Two lines, staggered: line 1 left, line 2 right (100:5885 / 100:5886).
   Mobile block = the content column (354 wide at x20, i.e. the full column).
   Desktop block = 947 wide, centred (x247..1194, centre 720.5 = 1440/2).       */
.hero__headline {
  position: absolute;
  z-index: 1;
  left: var(--ooc-container-pad);
  right: var(--ooc-container-pad);
  /* Group 11 sits at y375.8 of 852 = 44.1% */
  top: 44.1%;
  margin: 0;
  display: flex;
  flex-direction: column;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h1);
  font-weight: 400;
  line-height: 1;                 /* lineHeight PIXELS == fontSize on both */
  /* em, not px. Figma stores -2px at 130 (desktop) but -0.94px at 58 (mobile) —
     a flat -2px made mobile 1.06px too tight, well outside the 0.2px tolerance.
     -2/130 = -0.0154em reproduces desktop exactly and lands mobile at -0.893
     against -0.9398, i.e. 0.05px out. */
  letter-spacing: -0.0154em;
  /* The two line boxes do NOT touch: Figma has 299->433 at lh130 (a 4px gap)
     on desktop and 375.8->438.8 at lh58 (5px) on mobile. Desktop overrides. */
  row-gap: 5px;
  /* textCase TITLE in Figma — the strings are stored lowercase ("from japan")
     and Figma renders them title-cased. Reproducing that here keeps the ACF
     value editable as plain sentence text. */
  text-transform: capitalize;
}
/* Each line HUGS its text and sits at one edge of the block. The Figma nodes
   are autoResize WIDTH_AND_HEIGHT, so their x/width are the text's, not the
   container's. Leaving them as full-width blocks measured 947px wide with both
   lines starting at the same x — the stagger existed only as text-align, which
   is invisible to a geometry probe and would have shipped looking subtly wrong
   the moment the copy length changed. */
.hero__line {
  display: block;
  width: max-content;
  max-width: 100%;
}
.hero__line--1 { align-self: flex-start; text-align: left; }
.hero__line--2 { align-self: flex-end;   text-align: right; }

/* -- Decorative mark (Group 4, 100:5887) ----------------------------------
   x137.8 y281.1, 233.7x162.4 of 393x852 => 59.5% wide, top 33%.               */
.hero__mark {
  position: absolute;
  z-index: 1;
  top: 33%;
  left: 35.1%;                    /* 137.8 / 393 */
  width: 59.5%;                   /* 233.7 / 393 */
  aspect-ratio: 233.7 / 162.4;
  background: url("../images/hero-mark.svg") center / contain no-repeat;
  pointer-events: none;
}

/* -- Copy block (100:5976 / 228:2269) -------------------------------------
   Mobile x20 y710 253x122 => top 83.3%, width 64.4%.                          */
.hero__copy {
  position: absolute;
  z-index: 1;
  left: var(--ooc-container-pad);
  /* ⚠️ BOTTOM-ANCHORED, NOT a top percentage. The node says `bottom: 20`
     (100:5976 and 210:1229 both), and a top percentage is a LOSSY translation
     of it: it reproduces the design's y only while the copy is exactly as long
     as the frame it was derived from. This was `top: 83.3%` (i.e. 710 over 852)
     and the products hero's body wraps to five lines instead of four, which
     pushed the text flush to the frame edge with zero inset — visible in the
     render, invisible to every probe, because copy.y and body.height were each
     "correct" on their own. A comment showing a division is the smell.

     bottom: 20 reproduces every number already gated: home 800-20-102 = 678,
     home mobile 852-20-122 = 710, products 640-20-102 = 518. It is also why
     the 640 variant needs no copy or scroll override at all.

     ⚠️ Do NOT quote a CSS comment inside this one. CSS comments do not nest:
     an inner terminator ends the block early, the prose after it parses as a
     bad declaration, and the parser discards tokens to the next semicolon —
     which ate this very `bottom` and put the copy back at the frame top with
     the gate reporting a flat 0. That is how this note lost its punctuation. */
  bottom: 20px;
  width: 64.4%;                   /* 253 / 393 */
  display: flex;
  flex-direction: column;
  gap: 10px;                      /* VERTICAL gap10 */
}
.hero__kicker {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h5);  /* 28 mobile -> 32 desktop */
  font-weight: 400;
  line-height: 32px;               /* PIXELS 32 at BOTH sizes */
  letter-spacing: -1px;
  text-transform: capitalize;      /* textCase TITLE */
}
.hero__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  letter-spacing: 0;
}

/* -- Scroll cue -----------------------------------------------------------
   Mobile is the ARROW ONLY at x353 y812 (20x20) — the "Scroll to discover"
   label exists only on desktop (100:5980). The label is hidden rather than
   dropped so its text still reaches assistive tech.                           */
.hero__scroll {
  position: absolute;
  z-index: 1;
  right: var(--ooc-container-pad);
  /* Same edge inset, same reason as .hero__copy above — 100:5979 / 210:1232
     are both `bottom: 20`. Reproduces 760 at 800, 812 at 852, 600 at 640. */
  bottom: 20px;
  display: flex;
  align-items: center;
  gap: 10px;
}
.hero__scroll-label {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.hero__scroll-icon {
  width: 20px;
  height: 20px;
  background: currentColor;
  -webkit-mask: var(--ooc-arrow) center / contain no-repeat;
          mask: var(--ooc-arrow) center / contain no-repeat;
}

@media (min-width: 1025px) {
  /* ⚠️ CLIENT OVERRIDE — Vitaliy, 2026-08-02, reviewing the home page.
     "hero section should be taking browser height 100% so regardless on 13 inc
     mac book or large screen it should fit screen along with video."

     So the desktop hero is a FULL-VIEWPORT panel at every width, exactly as
     mobile already was — NOT the frame's fixed 1440x800 band. This is a
     deliberate deviation from 100:5874, recorded here and in
     figma-specs/home/hero.desktop.json. Hard rule 1 governs values READ from
     the file; it does not cover a behaviour the client decides and a static
     frame cannot express.

     What the frame still governs: the four overlays are percentages OF THE HERO
     BOX (headline 37.4%, mark 21.7%/47.6%/28.8%, copy and scroll cue bottom 20),
     so the design's composition rides the new height instead of breaking with
     it. At a 1440x800 viewport every one of them lands on its node value.

     What this replaced: `aspect-ratio: 1440/800` + `max-height: 800px`. Those
     existed to stop the ratio running away above 1440 (it measured 800.0 /
     1066.7 / 1422.2 at 1440 / 1920 / 2560). A viewport height has no such
     runaway — it is the same number at every width — so the cap is now
     structural rather than clamped, and `check-composition-cap.mjs` still
     gates the four width-capped children through `.hero__stage`.

     ⚠️ `width: 100%` stays: `.hero--h640` below still uses aspect-ratio, and a
     block's `width: auto` is a STRETCH fit rather than a definite size, so the
     ratio would be free to solve the WIDTH from the height instead — which
     boxed the full-bleed video into a left-aligned 1440 rectangle with page
     cream beside it, measured at both 1920 and 2560. */
  .hero {
    /* dvh not vh, for the same reason the mobile base gives: `vh` is measured
       against the largest viewport and leaves the bottom under the URL bar.

       The 640 floor is DERIVED, not invented. The headline sits at 37.4% and is
       264 tall (2 x 130 line-height + 4 row-gap); the copy is bottom-anchored
       122 off the bottom (102 tall + 20 inset). They collide when
       0.374H + 264 > H - 122, i.e. below H = 616.6. 640 is the next height the
       design system actually contains — the inner-page hero band 210:1133 — so
       the composition can never overlap itself in a short window. */
    min-height: max(100dvh, 640px);
    width: 100%;
    padding-inline: var(--ooc-container-pad-lg);
  }

  .hero__headline {
    /* x247..1194 of 1440: a centred 65.8% block, not the full content column. */
    left: 17.15%;
    right: 17.08%;
    top: 37.4%;                   /* 299 / 800 */
    row-gap: 4px;                 /* 433 - (299 + 130) */
  }

  .hero__mark {
    top: 21.7%;                   /* 173.8 / 800 */
    left: 47.6%;                  /* 686.1 / 1440 */
    width: 28.8%;                 /* 414.8 / 1440 */
    aspect-ratio: 414.8 / 288.2;
  }

  /* No `top` here — the block is bottom-anchored at 20 (see .hero__copy). At
     800 with a 102-tall block that resolves to the design's 678 by itself. */
  .hero__copy {
    width: 27.8%;                 /* 401 / 1440 */
  }
  /* The label is visible at desktop — unhide it. */
  .hero__scroll-label {
    position: static;
    width: auto; height: auto;
    overflow: visible;
    clip-path: none;
    font-size: var(--ooc-body-s-size);
    font-weight: var(--ooc-weight-book);
    line-height: var(--ooc-body-s-lh);
  }
  .hero__scroll-icon { width: 12px; height: 12px; }  /* Frame 4 100:5981 */

  /* -- The 640 inner-page hero — Figma `hero` 210:1133 (1440x640) ----------
     The SAME section: same scrim (#000 @ 0.2), same headline type (Instrument
     Serif 130/130, tracking -2, #FFF9E9, capitalize), same kicker (32/32, -1),
     same body (14/20 at 401 wide), same 20px bottom inset, same 12px scroll
     icon. Only the frame height and the four overlay positions differ, so this
     is a modifier rather than a second section — and every percentage below is
     the SAME arithmetic as the block above, re-divided by 640 instead of 800.

     The mark has no counterpart here and is not rendered at all (hero.php), so
     there is nothing to reposition.

     ⚠️ The line stagger is IDENTICAL on both frames: 433-299 on home and
     334-200 here are both 134, i.e. 130 of line-height plus a 4px row-gap. The
     inherited row-gap is therefore already correct and is deliberately not
     restated — if it ever needs restating, the two frames have diverged. */
  .hero--h640 {
    /* ⚠️ THIS OVERRIDE IS LOAD-BEARING, AND IT USED TO SAY `0`. The full-viewport
       rule above is a decision about the HOME hero only, and `min-height` beats
       `max-height` in the cascade — without an override here every inner-page
       hero would silently become full-screen too.

       But `0` also threw away the 640 FLOOR the base rule derives, and that
       floor is the thing stopping the composition overlapping itself: the
       headline sits at 37.4% and the copy is bottom-anchored, so they collide
       below ~616 tall. With the floor gone the aspect-ratio produced exactly
       that between 1025 and ~1290 — measured 2026-08-04, on all four pages
       carrying this variant:

           @1025  hero 455.5   headline->copy gap -55.7   OVERLAPPING TEXT
           @1100  hero 488.9   gap -23.1
           @1200  hero 533.3   gap  -6.3
           @1300  hero 577.8   gap +10.5

       1100 is one of the gate's own structural widths, and it captured and
       scored the page in that state for weeks — structural widths only assert
       overflow, one <h1> and a non-collapsed height, none of which an overlap
       trips. Restoring the floor as a floor (not 0) fixes it at every width and
       keeps the full-viewport rule off the inner pages, which is all the `0`
       was ever for. */
    min-height: 640px;
    /* 1440x640 = 2.25, the ratio all four inner-page heroes are drawn at
       (186:112, 210:1133, 230:5141, 359:499 are each 1440x640).

       ⚠️ NO `max-height` — REMOVED 2026-08-04 at Vitaliy's request, reviewing
       /services: "above design in figma we can make hero height bigger
       proportionally". The cap used to pin the height at 640 past 1440, so a
       2560 display got a 2560-wide band still only 640 tall — a letterbox the
       design never draws, growing flatter the wider the screen. The ratio now
       governs at every width: 640 at 1440, 853.3 at 1920, 1137.8 at 2560.

       It costs nothing to the composition, because everything inside is already
       proportional or capped: the headline, mark and copy are positioned in
       PERCENTAGES of the hero (37.4%, 21.7%, bottom-anchored), so they ride the
       new height, while `.hero__stage` keeps their horizontal geometry frozen
       at 1440 and centred. Only the media bleeds.

       ⚠️ This is the ONE section allowed to change height above the cap, and
       `scripts/check-composition-cap.mjs` is taught about it explicitly rather
       than having the rule loosened — see SCALES there. Everything below the
       hero still may not change height; it only shifts down by the hero's own
       delta, which the check now asserts. */
    aspect-ratio: 1440 / 640;
  }

  .hero--h640 .hero__headline {
    /* Group 11 210:1142 spans x283..1153 of 1440 — a centred 60.4% block. */
    left: 19.65%;                 /* 283 / 1440 */
    right: 19.93%;                /* (1440 - 1153) / 1440 */
    top: 31.25%;                  /* 200 / 640 */

    /* ⚠️ PROPORTIONAL, NOT THE SHARED CLAMP — this is Hard rule 3 applied to the
       RELATIONSHIP between the type and the box holding it, and without it
       /products' headline wrapped into its own copy.

       The block is a fixed 60.4% of the viewport, but `--ooc-fluid-h1` is a
       clamp with a fixed offset, so the two shrink at different rates: at 1440
       it is 130px in an 870px block (0.149), at 1025 it is 101.5px in a 619px
       block (0.164). The type therefore gets RELATIVELY BIGGER as the window
       narrows, and at 1025 "Premium Japanese" no longer fitted one line —
       measured 308.4px tall against 209, a third line that ran 10.4px into the
       copy beneath it. Only /products has a headline long enough to trip it,
       which is exactly why a per-page eyeball would have missed it.

       130/1440 = 9.0278vw makes the ratio constant, so whatever fits at the
       reference width fits at every width down to 1025. Capped at 130 because
       `.hero__stage` freezes the horizontal geometry at 1440 — the type must
       stop growing where its box does.

       Scoped to `--h640`: the HOME hero is full-viewport with its own
       composition and keeps the shared clamp. */
    font-size: min(9.0278vw, 130px);
  }

  /* NO copy or scroll override. Both are bottom-anchored at 20, so they resolve
     to the node's own 518 and 600 from the 640 height with no arithmetic here.
     They WERE overridden as top: 80.94% / 93.75% until the mobile render showed
     that a top percentage cannot survive a change of copy length. */

  /* ⚠️ THE HEADLINE BOX IS PER-PAGE and there is no rule that fits all four.
     Line 1 starts at 283 on every inner page, but the right edge differs and
     the CSS pins both:
        home     100:5874   247 … 1194  (947)   centred on 720.5
        products 210:1133   283 … 1153  (870)   centre 718
        about    230:5149   283 … 1124  (841)   centre 703.5 — not centred
        services 186:122    283 … 1124  (841)   centre 703.5 — about's box
     Carrying products' 1153 over would put "fish for all." 29px wide.
     /services reads the same box as /about: 186:123 sits at calc(50% - 437)
     = 283 and 186:124 is right-anchored at calc(50% + 404) = 1124. */
  .page-about .hero__headline,
  .page-services .hero__headline { right: 21.9444%; }   /* (1440 - 1124) / 1440 */

  /* A city page is the FIRST inner hero whose headline box is actually CENTRED
     on the frame: `360:1682` spans 372…1068 of 1440, centre 720. The other
     three are each off-centre in their own way, which is why this is a fourth
     scope rather than a rule anyone could have inferred. */
  .page-new-york-delivery .hero__headline {
    left: 25.8333%;               /*        372 / 1440 */
    right: 25.8333%;              /* (1440 - 1068) / 1440 */
  }

}

/* ⚠️ /services DARKENS THE SCRIM, and it is the only page that does. 186:116 is
   #000 at NODE opacity 0.3; the home, products and about specs all record 0.2.
   Read off this node rather than inherited from the other three — the 0.2 was
   already written and would have passed every probe, because opacity is not a
   fill and does not show up in a colour dump (the wordmark bug, again).
   Outside the 1025 block on purpose: it is true of the section at every width. */
.page-services .hero__scrim,
.page-new-york-delivery .hero__scrim { opacity: 0.3; }   /* 186:116 · 359:502 */


/* == STATEMENT ===============================================================
   Figma desktop 100:5983 (1440x1228) - mobile within 228:2864 (393 wide).
   Mobile-first; the base block is the 393 design.
   ========================================================================== */
.statement {
  position: relative;
  /* The image runs off the right edge by design (x1143 + w926 = 2069 in a 1440
     frame). `clip`, not `hidden` — hidden makes this a scroll container and
     trips the gate's horizontal-overflow check. */
  overflow: clip;
  /* Mobile: heading at y296, copy ends 536, next section's first text at 656.
     Desktop overrides. */
  padding-block: 296px 120px;
  padding-inline: var(--ooc-container-pad);
  /* NO background: 100:5983 has an empty fills array and shows the page's
     #F2E6D5 through. Setting one here is what made it too pale. */
  color: var(--ooc-navy-blue);
}

/* Pattern — 228:2865 is 393x206 at 8% on mobile, 100:5984 is 1440x629 on
   desktop. Anchored to the TOP of the section, not stretched over it. */
.statement__pattern {
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: 206px;
  background: url("../images/statement-pattern.png") center top / cover no-repeat;
  /* opacity 1, NOT 0.08. The node's own opacity is 1; the 8% lives on its IMAGE
     FILL, and the PNG export already composites that. Re-applying 0.08 in CSS
     dimmed it twice and the pattern nearly vanished. (A second fill on that node
     is visible:false — do not composite it.) */
  opacity: 1;
  pointer-events: none;
}

/* Bleed image. Mobile 228:3442 sits at x373 w353 — it starts BEYOND the right
   gutter and runs off-screen, so only its left sliver is visible. */
/* ⚠️ x comes from absoluteBoundingBox, NOT absoluteTransform. This node is
   flippedX, so its transform origin (373 mobile / 1143 desktop) is its RIGHT
   edge — reading that as the left edge put the image off-screen and left only
   its tail showing. The image does not bleed at all; it sits in the column. */
.statement__image {
  position: absolute;
  top: 120px;                     /* 228:3442 y */
  left: 5.09%;                    /* bbox x 20 / 393 */
  width: 89.8%;                   /* 353 / 393 */
  height: auto;
  aspect-ratio: 353 / 96;
  object-fit: cover;
  pointer-events: none;
}

.statement__inner {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 32px;                      /* Frame 6 228:3453 itemSpacing */
  text-align: center;             /* every text node is textAlign CENTER */
}

.statement__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);  /* 50 -> 100 */
  font-weight: 400;
  /* Mobile lineHeight is 52 at a 50px size — NOT set solid like the hero.
     Expressed as a ratio so it holds through the band; desktop overrides to
     the exact 100/100. */
  line-height: 1.04;
  letter-spacing: -2px;
}

.statement__copy {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 228:3456 itemSpacing */
}
.statement__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
  letter-spacing: 0;
}
.statement__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  letter-spacing: 0;
}

@media (min-width: 1025px) {
  /* ⚠️ CLIENT CHANGE — Vitaliy, 2026-08-02, reviewing the home page:
     "second section has to have background taking full width and heith as well
     and also there should be no bad cutting off background transition at the
     booton" — then, on the second pass: "background position should be starting
     from the top always ... so we dont have space from the top and start of
     background image". THREE defects. Everything below was measured, not read.

     THE ASSET WAS 80px OUT OF REGISTER — the cause of (1) and (2) both.
     100:5984 is a 1440x629 frame, but Figma RENDERS it 1440x789: the texture
     overflows the frame by exactly 80 above and 80 below (80 + 629 + 80 = 789).
     statement-pattern.png was that whole 789 render, pinned to the frame's top —
     so every dot sat 80px lower than the design. Proven by cross-correlating the
     asset's row profile against the design's own band: mean abs error 2.510 at
     offset 0, and 0.069 at offset 80, with a sharp minimum (0.212 at 77, 0.146
     at 78, 0.090 at 79, 0.651 at 90). The asset is now cropped to 1440x709 —
     the frame's 629 plus the 80px tail BELOW it, which is the fade.

     1. NO TEXTURE AT THE TOP OF THE SECTION. The asset's first 77 rows were the
        empty part of that above-frame bleed, so the build's first ink landed at
        y78 desktop / y21 mobile (= 80 x 393/1440) against the design's y0.
        Now: first ink at y0 at BOTH widths — deviation 1.67 desktop against the
        design's 2.10, and 2.97 mobile against the design's 2.97 exactly.

     2. THE STRAIGHT LINE ACROSS THE BOTTOM. The box was 629 tall, so `cover`
        cropped the texture at FULL INK: the largest single-row luminance jump
        in the section was 2.63, exactly at y629.
        ⚠️ THE DESIGN NEVER HAD IT. Measured on the frame render over the same
        left gutter, 100:5838 fades straight through 629 — 226.53 at y600,
        227.13 at y629, 228.33 (pure cream) by y720 — largest row jump anywhere
        in the band 0.83, i.e. dot noise. Figma does not clip the texture at the
        frame's edge; the CSS did. The box is now the whole section and the
        texture keeps its own proportion, so the asset's own tail ends it:
        measured 0.73 after, against the design's 0.83.

     3. THE BAND STOPPED AT 1440. `.statement` carried the cap itself, so on any
        display wider than that the texture was a 1440-wide island with page
        cream either side. The section now bleeds and its two percentage-placed
        children are capped individually instead — which is the same shape
        products / testimonials / site-footer already use, and it moves nothing:
        the image's left resolves to (viewport-1440)/2 + 217 either way, and
        `.statement__inner` was already centred on the page by margin auto.

     ⚠️ ONLY (3) IS A DEVIATION. (1) and (2) are defects against the design and
     the numbers above are the proof — the section measures 2.11 mean abs diff
     against 100:5983 now, from 4.26 before. (3) has nothing in the file behind
     it: 100:5983 has an EMPTY fills array and its texture is 1440 wide, so on a
     2560 display the design itself would show page cream either side. That one
     is Vitaliy's call, recorded here, in the spec, and in
     scripts/check-composition-cap.mjs, where `statement` had to join BLEEDS. */
  .statement {
    /* Frame 5 at y672 h344; section 1228 => 212 below. */
    padding-block: 672px 212px;
    padding-inline: var(--ooc-container-pad-lg);
  }

  .statement__pattern {
    /* Full width AND full height of the section (was 629px — 100:5984). */
    inset: 0;
    height: auto;
    /* `100% auto`, NOT `cover`. Cover solves the scale from whichever axis
       needs more, and against a 1228-tall box that is the HEIGHT — it would
       blow the texture up 1.56x and crop 400px off each side. This keeps the
       asset's own 1440:709 proportion, so its ink ends exactly where the PNG
       ends: 709 down a 1228 section at the reference width, i.e. the design's
       629 band plus the 80px tail it was being cut off mid-way through.
       `background-position` stays `center top` (the base rule) — with the
       asset now cropped to register, top IS the design's top and no offset is
       needed at any width, which a `100% auto` background could not have
       expressed as a length anyway. */
    background-size: 100% auto;
    /* THE GUARANTEE, and the reason this is not just a taller box. Past ~2130
       the proportional texture is taller than the section and the box would
       crop it mid-ink all over again. This dissolves the bottom of the box at
       every width. 63.5% is derived, not picked: the texture ends at 709 of
       1228 = 57.7% at the reference width, so the mask starts BELOW where
       anything is drawn there and only ever bites on wider screens. */
    -webkit-mask-image: linear-gradient(to bottom, #000 0 63.5%, transparent 100%);
            mask-image: linear-gradient(to bottom, #000 0 63.5%, transparent 100%);
  }

  .statement__image {
    top: 300px;                   /* 100:6561 y */
    /* Capped by hand now the section bleeds. Below 1440 these are the design's
       percentages; at and above it they freeze on the node's pixels, so the
       composition stops tracking the viewport exactly as the section's own
       max-width used to make it. `min()` crosses over at precisely 1440:
       15.07% x 1440 = 217.008 and 64.31% x 1440 = 926.06. */
    left: calc(max(0px, (100% - var(--ooc-container-max)) / 2) + min(15.07%, 217px));
    width: min(64.31%, 926px);    /* 926 / 1440 */
    aspect-ratio: 926 / 252;
  }

  /* Frame 5 is 851 wide, centred (x295, centre 720.5 = 1440/2) — narrower than
     the content column, so it is capped rather than gutter-bound. */
  .statement__inner {
    max-width: 851px;
    margin-inline: auto;
    gap: 60px;                    /* Frame 5 itemSpacing, vs 32 on mobile */
  }

  .statement__heading { line-height: 1; }  /* 100/100 exactly */

  /* B1Text is 400 wide inside the 851 block (x520.5, centred). */
  .statement__copy {
    max-width: 400px;
    margin-inline: auto;
  }
}


/* == COLLAGE =================================================================
   Figma desktop `3` 100:6568 (1440x1008) - mobile within `2` 228:2864, rows
   656..1734 (393x1078). Mobile-first: the base block is the 393 design.

   AN ASPECT-RATIO CANVAS, which §4 names as the other half of the fluid rule
   alongside "% of the content box". Nothing here is in flow — the section is a
   scattered composition of absolutely-positioned children — so the height
   cannot come from the content and every child is a % of the canvas. That is
   what keeps the whole collage proportional through the band instead of
   letting the pieces drift apart from each other.

   ⚠️ THE TWO PHOTO CARDS SWAP CORNERS between the breakpoints, and the
   rotations differ too (+15/-21.63 desktop, -20/+20 mobile). Confirmed by md5
   on the source fills, not by eye — see the note in collage.php.

   ⚠️ CARD GEOMETRY IS THE UNROTATED BOX, positioned so its ROTATED bounding
   box lands on the Figma node. The file's own numbers are the rotated bbox
   (e.g. 287.306x319.358 for a 205.093x262.226 card at -21.63deg), which is
   also what getBoundingClientRect returns, so the spec asserts the bbox while
   the CSS sets the box inside it. Deriving one from the other is the whole
   trick: card_left = bbox_left + bbox_w/2 - card_w/2.

   ⚠️ get_metadata and get_design_context DISAGREE on x/y for a rotated node.
   Metadata gives the rotated ORIGIN CORNER; design context gives the bounding
   box. For 100:6570 that is 1156.33 vs 1110 — and 179*sin(15deg) = 46.33 is
   exactly the difference. The bounding box is the correct read. Same family of
   bug as the flippedX trap logged against the statement image.
   ========================================================================== */
.collage {
  position: relative;
  /* ⚠️ THE CANVAS MOVED TO `.collage__stage` — see the note there and on
     `.collage__dots`. This section is full-bleed at every width so the dot
     band can be; the aspect-ratio and the 560 cap below belong to the canvas,
     not the section, because uncapping a ratio makes it grow instead of
     staying put. */
  /* 393x1078. The mobile section is the tail of frame 228:2864: `statement`
     ends at frame y656 and this runs 656..1734. */
  /* ⚠️ DERIVED, NOT READ (Hard rule 2). The file has no artboard between 393
     and 1440, and this composition is 2.7x taller than it is wide — scaled to
     the full 1024 of the mobile band it would be a 2809px-tall section with
     hairline type. So the canvas scales to 560 (1.42x the reference) and then
     centres, leaving the page colour either side. 560 is a judgement, not a
     measurement; it is recorded in FIGMA.md as a question for the designer.
     ⚠️ IT NOW LIVES ON `.collage__stage`, not here — see above. */
  /* `clip`, not `hidden`, and only on x. The band still starts above this
     section (top -128 mobile / -202 desktop) and has to keep bleeding up over
     the statement — `overflow: hidden` would clip both axes and cut that off,
     and it would make this a scroll container, which trips the gate's
     horizontal-overflow check. */
  overflow-x: clip;
  /* NO background: 100:6568 has an empty fills array and shows the page's
     #F2E6D5, exactly as the statement above it does. */
  color: var(--ooc-navy-blue);
}

/* -- Dot field (100:6569 / 228:3511) --------------------------------------
   A background-image rather than a nested <img>: the fill is 2.5x the height
   of its own box, and as a child element that overflow would land in the
   capture's contentBottom scan and wreck every boundary measurement in this
   section. As a background it is clipped to the padding box for free.

   ⚠️ background-position % is NOT "offset by % of the box" — it aligns the
   image's p% point with the box's p% point. The design's offsets are
   left -31.45% / top -28.98% of the box at a size of 143.89% x 254.15%, which
   solves to 71.6564% / 18.7999%:
       p = offset / (size - 100%)  ->  -31.45 / (100 - 143.89) = 71.66%
                                       -28.98 / (100 - 254.15) = 18.80%
   Both breakpoints carry the SAME four inner percentages, so this never
   changes; only the box around it does.                                      */
/* ⚠️ THE BAND SPANS THE SECTION, AND THE SECTION SPANS THE VIEWPORT — Vitaliy,
   2026-08-02: "span with background is not taking full width on the section
   plus negative top we have it should be also 100% width".

   The band was never the problem: measured, it always covered its own section.
   The SECTION was 560 wide between ~560 and 1024, because the derived mobile
   cap sat on `.collage` — so at 768 the whole thing was a 560 column at x104,
   at 860 x150, at 1024 x232, with page colour either side and the texture
   confined to it. The cap now lives on `.collage__stage` at BOTH breakpoints
   (560 here, 1440 at desktop), so the section is full width everywhere and the
   band with it, while the composition keeps the exact canvas it had.

   `left: 0 / width: 100%` replaces -430/954, and the texture is a fixed square
   anchored bottom-right rather than a percentage box offset off-canvas. 1372px
   is not invented: it is what 143.89% x 254.15% resolved to in the old
   954x540 box (1372.7 x 1372.4), so the dots stay the size they have always
   been at this breakpoint. `top` keeps its negative — the band still starts
   above the section and bleeds up over the statement, which is the whole
   point of it. */
.collage__dots {
  position: absolute;
  left: 0;
  /* -128 / 1078, the mobile node's own offset. Vitaliy's "-20% top" is applied
     at DESKTOP, where it is the node value to within 0.4px (-202/1008 =
     -20.0397%); at this width -20% would be -215.6 and would move the band 87px
     off the design for no reason he gave. It still bleeds up over the
     statement either way, which is the point of the negative. */
  top: -11.8738%;
  width: 100%;
  height: 50.0928%;               /* 540 / 1078 */
  /* MOBILE KEEPS ITS OWN NODE'S ASSET. 228:3511 is a different node from the
     desktop band (954x540 at -430,528 against 1440x816 at 0,-202), and the
     export Vitaliy supplied is the DESKTOP one. Measured here, this pairing is
     already within 2% of the design — local contrast 0.5554 against 0.5666 —
     so it stays, with its own opacity and blur. The desktop override below
     carries the new asset. */
  background: url("../images/s3-dots.webp") right bottom / 1372px 1372px no-repeat;
  /* The blur is on the BOX and the 10% is on the IMAGE inside it — this asset
     is a sharp crop with no alpha of its own, unlike the desktop export. */
  filter: blur(20px);
  opacity: 0.1;
  pointer-events: none;
}

/* -- The split display sentence (100:6581 + 100:6582) ----------------------
   ONE heading, two positioned fragments. "Auctions refined ... over centuries"
   is a single sentence the composition pulls to opposite corners; two <h2>s
   would tell a screen reader this section has two headings.

   `display: contents` so the fragments position against the section canvas
   rather than a wrapper, WITHOUT the wrapper contributing a box — an
   inset:0 wrapper would have made contentBottom equal the section bottom and
   silently zeroed the trailing-space measurement. The old a11y bug that
   dropped display:contents elements from the accessibility tree was fixed in
   Chrome 89 / Firefox 87 / Safari 15.4, so the heading semantics survive.     */
.collage__heading {
  display: contents;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100, exact at both references */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop is 80/100 */
  letter-spacing: -2px;             /* -2 at BOTH sizes in the file */
}

.collage__heading-part {
  position: absolute;
  margin: 0;
  /* Both fragments are hard-broken in the file and the renderer emits the
     <br>, so nowrap only stops a THIRD wrap appearing when the body face is
     swapped. Their width is glyph-determined, which is why the spec asserts
     x/y but not width. */
  white-space: nowrap;
}
.collage__heading-part--a { left: 5.0891%; top: 0; }          /* 20 / 0      */
.collage__heading-part--b { left: 60.0509%; top: 56.2152%; }  /* 236 / 606   */

/* -- Centre photograph (100:6583 / 228:3490) ------------------------------- */
.collage__centre {
  position: absolute;
  left: 5.3435%;                  /* 21 / 393   */
  top: 11.5028%;                  /* 124 / 1078 */
  width: 89.8219%;                /* 353 / 393  */
  height: 42.8571%;               /* 462 / 1078 */
  border-radius: 20px;            /* cornerRadius, same at both breakpoints */
  overflow: hidden;
}
.collage__centre-img {
  width: 100%;
  height: 100%;
  /* The shipped files are FRAME exports at 2x, so the design's own crop and
     image adjustments are already baked in and this is a no-op — it is here so
     an editor uploading a differently-shaped photo gets a crop, not a squash. */
  object-fit: cover;
}

/* -- The two rotated cards -------------------------------------------------
   Each is a radiused window onto an image ~2x its own size. The crop is
   IDENTICAL at both breakpoints (verified: the four inner percentages agree to
   within 0.6px), so one file serves both and only the box and rotation move.  */
.collage__card {
  position: absolute;
  margin: 0;
  border-radius: 12px;            /* both cards, both breakpoints */
  overflow: hidden;
}
.collage__card-img {
  position: absolute;
  /* ⚠️ REQUIRED, not tidying. §2 sets `img { max-width: 100% }` globally, which
     silently capped these at the card width — the crop is 203-209% wide by
     design, so the photograph was being SQUASHED to fit instead of cropped.
     The gate caught it as an inline-size miss; by eye it read as "the photo
     looks a bit narrow", which is exactly the kind of thing that ships. */
  max-width: none;
}

/* Card 1 — 100:6571 top-right on DESKTOP, 228:3482 bottom-left on MOBILE. */
.collage__card--1 {
  left: 20.7583%;                 /* box 105 x 134.25, bbox 61.788,539 */
  top: 51.2902%;
  width: 26.7176%;
  height: 12.4536%;
  transform: rotate(-20deg);
}
.collage__card--1 .collage__card-img {
  left: -36.7890%;
  top: -7.6123%;
  width: 208.5876%;
  height: 108.7739%;
}

/* Card 2 — 100:6586 bottom-left on DESKTOP, 228:3493 top-right on MOBILE. */
.collage__card--2 {
  left: 47.0316%;                 /* box 153.82 x 196.67, bbox 155.84,0 */
  top: 1.8900%;
  width: 39.1399%;
  height: 18.2440%;
  transform: rotate(20deg);
}
.collage__card--2 .collage__card-img {
  left: -52.3654%;
  top: -2.5169%;
  width: 203.4046%;
  height: 106.0711%;
}

/* -- Lede / body / CTA (100:6572 / 228:3483) ------------------------------- */
.collage__copy {
  position: absolute;
  left: 5.3435%;                  /* 21 / 393   */
  top: 71.4286%;                  /* 770 / 1078 */
  width: 89.8219%;                /* 353 / 393  */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;                      /* 228:3483 itemSpacing; desktop is 40 */
  text-align: center;
}

.collage__text {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 228:3484 / 100:6573 itemSpacing */
  width: 100%;
}

/* The lede's Figma width (225) is the string HUGGING in Suisse Semibold, not a
   layout constraint — forcing it while the body face is a placeholder would
   wrap a one-line label onto two. Centred and left to hug, as the design does;
   the spec asserts its height and type, not its width. */
.collage__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}

.collage__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* The CTA is 260x52 mobile / 260x48 desktop — the SAME 260 at both reference
   widths, so fixed px here IS the fluid-correct answer: it is exact at both
   references and constant between, which is what the design does. */
.collage__cta {
  width: 260px;
  min-height: 52px;               /* 228:3487; desktop 186:31 is 48 */
  /* The component puts the label hard left and the icon hard right inside a
     20px inset, rather than centring a label+icon pair as the chrome does. */
  justify-content: space-between;
  gap: 0;
}
.collage__cta-icon {
  flex: none;
  width: 30.363px;                /* 186:17 */
  height: 24px;
}
/* An unset destination renders the same box as an inert <span> — see the note
   in collage.php. It must not pretend to be clickable. */
span.collage__cta { cursor: default; }

/* The capped canvas — see the note in collage.php. It carries the aspect-ratio
   and the width cap at BOTH breakpoints so `.collage` itself can be full-bleed
   and the dot band with it. Everything inside the section is positioned as a
   percentage of this box, which is why the cap has to be a real element: leave
   a ratio uncapped and it grows with the viewport instead of holding still. */
.collage__stage {
  position: relative;
  aspect-ratio: 393 / 1078;
  max-width: 560px;
  margin-inline: auto;
}


@media (min-width: 1025px) {
  /* ⚠️ CLIENT CHANGE — Vitaliy, 2026-08-02: the dot field runs the full width
     of the browser instead of stopping at 1440, "so it doesnt cut inside
     layout like we did other backgrounds also", keeping the design's own
     placement rather than being re-anchored (his option b).

     THE SECTION BLEEDS; THE CANVAS DOES NOT. `aspect-ratio` and the cap move
     to `.collage__stage`. Leaving them here and simply removing the max-width
     would have made the canvas 1344 tall at 1920 and 1778 at 2560 — a ratio
     has no ceiling, which is the exact runaway §4 was written for, and it
     would have dragged every section below it down the page.

     100:6568 has an EMPTY fills array, so — like the statement — nothing in
     the file backs the bleed; the design's band is 1440 wide. Recorded here
     and in scripts/check-composition-cap.mjs, where `collage` joins BLEEDS and
     the TARGET moves from `.collage` to `.collage__stage`, which is the thing
     that must actually stay frozen. */
  .collage__stage {
    aspect-ratio: 1440 / 1008;    /* 100:6568 */
    max-width: var(--ooc-container-max);
  }

  /* ⚠️ THE BAND'S BOX IS THE TEXTURE'S OWN EXTENT — derived from the node and
     the export together, 2026-08-02, after three rounds of anchoring guesses.

     `Dot Pattern — Warm Ink` 100:6569 is a 1440x816 frame at y-202, but Figma
     RENDERS it 1440x896 — and the file Vitaliy supplied IS that render. Where
     the extra 80 sits was measured, not assumed: compositing the export over
     #F2E6D5 and correlating it against the design's own page render over a
     dots-only column strip gives a sharp minimum at row 40 —

         offset   -80    -40      0     20     40     60     80    120
         MAE     1.933  1.352  1.145  0.780  0.052  0.787  1.130  1.357

     i.e. 40px of bleed above the node box and 40 below (40 + 816 + 40 = 896).

     So instead of drawing an 816-tall box and shoving the texture around
     inside it — which is what every previous anchor was trying to do, and why
     an edge was always cut — the BOX IS the texture: top -242 (the node`s -202
     minus its 40 of top bleed), and an aspect-ratio that keeps the box exactly
     1440:896 at any width. The texture then fills it at `left top / 100% auto`
     with no offset at all, so no edge can be cut on any screen, and at 1440 it
     lands precisely where the design puts it.

     The 40px above and below are the design`s own soft edges — that is what
     makes the band dissolve into the page instead of ending on a line, the
     same property the statement`s texture turned out to need. */
  .collage__dots {
    top: -24.0079%;               /* (-202 - 40) / 1008 */
    width: 100%;
    height: auto;
    aspect-ratio: 1440 / 896;     /* the export`s own ratio */
    background-image: url("../images/s3-dots-warm-ink.png");
    background-position: left top;
    background-size: 100% auto;
    /* ⚠️ opacity 1 and NO blur — both are already in the export. The alpha is
       in the file (ink rgb(0,31,81) at alpha ~14/255), so multiplying by the
       base rule`s 0.1 would composite it twice, to 0.55% — under 8-bit
       quantisation, drawing nothing while every geometry probe still passed.
       And the export is the node as Figma renders it, blur included; the base
       rule`s blur exists only to soften the sharp mobile crop. */
    filter: none;
    opacity: 1;
  }

  .collage__heading { line-height: 0.8; }          /* 80/100 exactly */
  .collage__heading-part--a { left: 1.3889%; top: 5.1587%; }    /* 20 / 52   */
  .collage__heading-part--b { left: 75.3472%; top: 43.6508%; }  /* 1085 / 440 */

  .collage__centre {
    left: 34.2361%;               /* 493 / 1440 — the node is centred */
    top: 3.3730%;                 /* 34 / 1008  */
    width: 31.5278%;              /* 454 / 1440 */
    height: 57.5397%;             /* 580 / 1008 */
  }

  .collage__card--1 {             /* box 140 x 179, bbox 1110,0 */
    left: 78.5263%;
    top: 1.4948%;
    width: 9.7222%;
    height: 17.7579%;
    transform: rotate(15deg);
  }
  .collage__card--1 .collage__card-img {
    left: -36.6843%;
    top: -7.9279%;
    width: 208.5879%;
    height: 108.7743%;
  }

  .collage__card--2 {             /* box 205.093 x 262.226, bbox 68,387 */
    left: 7.5768%;
    top: 41.2268%;
    width: 14.2426%;
    height: 26.0145%;
    transform: rotate(-21.63deg);
  }
  .collage__card--2 .collage__card-img {
    left: -52.3655%;
    top: -2.5196%;
    width: 203.4048%;
    height: 106.0715%;
  }

  .collage__copy {
    left: 35.4167%;               /* 510 / 1440 */
    top: 65.8730%;                /* 664 / 1008 */
    width: 29.1667%;              /* 420 / 1440 */
    gap: 40px;                    /* 100:6572 itemSpacing, vs 32 on mobile */
  }

  .collage__cta { min-height: 48px; }   /* 186:31 */
}


/* == PRODUCTS ================================================================
   Figma desktop `4` 100:6587 (1440x2066) - mobile `4` 228:3643 (393x2049).
   Mobile-first; the base block is the 393 design.
   Full node read: figma-specs/home/_nodes/products.md

   Unlike the collage this is ordinary FLOW layout — a column of intro, grid and
   button — so the vertical rhythm is padding and margins, and only the card
   internals need aspect-ratio to stay proportional.

   ⚠️ CARD 1 IN THE FILE IS THE HOVER STATE, not a different card. `maguro`
   (100:6596) is the only card with a "See More" overlay AND the only one whose
   pattern is at opacity 0.50 against 0.10 on the other five. So 0.10 is the
   default and everything about card 1 belongs under :hover / :focus-within.

   ⚠️ MOBILE PHOTO BOXES ARE A DELIBERATE, DOCUMENTED SIMPLIFICATION. The file
   gives each photo a hand-tuned box per breakpoint (maguro 408x230 desktop but
   293x147 mobile, a tighter crop; crab 388x272 / 293x205) with no consistent
   relationship between the two. Reproducing six hand-tuned mobile crops would
   break the moment a marketer swaps one photograph — the crop is tied to that
   exact image. So the width is per-row and the height follows the photograph's
   own proportions at both widths. Desktop is EXACT (each asset is 2x its node
   box, so the natural aspect IS the design's box); mobile crops less tightly
   than the frame. Recorded in the spec's __doc__ with the measured delta.
   ========================================================================== */
.products {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Section top -> intro 120, button bottom -> section bottom 120. */
  padding-block: 120px;
  padding-inline: var(--ooc-container-pad);
  color: var(--ooc-navy-blue);
  /* ⚠️ THIS SECTION DOES HAVE ITS OWN FILL, unlike the two above it. 100:6587
     is #FFF9E9 (`light 2`) where the statement and the collage have empty fills
     and show the page's #F2E6D5 through. Carrying the previous sections'
     "no background" over by analogy put the whole section 13 units too dark —
     invisible against a section boundary, and caught only by diffing an EMPTY
     margin strip against the Figma render, where a 17.6 mean over pixels that
     should be identical is unambiguous. */
  background: var(--ooc-light-2);
}

/* -- Intro (100:6588 / 228:3644) ------------------------------------------ */
.products__intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;                      /* 228:3644 itemSpacing; desktop is 40 */
  width: 100%;
  text-align: center;
}

.products__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100, exact at both references */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop overrides to 100/100 */
  letter-spacing: -2px;
  /* The intro is a centred flex column, so without this the heading shrinks to
     its own text and stops being the 851/353 block the design measures. */
  width: 100%;
}

.products__copy {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 228:3646 / 100:6590 itemSpacing */
  width: 100%;
}
.products__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}

/* ⚠️ THE PRODUCTS PAGE'S LEDE IS 14, NOT 16 — and this is REPRODUCING a defect
   in the file, not correcting one.

   Home's 100:6591 carries its own text-[16px] and its read reports the bound
   style `body M: Semibold 16/20/600`. This page's 210:2749 carries NO size and
   inherits 14 from its parent, and its read reports no body token at all. The
   two blocks are otherwise byte-identical — same copy, same 851x200 heading,
   same 400-wide B1Text, same 4px gap, same 64 total height. So the `body M`
   style has DETACHED on this page's copy of the block.

   Built as read rather than "fixed" to 16: writing 16 into both the CSS and the
   spec would agree with itself, go green, and bury a live discrepancy nobody
   would ever re-check. Leading is 20 either way, so nothing in the layout
   moves — this is glyph size alone. Delete this rule the moment the designer
   confirms it should be 16. docs/content-flags.md. */
.page-products .products__lede { font-size: var(--ooc-body-s-size); }

/* -- The dot band — `pattern 1` 210:1239, 1440x629 at the section's top -----
   Missing until the render was diffed against the node's: ours measured a flat
   #FFF9E9 (ONE distinct colour, 100% modal share) where Figma's had 108
   distinct colours and sat 6.4/5.5/3.4 darker. Invisible to the scorecard,
   which was 137/137 at the time — nothing probes "is there a texture here".

   PRE-COMPOSITED, deliberately. The node is a 2878x2878 source windowed at
   266.84% x 610.29% and offset -112.14% / -381.57%, at 8% opacity under a
   40px blur. Reproducing that at runtime means a 40px blur over 1440x629 on
   every scroll frame, and the offsets are only expressible in fixed px, which
   breaks the fluid rule. So the window, the blur and the 8% are baked once.

   ⚠️ Extract rule 4: a Figma node export composites against the PAGE, not
   against transparency — this one came back opaque over WHITE. That is not a
   problem to correct, it is the useful form: the pattern is a SUBTRACTIVE
   overlay, and `background-blend-mode: multiply` reproduces subtraction against
   whatever background-color the section sets.

       result = base x render / 255,  render = 255 - d over white
       over #FFF9E9 (R 255): 255 - d          exact
       over #F2E6D5 (R 242): 242 - 0.949d     err <= 0.65/255

   THE ASSET IS THEREFORE BASE-INDEPENDENT and shared. It was briefly baked over
   #FFF9E9 (`products-pattern.png`), which made it single-use — and the About
   page's section 6 turned out to be #F2E6D5, not #FFF9E9 (its Figma fill is
   EMPTY and the page shows through — extract rule 11, again). A second baked
   copy would have been the wrong fix.

   `100% auto` keeps it proportional to the width, as the node is (629/1440). */
.page-products .products {
  background-image: url("../images/section-pattern.png");
  background-repeat: no-repeat;
  background-position: top center;
  background-size: 100% auto;
  /* Blends with this element's own background-color, set above. */
  background-blend-mode: multiply;
}
.products__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* -- Control bar (`Group 33` 360:2680) — the products page only ------------
   A second, smaller heading with the category filter beside it. On the home
   page it does not exist, and the section renders exactly as before.

   It occupies the SAME 100px the home page leaves between its intro and its
   grid: intro ends 544, bar heading at 644, heading ends 692, grid at 724. So
   the bar consumes that gap and pushes the grid down by 80.

   ⚠️ MOBILE IS DERIVED — no inner page has a mobile frame. Stacked rather than
   side by side (a 48px heading and a 133px control will not share 353), and the
   heading rides the design system's OWN 48->40 ramp (--ooc-fluid-product,
   measured off the product name, the only other 48px display role in the file).
   Nothing here is freestyled; see docs/mobile-first.md §2.                   */
.products__controls {
  width: 100%;
  display: flex;
  flex-direction: column;         /* desktop is a row */
  gap: 20px;
  /* Mobile intro -> bar. 60 is the home frame's own mobile intro -> grid gap,
     i.e. the value this bar displaces. Desktop is 100. */
  margin-top: 60px;
}

/* ⚠️ STICKY ON MOBILE ONLY — DERIVED, the file draws no stuck state for this
   bar. Vitaliy 2026-08-07: "on mobile can filtering also be sticky while
   scroling products."

   The reason is measurable: at 393 the grid is 4075px tall, so the filter
   leaves the viewport after the first card and every later card is browsed with
   no way to change category without scrolling back to the top.

   ⚠️ THE WHOLE CONTROLS BLOCK STICKS, not just the filter row, and that is
   structural rather than a preference: `position: sticky` is constrained by its
   PARENT's box, and `.products__filters` sits inside this 80px container — so
   sticking the row alone would travel 80px and stop. Sticking this block makes
   the section the containing block, so it travels the whole grid.

   `top` is the fixed header's own height token, so the two cannot drift apart.
   The cream background is required, not decorative: without it the cards scroll
   visibly through the bar. z-index sits below the header's 100 and the drawer's
   200, and above the cards. */
@media (max-width: 1024px) {
  .products__controls {
    position: sticky;
    inset-block-start: var(--ooc-nav-h);
    z-index: 20;
    background: var(--ooc-light-2);
    /* ⚠️ THE BREATHING ROOM IS A BOX-SHADOW, NOT PADDING, AND THAT IS THE WHOLE
       TRICK. The bar needs cream above and below it so cards do not scroll into
       its text — but padding CHANGES THE LAYOUT: 12px top and bottom grew this
       block 80 -> 104 and pushed the grid, every card and the section height
       down by 24, failing eight assertions that were describing the design
       correctly.

       A shadow paints outside the border box and occupies no space. Same cream,
       same visual result, and the section's measured geometry is byte-identical
       to the design.

       ⚠️ NO NEGATIVE INLINE MARGIN EITHER. The first version pulled the bar to
       the viewport edge and padded it back in, which shifted the heading from
       x=20 to x=40 (the gate caught it) — and it was never needed: the cards
       share this bar's 20px gutter, and the gutters show the section's own
       cream. There is nothing to cover. */
    box-shadow:
      0 -14px 0 var(--ooc-light-2),
      0  14px 0 var(--ooc-light-2);
  }
}

.products__controls-heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-product);  /* 40 -> 48; the `h4` token at 1440 */
  font-weight: 400;
  line-height: 1;                       /* 48/48, set solid */
  letter-spacing: -1px;
}

.products__filters {
  display: flex;
  align-items: flex-start;
  /* 22, not 24 or 10. The file draws ONE gap here — the OPEN panel's right edge
     (1179+157 = 1336) to `Sort By` (1346) is 10. The panel's chrome overhangs
     its own content by the 12px padding, and the panel is positioned out of
     flow, so a 22px flex gap between the two CONTROLS is what puts the open
     panel exactly 10 from Sort By. Reproducing the drawn number rather than
     rounding to a familiar one. */
  gap: 22px;
  /* Suisse Book 14/20 on both controls (360:2658 / 360:2670). */
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* -- The category control — a <details>, not a <select> --------------------
   A native disclosure gives keyboard support and the design's exact two states
   for free, and every option inside is a real link, so a filtered grid is a URL
   rather than a JS state nobody can share, index or go back from. */
.products__filter { position: relative; }

.products__filter-trigger {
  display: flex;
  align-items: center;
  gap: 10px;                      /* 360:2715 itemSpacing */
  cursor: pointer;
  /* The disclosure triangle is replaced by the design's own chevron. Both
     properties are needed: `list-style` covers Firefox, the pseudo-element
     covers WebKit. */
  list-style: none;
  /* Above the panel's chrome, so the trigger stays legible once it opens. */
  position: relative;
  z-index: 2;
}
.products__filter-trigger::-webkit-details-marker { display: none; }

.products__filter-icon,
.products__sort-icon {
  flex: none;
  width: 16px;
  height: 16px;
}

/* OPEN = the same chevron flipped, which is `360:2659` EXACTLY. A 180deg rotate
   would land the apex at x 8.5 rather than 7.5 — 1px out — so this flips. */
.products__filter[open] .products__filter-icon { transform: scaleY(-1); }

/* THE PANEL OVERLAYS, IT DOES NOT PUSH. In the file the open panel runs
   y 659..841 while the grid starts at 724 — it is drawn OVER the first row of
   cards. In flow it would shove the whole grid down 182px on open, which is
   both wrong and horrible to use.

   Geometry is the node's, worked backwards from the trigger rather than the
   panel: the panel's top-left is the trigger's minus its 10/12 padding, and the
   options begin 10 below the 20px-tall trigger row — hence the 40px top pad. */
.products__filter-options {
  position: absolute;
  top: -10px;                     /* panel top = trigger top - padding */
  left: -12px;
  z-index: 1;
  margin: 0;
  list-style: none;
  width: max-content;
  min-width: calc(100% + 24px);   /* 133 trigger + 24 padding = the node's 157 */
  /* 10 (pad) + 20 (trigger row) + 10 (panel gap) — 360:2668 itemSpacing. */
  padding: 40px 12px 10px;
  display: flex;
  flex-direction: column;
  gap: 8px;                       /* 360:2727 itemSpacing */
  background: var(--ooc-light-2);
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 6px;
  box-shadow: 0 0 2px rgba(0, 0, 0, 0.04), 0 8px 8px rgba(0, 0, 0, 0.08);
}

.products__filter-link {
  color: inherit;
  text-decoration: none;
  /* Options are Suisse REGULAR (360:2717) where the trigger is Book
     (360:2658) — a real weight change between the two, not one style. */
  font-weight: var(--ooc-weight-regular);
  white-space: nowrap;
}
.products__filter-link:hover,
.products__filter-link:focus-visible { text-decoration: underline; }
.products__filter-link.is-current { font-weight: var(--ooc-weight-semibold); }

.products__filter-option--reset {
  /* The reset is derived, not drawn (the file only shows the unfiltered state),
     so it is set apart rather than passed off as one of the design's five. */
  border-top: 1px solid rgba(0, 0, 0, 0.1);
  padding-top: 8px;
}

/* -- Sort — drawn as designed, inert, and announced as such ----------------
   `360:2669` has no open state anywhere in the file, so its options are unknown
   and inventing them is a Hard rule 1 violation. Rendered at full fidelity and
   disabled; docs/content-flags.md carries the question. */
.products__sort {
  display: flex;
  align-items: center;
  gap: 10px;                      /* 360:2669 itemSpacing */
  cursor: not-allowed;
  /* Not dimmed: the design draws it at full strength and a greyed control would
     be inventing a visual state the file does not contain. aria-disabled
     carries the meaning instead. */
}

.products__empty {
  margin: 32px 0 0;
  font-size: var(--ooc-body-s-size);
  line-height: var(--ooc-body-s-lh);
}

/* -- The grid (100:6593 / 228:3649) --------------------------------------- */
.products__grid {
  list-style: none;
  margin: 60px 0 0;               /* intro -> grid; desktop is 100 */
  padding: 0;
  width: 100%;
  display: grid;
  grid-template-columns: 1fr;     /* desktop is 3 */
  gap: 20px;                      /* both axes, both breakpoints */
}

/* With a control bar between them, the intro -> grid gap belongs to the bar and
   what is left is the bar -> grid gap: 692 -> 724 = 32 on desktop, scaled to
   the mobile ramp here (32 x 60/100 = 19.2, taken to the design's own 20 unit). */
.products__controls + .products__grid { margin-top: 20px; }

.products__item { display: flex; }   /* so a short card still fills its row */

/* THE COLLAPSE. Scoped to `.js` because only a script can reveal these again —
   without it the reader gets every product rather than content behind a control
   that cannot work. They stay in the DOM either way, so search engines see the
   whole catalogue at both widths. */
.js .products__grid:not(.is-expanded) .products__item--beyond-mobile {
  display: none;
}

/* ⚠️ THE CATEGORY FILTER IS *NOT* GATED ON `.js`, unlike the collapse above,
   and the difference is deliberate.

   The collapse is an enhancement: with scripting off there is no button to
   expand it, so hiding cards would strand them. The FILTER is the opposite —
   `?product_category=` is a real URL that the server renders correctly, so with
   scripting off these rules are the whole feature. main.js only moves the class
   around afterwards so choosing a category does not reload the page.

   Both flags can sit on one card at once — filtered out AND beyond the fold —
   which is why the filter has a class of its own rather than reusing either
   collapse class. `display: none` from either wins; neither can un-hide the
   other. */
.products__item--filtered-out { display: none; }
.products__filter-option.is-hidden { display: none; }

/* -- The card (100:6596 / 228:3670) --------------------------------------- */
.products__card {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  padding-bottom: 20px;           /* 228:3670; desktop is 32 */
  /* OUTLINE, not border, and that is a fidelity fix rather than a preference.
     In the file the card is 453 wide and its `product im` child is ALSO 453 —
     the stroke does not inset the frame. A CSS border does: with border-box
     sizing it would take 1px off each side and leave the media 451, dragging
     the pattern and the photograph 2px narrow with it. An outline draws on the
     edge without consuming layout, and modern browsers follow border-radius
     with it. `outline-offset: -1px` pulls it just inside, matching a stroke
     drawn on the boundary rather than outside it. */
  outline: 1px solid var(--ooc-navy-blue);
  outline-offset: -1px;
  border-radius: 20px;
  color: inherit;
  text-decoration: none;
}

.products__media {
  position: relative;
  width: 100%;
  /* aspect-ratio, not a fixed height: the card is a grid track and widens
     between the references, so a px height would distort the photo's frame. */
  aspect-ratio: 353 / 320;        /* 228:3671; desktop is 453/380 */
  border-radius: 20px 20px 0 0;
  /* `clip` rather than `hidden` — this must not become a scroll container. */
  overflow: clip;
}

/* Pattern (228:3672 / 100:6598). 495x383 pinned top-left of the media box and
   STRETCHED to that size by the design's own `size-full`, hence the explicit
   background-size rather than `cover`. */
.products__pattern {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 140.2266%;               /* 495 / 353 */
  height: 119.6875%;              /* 383 / 320 */
  background: url("../images/s4-card-pattern.webp") 0 0 / 100% 100% no-repeat;
  filter: blur(20px);
  /* ⚠️ 0.2, NOT the node's 0.10 — because FIGMA BAKES A NODE'S FILL OPACITY
     INTO ITS EXPORT, and this asset came from 100:6598, which is the HOVER
     card at opacity-50. Proven: that node's export has mean alpha 27.89 and
     the 10%-opacity carousel node's export of the same picture has 5.56 —
     a ratio of 5.016 against the nodes' 50/10. So the file already carries
     50%, and each state divides down from there rather than multiplying up:
         default  0.2 x 50% = 10%   (the design's default cards)
         hover    1.0 x 50% = 50%   (100:6598 itself)
     Re-applying the node's own 0.10 on top composited it twice and rendered
     the pattern at 1% — a flat wash. It was at 0.1 here for the same reason.  */
  opacity: 0.2;
  pointer-events: none;
  transition: opacity 160ms ease;
}

/* The designer MIRRORS the pattern on some cards so the same blurred blobs do
   not tile visibly across the row. In the file it is cards 2 and 5 that carry
   `-scale-y-100 rotate-180` (100:6616, 100:6656) while 1, 3, 4 and 6 do not —
   every third card starting at the second, which is what this reproduces, and
   it keeps the rhythm for a seventh card a marketer adds. Not an ACF field:
   the pattern is decorative and its mirroring is a property of the layout, not
   of any one product. */
.products__item:nth-child(3n+2) .products__pattern { transform: scaleX(-1); }

/* Each photograph is drawn at its own width — the design does not scale them to
   a shared box — so the width arrives per row as --ooc-photo-w and the height
   follows the file's own proportions. --ooc-photo-flip is -1 on the rows the
   design mirrors so the fish faces into the card. */
.products__photo {
  position: absolute;
  /* Vertical position is PER ROW. Five of the six are centred; the design drops
     the shrimp to `top: calc(50% + 25.49px)` (100:6671), which is 6.708% of the
     380 media box. Centring it too put it 25px high — the size was right, so no
     geometry probe saw it; only the pixel diff did. */
  inset-block-start: var(--ooc-photo-y, 50%);
  inset-inline-start: 50%;
  /* MOBILE IS UNIFORM. The per-row width is a desktop value — every mobile card
     in the file draws its photograph at 293 of a 353 card (228:3673, 228:3777,
     228:3762 all agree), so mobile takes one number and the per-row widths
     apply from 1025 up. Letting the height follow the photograph reproduces the
     file exactly for madai (146.9 vs 147) and crab (205.4 vs 205); maguro is
     the single outlier at 165 against the file's 147, because that one card
     crops the source tighter on mobile than on desktop. */
  width: 83.0%;                   /* 293 / 353 */
  height: auto;
  transform: translate(-50%, -50%) scaleX(var(--ooc-photo-flip, 1));
  object-fit: contain;
}

/* Hover affordance (100:6600) — only rendered when the card has a destination.
   Hidden by default and revealed with the card's own hover, so it never
   advertises an interaction that is not there. */
.products__more {
  position: absolute;
  inset-block-start: 56.0526%;    /* 213 / 380 */
  inset-inline-start: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10.6px;                    /* icon bottom 274.4 -> label top 285 */
  opacity: 0;
  transition: opacity 160ms ease;
  pointer-events: none;
}
.products__more-icon {
  display: block;
  width: 77.682px;
  aspect-ratio: 77.6816 / 61.4034;
  background: url("../images/s4-see-more.svg") center / contain no-repeat;
}
.products__more-label {
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: 14px;
  letter-spacing: -0.14px;        /* 100:6601 */
}

/* :focus-within as well as :hover — the card is a link, so it must show the
   same affordance to a keyboard reader. */
.products__item--linked .products__card:hover .products__pattern,
.products__item--linked .products__card:focus-visible .products__pattern {
  /* 1.0, not the node's 0.50: the asset IS the 50% export (100:6598), so full
     strength here is exactly what the design draws hovered. See the default
     rule above for the arithmetic. */
  opacity: 1;
}
.products__item--linked .products__card:hover .products__more,
.products__item--linked .products__card:focus-visible .products__more {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .products__pattern,
  .products__more { transition: none; }
}

/* -- Card meta (228:3675 / 100:6606) -------------------------------------- */
.products__meta {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;                      /* itemSpacing, same at both breakpoints */
  width: 100%;                    /* desktop caps this at 272 */
  padding-top: 20px;              /* 228:3675; desktop is 32 */
  text-align: center;
}

/* The rule between media and meta (228:3674 / 100:6605). Its own element, and
   that matters: the node is `w-full` — the whole card — while the meta beside
   it is capped at 272 on desktop. Drawn as a border on the meta it came out
   272 wide against the design's 453, and the probe passed anyway because it
   checked the border's WIDTH-IN-PX and COLOUR but never the line's extent. */
.products__rule {
  width: 100%;
  height: 1px;
  background: var(--ooc-navy-blue);
}

.products__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-product);   /* 40 -> 48, exact at both references */
  font-weight: 400;
  line-height: 1;                 /* 40/40 mobile, 48/48 desktop — solid at both */
  letter-spacing: -1px;
}

.products__attrs {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;                      /* 228:3789 itemSpacing */
}
.products__attr {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-medium);
  line-height: 14px;
  white-space: nowrap;
}
/* The separator is a 10x8 blob at 20% (228:3791). A mask, not a background
   image, so it follows the text colour — and generated rather than marked up,
   because it is punctuation between items, not an item of its own. */
.products__attr + .products__attr::before {
  content: "";
  flex: none;
  width: 10px;
  height: 8px;
  background: currentColor;
  opacity: 0.2;
  -webkit-mask: var(--ooc-attr-sep) center / contain no-repeat;
  mask: var(--ooc-attr-sep) center / contain no-repeat;
}

/* -- The button (228:3737 / 100:6681) ------------------------------------- */
.products__cta {
  margin-top: 40px;               /* grid -> button; desktop is 60 */
  width: 260px;
  min-height: 52px;               /* 228:3737; desktop is 48 */
  justify-content: space-between;
  gap: 0;
}
.products__cta-icon {
  flex: none;
  width: 30.363px;                /* 122:279 */
  height: 24px;
}
span.products__cta { cursor: default; }


@media (min-width: 1025px) {
  .products {
    padding-block: 240px;
    padding-inline: var(--ooc-container-pad-lg);
  }

  /* Frame 5 is 851 wide and centred (x294.5, centre 720 = 1440/2) — narrower
     than the content column, so it is capped rather than gutter-bound. */
  .products__intro {
    max-width: 851px;
    gap: 40px;                    /* 100:6588 itemSpacing */
  }
  .products__heading { line-height: 1; }   /* 100/100 exactly */
  /* B1Text is 400 wide inside the 851 block. */
  .products__copy { max-width: 400px; }

  /* -- Control bar at 1440 (360:2680) -------------------------------------
     The heading sits at the bar's top (644) and the controls 25 lower (669),
     which is neither centred nor baseline-aligned to it — so both are placed
     from the node's own numbers rather than by an alignment keyword. */
  .products__controls {
    flex-direction: row;
    justify-content: space-between;
    align-items: flex-start;
    margin-top: 100px;            /* intro ends 544 -> bar 644 */
    /* DESKTOP COMPOSITION CAP (§4) — the same 1400 the grid below it takes.
       `.products` is one of the three full-bleed fills, so its padding does NOT
       cap this; a space-between row given the whole viewport pushes `Sort By`
       to the far edge while the grid under it stays 1400, and the two stop
       sharing a right margin. Measured 1400 → 1880 → 2520 at 1440/1920/2560
       before this line, with every scorecard still 100% — the gate cannot see
       above 1440 by construction. Caught by scripts/check-composition-cap.mjs.
       `.products` centres it via its own align-items: center. Inert below 1440. */
    max-width: 1400px;
  }
  .products__filters { margin-top: 25px; }   /* 669 - 644 */

  .products__controls + .products__grid { margin-top: 32px; }  /* 692 -> 724 */

  /* ⚠️ THE PRODUCTS PAGE'S TRAILING SPACE IS 306, NOT 240.
     `2` 210:1236 is 3198 tall with its grid ending at 2892. The home frame's
     240 is symmetric with its top padding; this one is not, and 306 is what the
     file draws. Scoped to the page because it is that frame's number, not a
     property of a catalogue grid — functions.php adds `page-<slug>` for exactly
     this. Re-derive if the frame changes. */
  .page-products .products { padding-bottom: 306px; }

  .products__grid {
    margin-top: 100px;            /* 644 - 544 */
    grid-template-columns: repeat(3, 1fr);
    /* DESKTOP COMPOSITION CAP (§4). `repeat(3, 1fr)` divides whatever it is
       given, so uncapped the grid measured 1400 → 1880 → 2520 and each card
       453.3 → 613.3 → 826.7. Because .products__media is an aspect-ratio canvas
       (453/380) that width became height too: the section measured 2066.6 →
       2335.0 → 2692.9, i.e. 626px of page bought with no extra content, under
       an intro frozen at 851 and a heading --ooc-fluid-h2 had already clamped
       at 100px. One line here fixes the card, the grid and the section height.

       The #FFF9E9 fill on .products stays full-bleed — it is one of the three
       the design draws edge to edge. .products centres this with its own
       `align-items: center`. Inert below 1440. */
    max-width: 1400px;            /* the content column, 1440 - 2x20 */
  }

  .js .products__grid:not(.is-expanded) .products__item--beyond-mobile {
    display: flex;                /* mobile's collapse does not apply here */
  }
  .js .products__grid:not(.is-expanded) .products__item--beyond-desktop {
    display: none;
  }

  .products__card { padding-bottom: 32px; }        /* 100:6596 */
  .products__media { aspect-ratio: 453 / 380; }    /* 100:6597 */

  .products__pattern {
    width: 109.2715%;             /* 495 / 453 */
    height: 100.7895%;            /* 383 / 380 */
  }

  /* Each photograph at its OWN width from here up — 408/453 for maguro,
     308/453 for uni, and so on. Each asset is exported at 2x its node box, so
     the natural aspect IS the design's box and the height needs no value. */
  .products__photo { width: var(--ooc-photo-w, 85%); }

  .products__meta {
    max-width: 272px;             /* 100:6606 */
    padding-top: 32px;
  }

  .products__cta {
    margin-top: 60px;             /* 1134 - 1074 */
    min-height: 48px;             /* 100:6681 */
  }
}


/* == CAROUSEL ================================================================
   Figma desktop `roseart` 100:6687 — block A of `Frame 1975` 100:6686
   (1440x2391 at page y 5102) · mobile inside `5` 228:3796 (393x2476).
   Mobile-first; the base block is the 393 design.
   Full node read: figma-specs/home/_nodes/menu-and-difference.md

   ⚠️ NO BACKGROUND, and that was PROVEN rather than inherited. `products`
   directly above is #FFF9E9, so carrying the neighbour over would have been the
   obvious mistake. Sampling an empty 400x120 strip of the Figma render between
   the two blocks reads #F2E6D5 at 100% modal share over 48000 pixels — the
   page's own colour — so the section paints nothing and the body shows through.

   ⚠️ THE FRAME IS TWO BLOCKS. This is the carousel only; the numbered
   "What makes us different" list below it is a separate layout. The 200 desktop
   / 100 mobile gap between them is SPLIT EVENLY — 100 here, 100 there — so each
   section carries its own breathing room and marketing can reorder them without
   either one collapsing against its neighbour.
   ========================================================================== */
.carousel {
  position: relative;
  /* Section top -> heading 120, gallery bottom -> section bottom 50 (half the
     100 gap to the numbered list; that block carries the other half). */
  padding-block: 120px 50px;
  color: var(--ooc-navy-blue);
  /* The dot band's 20px blur bleeds past the section edges. `clip` rather than
     `hidden` so this never becomes a scroll container and trips the gate's
     horizontal-overflow check. */
  overflow-x: clip;
}

/* -- Dot band -------------------------------------------------------------
   ⚠️ SUPPLIED ASSET, 2026-08-02. This band no longer derives from 100:6775 /
   228:3822. Vitaliy exported it directly — "background that should be full
   width and stick to the top" — and the export supersedes the node's geometry:
   it is 1440x325, where 100:6775 is 1440x245.

   WHAT THE EXPORT ALREADY CONTAINS, measured off the file rather than assumed
   (Playwright canvas, every one of its 468,000 pixels):

     opacity   NOT a separate property any more. 0 pixels are opaque and the
               MAXIMUM alpha in the whole image is 26/255 = 10.2%. The 10% is
               baked into the alpha channel, so the old `opacity: 0.1` would
               have multiplied it to 1% and rendered the band invisible.
     blur      already in the pixels; `filter: blur(20px)` would double-apply.
     flip      already in its final orientation. `transform: scaleX(-1)` existed
               to correct the metadata-x trap when a crop was being derived out
               of the 2072x2074 texture. There is no crop to derive now.
     ink       rgb(0,21,85) at its darkest — the navy, not the warm ink the
               node's name implies.

   So all four of the constants this rule used to carry are GONE, not moved.
   That is also what unblocked full width: they were solved against a 1440-wide
   box (`p = offset / (100% - size%)`), which is why a wider box rescaled the
   texture 1.33x at 1920 and landed a different crop of it. A pre-cut band needs
   none of that — `100% auto` from `top` holds the asset's own proportion at any
   width, exactly as `.statement__pattern` does.

   ⚠️ FORMAT — PNG, not the JPEG also supplied, and this was not a preference.
   The export is transparent EVERYWHERE (0 opaque pixels), so a JPEG flattens it
   to an opaque plate over the page's #F2E6D5. Shipped as LOSSLESS WebP: 27,012
   bytes against the PNG's 83,220 and the JPEG's 35,094 — smaller than either,
   with the alpha channel bit-exact (transparent/partial pixel counts identical
   before and after). Lossy WebP measured LARGER at 39,524, because nearly all
   the signal is in the alpha plane, which WebP stores losslessly regardless.

   ⚠️ NOT `s3-dots.webp`. That asset is still the navy-on-cream field used at
   main.css:1808 and must not be overwritten by this one.

   ⚠️ MOBILE IS DERIVED FROM THE SAME ASSET (Hard rule 2). No mobile export was
   supplied and 228:3822 draws 393x67, where this asset's own proportion gives
   393x88.7. Deriving from the supplied asset is the same call `.statement__
   pattern` makes; flagged for Vitaliy since the node says otherwise. */
/* ⚠️ PLACED AT ITS NATURAL SIZE, TOP-LEFT — NOT SCALED. Vitaliy 2026-08-02,
   "background image position should be top left".

   It was `center top / 100% auto` with an `aspect-ratio` box, i.e. the asset
   was STRETCHED to whatever width the viewport happened to be. Exact at 1440
   and wrong at both ends of the range: at 2560 the dots rendered 1.78x their
   designed size in a 578-tall band, and at 393 the whole 1440-wide texture was
   squeezed into 393px, making them 3.7x too small and far too dense.

   `left top / auto` places the asset instead, and the box crops it. Measured
   consequences:
     1440  identical — the asset IS 1440x325, so nothing moves at the reference
     393   the left 393px at TRUE scale, rather than the whole band squeezed
     2560  dots at designed size, cream to the right

   ⚠️ THE SHIPPED ASSET IS TRIMMED, 1408x288 from the export's 1440x325.
   Vitaliy, 2026-08-02: "background still not starting from top, some weird
   space about 40px from top". Measured, it was 37: the export carries 37px of
   FULLY TRANSPARENT rows at its top and 32px of transparent columns at its
   left, and the boundary is hard rather than a fade — rows 0-36 hold zero
   alpha and row 37 is already at 91% of the band's peak. So `left top` was
   honouring the file exactly and the file had a margin baked into it.

   Cropped at the source with `cwebp -crop 32 37 1408 288` from the ORIGINAL
   PNG, not by re-encoding the WebP, and verified: first row and first column
   with any alpha are both 0 now. 26,950 bytes.

   ⚠️ A REPLACEMENT EXPORT MUST BE TRIMMED THE SAME WAY. The alternative was
   `background-position: -32px -37px`, which leaves the asset untouched but
   hides the offset in the stylesheet where a re-export would silently
   mis-position instead of merely re-introducing the gap. Neither is
   self-healing; this one at least keeps the CSS honest at `left top`.

   ⚠️ NO SEAM ABOVE 1440, measured rather than hoped: ink ends at column 1074 of
   1408, so the band fades out on its own before its right edge at any width.
   Ink ends at row 176 of 288 likewise — the box is the asset's full height
   rather than its ink height because that tail IS the fade.

   ⚠️ The height is FIXED, not an aspect-ratio. A ratio box would have grown to
   512 at 2560 (empty below the image) and shrunk to 78.6 at 393, cutting a hard
   horizontal edge THROUGH ink whose fade runs to y176. */
.carousel__dots {
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: 288px;                  /* the trimmed asset's own height */
  background: url("../images/s5-dots.webp") left top / auto no-repeat;
  pointer-events: none;
}

/* -- Header (228:3800 / 100:6688 + 100:6689) -------------------------------
   Mobile stacks and centres; desktop pulls the heading to the content-column
   left edge and the lede flush right against it, the same left/right pairing
   the collage uses for its split display sentence. */
.carousel__header {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;                      /* 228:3800 itemSpacing */
  margin-bottom: 60px;            /* header bottom 268 -> gallery 328 */
  /* MARGIN, not padding. 228:3800 is 353 wide at x20 — the gutter is OUTSIDE
     its box. As padding the element measured 393 wide at x0 with the text in
     exactly the right place, so every child probe passed and the header's own
     box was 40px too wide at both widths. */
  margin-inline: var(--ooc-container-pad);
  text-align: center;
}

.carousel__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100, exact at both references */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop is 100/100 */
  letter-spacing: -2px;
  /* Without this the centred flex column shrinks the heading to its own text
     and it stops being the 353/851 block the design measures. */
  width: 100%;
}

.carousel__copy {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 228:3802 / 100:6689 itemSpacing */
  width: 100%;
}
.carousel__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.carousel__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* -- Gallery (228:3824 / 100:6692) ---------------------------------------- */
.carousel__gallery {
  position: relative;
  width: 100%;
}

/* THE CAROUSEL IS A SCROLL CONTAINER. Touch momentum, arrow-key scrolling,
   screen-reader traversal and find-in-page are all the platform's, already
   correct; the pointer-drag and custom cursor are an enhancement on top rather
   than a reimplementation of scrolling.

   Asymmetric mobile padding is the design, not a fudge: the slide is 354 wide
   at x20 in a 393 frame, which leaves 19 on the right. 20/19 reproduces that
   exactly and keeps the flex content box at 354 so the slide can be a plain
   `flex-basis: 100%` at both widths (desktop: 1440 - 64 - 64 = 1312). */
.carousel__track {
  list-style: none;
  margin: 0;
  padding-block: 0;
  padding-inline: 5.0891% 4.8346%;  /* 20 / 19 of 393; desktop is 64 both */
  display: flex;
  gap: 20px;                        /* 1332 - 1312 desktop, 374 - 354 mobile */
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline-start: 5.0891%;
  scroll-behavior: smooth;
  /* Stops a horizontal flick from rubber-banding the whole page. */
  overscroll-behavior-x: contain;
  /* The design draws no scrollbar; the pager and the drag cue are the cues. */
  scrollbar-width: none;
}
.carousel__track::-webkit-scrollbar { display: none; }

.carousel__slide {
  position: relative;
  flex: 0 0 100%;                 /* = the track's content box, 354 / 1312 */
  aspect-ratio: 354 / 480;        /* 228:3825; desktop is 1312/667 */
  border-radius: 20px;
  /* `clip`, not `hidden` — a second scroll container here would swallow the
     track's own scrolling. */
  overflow: clip;
  scroll-snap-align: start;
}

/* The slide photograph (228:3826 / 100:6694).
   `object-fit: cover` rather than the design's hand-placed fill. The node scales
   the photograph to the slide's WIDTH and pushes it up — a framing tied to that
   one image's proportions, which would land a swapped photograph anywhere. The
   shipped asset is instead pre-cut to the UNION of the two windows (source
   x28-2703, y1423-3126 of the 2731x4096 original), which makes `cover` exact at
   both widths: mobile needs 50% 50% and desktop 50% 45.93%. */
.carousel__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The global img { max-width: 100% } would fight an oversize fill. */
  max-width: none;
  object-fit: cover;
  object-position: 50% 50%;
}

/* -- The card (228:3827 / 100:6695) ---------------------------------------
   A smaller sibling of the product-grid card, same anatomy. Percentages are of
   the SLIDE, which is the positioned ancestor, so the whole card scales with it.

   ⚠️ Mobile fixes the card's height (228:3827 is `h-[232px]` against content of
   208.5) while desktop lets it follow its content (257 + 1 + 80 + 20 = 358).
   Reproduced as-is: aspect-ratio here, auto at desktop.

   OUTLINE, not border, for the same reason as the product card: the stroke does
   not inset the frame in the file, and a CSS border would take a pixel off each
   side and drag the pattern and photograph narrow with it. The design's stroke
   is 0.436px mobile / 0.675px desktop; a sub-pixel line renders inconsistently
   across browsers, so this ships 1px at both — a documented +0.325..0.564px. */
.carousel__card {
  position: absolute;
  inset-inline-start: 2.8249%;    /* 10 / 354; desktop 20 / 1312 */
  inset-block-end: 2.0833%;       /* 10 / 480; desktop 20 / 667  */
  width: 50.8475%;                /* 180 / 354; desktop 280 / 1312 */
  aspect-ratio: 180 / 232;        /* 228:3827; desktop is content-driven */
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-bottom: 2.8249%;        /* 10 / 354 — % of the SLIDE, so it scales */
  background: var(--ooc-light-2);
  outline: 1px solid var(--ooc-navy-blue);
  outline-offset: -1px;
  border-radius: 12px;
}

.carousel__media {
  position: relative;
  width: 100%;
  aspect-ratio: 180 / 165.8605;   /* 228:3828; desktop is 280/257 */
  border-radius: 8.718px 8.718px 0 0;   /* 228:3828; desktop 13.508 */
  overflow: clip;
}

/* Pattern (228:3829 / 100:6697), pinned top-left and STRETCHED to its own size
   by the design's `size-full`, hence explicit background-size rather than cover.
   MIRRORED: the node carries `-scale-y-100 rotate-180`, which composes to a
   horizontal flip. */
.carousel__pattern {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 119.7524%;               /* 215.554 / 180; desktop 334 / 280 */
  height: 100.7784%;              /* 167.151 / 165.8605; desktop 259 / 257 */
  background: url("../images/s4-card-pattern.webp") 0 0 / 100% 100% no-repeat;
  filter: blur(8.713px);          /* 228:3829; desktop 13.5px */
  /* ⚠️ 0.2, NOT THE NODE'S 0.10 — because FIGMA BAKES A NODE'S FILL OPACITY
     INTO ITS EXPORT. This section shares the product grid's asset, which came
     from 100:6598 (the hover card, opacity-50): its export has mean alpha
     27.89 where this section's own 10% node exports the same picture at 5.56,
     a ratio of 5.016 against the nodes' 50/10. So the file carries 50% and
     0.2 x 50% = the 10% this node asks for.
     Applying the node's 0.10 on top of a pre-baked fill composites it twice:
     darkening against the card's #FFF9E9 measured -0.10/-0.07/0.00 where
     Figma's own render of the node gives -10.37/-7.91/-5.09 — nothing drawn,
     and low enough to fall under 8-bit quantisation entirely. Invisible to the
     gate too: the probes asserted the element's box and its opacity and both
     were "correct". Only a numeric diff of the render found it.
     Blur was swept (2 -> 12px) and the curve is nearly flat — 3px scores 3.894
     against 8.713px's 4.561 — so the design's own value stays rather than being
     tuned to a 15% difference. Orientation was settled the same way:
     scaleX(-1) scores 3.894 where none/scaleY/scale(-1,-1) score 5.57-5.96. */
  opacity: 0.2;
  transform: scaleX(-1);
  pointer-events: none;
}

/* The cut-out (228:3830 / 100:6698). Mirrored, same composed flip as the
   pattern. Its metadata x is the flipped right edge (163.28 = 18.04 + 145.25),
   so the box comes from the design context, never get_metadata. */
.carousel__cutout {
  position: absolute;
  inset-inline-start: 10.0198%;   /* 18.0356 / 180; desktop 28 / 280 */
  inset-block-start: 28.4083%;    /* 47.1168 / 165.8605; desktop 73 / 257 */
  width: 80.6937%;                /* 145.2487 / 180; desktop 225 / 280 */
  height: 43.9569%;               /* 72.9069 / 165.8605; desktop 113 / 257 */
  max-width: none;
  object-fit: cover;
  transform: scaleX(-1);
}

/* Its OWN element, not a border on the meta: 228:3831 / 100:6699 is w-full
   (the whole card) while the meta above it is 204 wide on desktop. The design's
   0.645px mobile hairline ships at 1px for the same reason as the outline. */
.carousel__rule {
  width: 100%;
  height: 1px;
  background: var(--ooc-navy-blue);
}

.carousel__meta {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;                      /* 100:6700 itemSpacing */
  padding-top: 5.5556%;           /* 10 / 180 of the CARD; desktop 20 / 280 */
  text-align: center;
}

.carousel__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 32px;                /* 32 at BOTH widths — not on the fluid ramp */
  font-weight: 400;
  line-height: 1;                 /* 32/32 solid at both */
  letter-spacing: -1.3333px;      /* 228:3833; desktop is -1 */
  white-space: nowrap;
}

/* ⚠️ NOT RENDERED AT MOBILE, and that is the design: 228:3832 holds the title
   alone where desktop's 100:6700 holds title + attributes. Hidden rather than
   dropped from the markup — content that exists at only one width is content
   Google indexes inconsistently, and it indexes mobile-first. */
.carousel__attrs { display: none; }

/* -- The custom cursor (122:292) ------------------------------------------
   The file parks this at (1110, 256) because that is where the designer's
   pointer was, so it is NOT pinned there. It follows the pointer, and only
   under a fine pointer on a wide screen — see the desktop block. Kept in the
   DOM at opacity 0 so the gate can measure its geometry. */
.carousel__cursor {
  display: none;
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 77.6816px;               /* 122:292 */
  height: 61.4034px;
  background: url("../images/s5-drag.svg") center / contain no-repeat;
  transform: translate(calc(var(--ooc-cursor-x, 0px) - 50%),
                       calc(var(--ooc-cursor-y, 0px) - 50%));
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease;
  z-index: 2;
}
.carousel__gallery.is-cursor-active .carousel__cursor { opacity: 1; }

/* -- Pagination (228:3891) ------------------------------------------------
   Four blobs, the first solid and the rest at 0.2. Real <button>s, which is
   also what pairs the desktop custom cursor with a control a keyboard can
   reach. Mobile only — the desktop frame draws no pager. */
/* ⚠️ `fit-content` + auto margins, NOT a full-width row with centred contents.
   Both put the dots in the same place, which is why this shipped wrong: the
   element was 393 wide at x0 against 228:3891's 90 at x152 — 303px out — while
   every dot sat exactly where the design draws it. The same shape as the rule
   that probed correct on colour and thickness while measuring 76px short, and
   as the footer wordmark that scored 100% while being 143px out of position.
   A centred CHILD is not a centred BOX; assert the box.

   The node's 90 is the FOUR-dot width (4 x 15 + 3 x 10). It is a function of
   the slide count, so the seeded two-slide state measures 40 at x176.5 and the
   spec records that. The 90 is proved by injecting two clones rather than by
   seeding rows nobody authored — the technique already used for this section's
   44px peeks and for Flag 24's thumbnail strip. */
.carousel__pager {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;                      /* 25 pitch - 15 blob */
  width: fit-content;
  margin-inline: auto;
  margin-top: 40px;               /* slide bottom 480 -> pager 520 */
}

.carousel__pager-dot {
  position: relative;
  flex: none;
  width: 15px;
  height: 12px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  color: inherit;
}
.carousel__pager-dot::before {
  content: "";
  position: absolute;
  inset: 0;
  background: currentColor;
  opacity: 0.2;
  -webkit-mask: var(--ooc-carousel-dot) center / 100% 100% no-repeat;
  mask: var(--ooc-carousel-dot) center / 100% 100% no-repeat;
}
.carousel__pager-dot[aria-current="true"]::before { opacity: 1; }

/* The hit area, widened past the 15x12 the design draws. 25 is the dot PITCH —
   any wider and adjacent targets would overlap and swallow each other's taps,
   which is worse than a small target. 25x44 clears WCAG 2.5.8's 24x24. */
.carousel__pager-dot::after {
  content: "";
  position: absolute;
  inset-inline-start: 50%;
  inset-block-start: 50%;
  width: 25px;
  height: 44px;
  transform: translate(-50%, -50%);
}

@media (prefers-reduced-motion: reduce) {
  .carousel__track  { scroll-behavior: auto; }
  .carousel__cursor { transition: none; }
  .testimonials__cursor { transition: none; }
}


@media (min-width: 1025px) {
  /* ⚠️ THE SECTION BLEEDS ABOVE THE CAP — amended 2026-08-02, Vitaliy: "lets
     make this section to be full width not in layout grid".

     It used to carry `max-width: var(--ooc-container-max); margin-inline: auto`
     itself, which froze the GALLERY too, so on anything wider than 1440 the
     carousel was a 1440 island with page cream either side — the same defect
     he called on `statement` the same day. The design backs this one where it
     did not back statement's: `gallery` 100:6692 is x0 w1440, the FULL frame
     width, while the header 100:6688 is x20 w1400, the content column. The file
     itself draws the two differently.

     The half of the cap rule this does NOT relax: the content still freezes.
     `.carousel__header` and `.carousel__dots` are capped individually below,
     and the slide holds 1312 at every width >= 1440 because the track's padding
     absorbs the surplus. Uncapping without that would have inflated the slide
     to 1749x889 at 1920 and 2332x1186 at 2560 — an aspect canvas with no
     ceiling, which is exactly how the hero and the product grid ran away. */
  .carousel {
    padding-block: 240px 100px;
  }

  /* No desktop override for `.carousel__dots` any more. It used to re-solve
     aspect-ratio, background-position and background-size against the 1440
     frame; the supplied 1440x325 export carries all three itself and bleeds at
     its own proportion from 393 up. */

  /* Heading at the content-column left edge, lede flush right against it:
     1085 + 335 = 1420 = 1440 - 20. */
  .carousel__header {
    flex-direction: row;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0;
    margin-bottom: 80px;          /* heading bottom 340 -> gallery 420 */
    /* Carries the cap now that the section bleeds. `min()` keeps the 20px
       gutter below 1440 and the 1400 content column above it, in one
       expression: at 1440 both branches are 1400, so nothing moves at the
       reference width and the header stays x20 w1400. */
    margin-inline: auto;
    max-width: min(
      100% - 2 * var(--ooc-container-pad-lg),
      var(--ooc-container-max) - 2 * var(--ooc-container-pad-lg)
    );
    text-align: left;
  }
  .carousel__heading {
    width: 60.7857%;              /* 851 / 1400 */
    line-height: 1;               /* 100/100 exactly */
  }
  .carousel__copy {
    width: 23.9286%;              /* 335 / 1400 */
    /* 265 - 240: the lede sits a little below the heading's cap line. MARGIN,
       not padding — padding would leave the element's own box starting at 240
       and 89 tall, so its measured geometry would stop matching 100:6689's
       (265, 64) even with the text in exactly the right place. % of the
       header's content box, so it holds across the range. */
    margin-top: 1.7857%;          /* 25 / 1400 */
  }

  /* THE SURPLUS ABOVE 1440 GOES INTO THE PADDING, NOT THE SLIDE.

     The slide is `flex: 0 0 100%` of this content box, so whatever the padding
     does not take, the slide does. `max()` splits that at exactly the reference
     width without a media query:

       <=1440   4.4444% wins          fluid, the design's 64/1440 ramp, untouched
        =1440   both are 64           the branches MEET — nothing moves here
       >=1440   (100% - 1312px)/2     slide frozen at 1312, surplus -> peeks

     A `@media (min-width: 1441px)` would express the same thing today and let
     the two sides drift apart under a later edit with nothing failing — the
     silent split the composition cap exists to catch. This cannot drift: the
     branches are provably equal at 1440.

     Measured consequence at 1920: 304 padding a side, so the neighbours show
     284 each rather than 44 — which is what makes it read as a carousel on a
     wide display. The peek is `padding - gap`, so it was never a function of
     the section's width; freezing the slide is what buys it. */
  .carousel__track {
    padding-inline:              max(4.4444%, (100% - 1312px) / 2);
    scroll-padding-inline-start: max(4.4444%, (100% - 1312px) / 2);
  }

  .carousel__slide { aspect-ratio: 1312 / 667; }   /* 100:6693 */

  .carousel__photo { object-position: 50% 45.93%; }

  .carousel__card {
    inset-inline-start: 1.5244%;  /* 20 / 1312 */
    inset-block-end: 2.9985%;     /* 20 / 667  */
    width: 21.3415%;              /* 280 / 1312 */
    aspect-ratio: auto;           /* 100:6695 has no height — content drives it */
    padding-bottom: 1.5244%;      /* 20 / 1312 */
  }

  .carousel__media {
    aspect-ratio: 280 / 257;      /* 100:6696 */
    border-radius: 13.508px 13.508px 0 0;
  }

  .carousel__pattern {
    width: 119.2857%;             /* 334 / 280 */
    height: 100.7782%;            /* 259 / 257 */
    filter: blur(13.5px);
  }

  .carousel__cutout {
    inset-inline-start: 10%;      /* 28 / 280 */
    inset-block-start: 28.4047%;  /* 73 / 257 */
    width: 80.3571%;              /* 225 / 280 */
    height: 43.9689%;             /* 113 / 257 */
  }

  .carousel__meta { padding-top: 7.1429%; }   /* 20 / 280 */

  .carousel__title { letter-spacing: -1px; }  /* 100:6701 */

  /* 100:6702: gap 7 between every item, so the separator sits 7 from the word
     each side. Measured: Clean 0-40, sep 47-53, Buttery 60-110, sep 117-123,
     Zero waste 130-204 — and 204 is Frame 6's width. */
  .carousel__attrs {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 7px;
    list-style: none;
    margin: 0;
    padding: 0;
  }
  .carousel__attr {
    display: flex;
    align-items: center;
    gap: 7px;
    font-size: var(--ooc-body-s-size);
    font-weight: var(--ooc-weight-medium);
    line-height: 14px;
    white-space: nowrap;
  }
  /* 6x5 here against the product grid's 10x8 (100:6704 vs 228:3791). It is the
     SAME blob path scaled NON-uniformly — x by 10/6, y by 8/5 — so the shared
     token is reused but sized `100% 100%`. `contain`, which the product grid
     can use because its box matches the viewBox, would letterbox this one to
     6x4.8. */
  .carousel__attr + .carousel__attr::before {
    content: "";
    flex: none;
    width: 6px;
    height: 5px;
    background: currentColor;
    opacity: 0.2;
    -webkit-mask: var(--ooc-attr-sep) center / 100% 100% no-repeat;
    mask: var(--ooc-attr-sep) center / 100% 100% no-repeat;
  }

  /* The desktop frame draws no pager — the drag cue is its only affordance. */
  .carousel__pager { display: none; }
}

/* The custom cursor exists ONLY where there is a pointer to follow. Without
   this gate it would be a decoration that touch and keyboard users cannot use,
   and `cursor: none` would hide a real pointer for no gain. Scoped to `.js`
   because only the script can move it — set in header.php before first paint. */
@media (min-width: 1025px) and (hover: hover) and (pointer: fine) {
  .js .carousel__cursor { display: block; }
  .js .carousel__gallery.is-cursor-active .carousel__track { cursor: none; }
  /* The testimonials row carries the same cue (122:296) under the same gate. */
  .js .testimonials__cursor { display: block; }
  .js .testimonials__gallery.is-cursor-active .testimonials__track { cursor: none; }
}


/* == NUMBERED LIST ===========================================================
   Figma desktop `Frame 1978` 100:6744 (1400x904 at x20, y1287) — block B of
   `Frame 1975` 100:6686 · mobile `230:3947` (354x1253) inside `5` 228:3796.
   Mobile-first; the base block is the 393 design.
   Full node read: figma-specs/home/_nodes/menu-and-difference.md

   The frame's OTHER block is the carousel above. The 200 desktop / 100 mobile
   gap between them is split evenly, so this section opens with 100/50 and the
   carousel closes with the matching half.

   No background: the frame shows the page's #F2E6D5, proven by sampling an
   empty strip of the render at 100% modal share over 48000 pixels.

   ⚠️ TWO LAYOUTS, NOT ONE REFLOWED. Desktop lays each row out as four columns
   at fixed offsets (badge 0, title 118, body 592, photo 1183 of 1400) with the
   row's height set by the photograph. Mobile stacks them photo-first and the
   gaps change with it (40 / 20 / 10 rather than a single rhythm).
   ========================================================================== */
.numbered-list {
  padding-block: 50px 120px;      /* half the 100 gap to the carousel, then the
                                     frame's own 120 trailing space */
  padding-inline: var(--ooc-container-pad);
  color: var(--ooc-navy-blue);
}

.numbered-list__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100, exact at both references */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop is 100/100 */
  letter-spacing: -2px;
  width: 100%;
}

/* -- The list (230:3947 + its rule 230:3901 / 100:6746) --------------------
   The rule ABOVE the first row is a sibling of the rows, inside this wrapper,
   because that is how the desktop frame nests it (100:6747 is Frame 1977's
   first child) and the 40 gap after it is the same flex gap the rows use. */
.numbered-list__list {
  display: flex;
  flex-direction: column;
  gap: 40px;
  margin-top: 50px;               /* heading -> rule; desktop is 80 */
  width: 100%;
}

/* ⚠️ 10% NAVY, NOT SOLID. `opacity` is a NODE property on 100:6747 and on each
   row's rule — it never appears in a fills dump, which is exactly what hid the
   footer wordmark at 0.1 and the statement pattern at 8%. Its own element
   rather than a border so the opacity is something a probe can assert. */
.numbered-list__rule {
  width: 100%;
  height: 1px;
  background: var(--ooc-navy-blue);
  opacity: 0.1;
}

.numbered-list__items {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 40px;                      /* 431 pitch - 391 row, same at both widths */
}

.numbered-list__item {
  display: flex;
  flex-direction: column;
  gap: 40px;                      /* content -> its closing rule; desktop is 0,
                                     where the rule sits at the row's own 200 */
}

/* Mobile stacks photo-first with three different gaps (40 / 20 / 10), so the
   base gap is the smallest and the two larger ones are made up with margins. */
.numbered-list__row {
  display: flex;
  flex-direction: column;
  gap: 10px;                      /* title -> body (230:3932 itemSpacing) */
}

.numbered-list__media {
  order: -1;                      /* the photograph leads on mobile */
  /* ⚠️ FULL WIDTH — a DELIBERATE OVERRIDE of the mobile frame, not a match.
     `230:3905` draws this 217 wide in a 353 column (61.4731%), and it was built
     that way and gate-passed that way. Vitaliy 2026-08-05, from the live site
     on a phone: "What makes us different images should be full width" — the
     217 leaves a 136px void to its right that reads as a broken image on a
     narrow screen, whatever the frame says.

     The ASPECT RATIO is kept, so this is a scale rather than a re-crop: 353
     wide x 260.37 tall against the frame's 217 x 160. Nothing else in the row
     moves. The mobile spec is updated with the same note — the numbers there
     now record a decision rather than a reading, which is exactly the kind of
     drift FIGMA.md exists to keep visible. */
  width: 100%;
  aspect-ratio: 217 / 160;        /* 230:3905; identical at both widths */
  margin-bottom: 30px;            /* + the 10 gap = 40 to the badge */
  border-radius: 12px;
  /* `clip`, not `hidden` — never a scroll container. */
  overflow: clip;
}

.numbered-list__photo {
  width: 100%;
  height: 100%;
  /* The global img { max-width: 100% } would fight an oversize fill. */
  max-width: none;
  object-fit: cover;
  object-position: 50% 50%;
}

/* The badge (230:3907 / 100:6753). A mask so it takes a colour from CSS; the
   design fills it `light 2`. Sized 100% 100% and never `contain`, because
   77x61 -> 60x48 is a NON-uniform scale (x by 60/77, y by 48/61). */
.numbered-list__badge {
  flex: none;
  display: grid;
  place-items: center;
  width: 60px;                    /* 230:3908; desktop is 77 */
  height: 48px;                   /* 230:3908; desktop is 61 */
  /* The numeral sits BELOW the blob's centre — 17 from the top of 48 where
     centring would put it at 15. Padding rather than a nudge, so a two-digit
     number stays centred horizontally as the design has it. */
  padding-top: 4px;
  margin-bottom: 10px;            /* + the 10 gap = 20 to the title */
  background: var(--ooc-light-2);
  -webkit-mask: var(--ooc-badge-blob) center / 100% 100% no-repeat;
  mask: var(--ooc-badge-blob) center / 100% 100% no-repeat;
}

.numbered-list__number {
  font-size: 14px;                /* 230:3909; desktop is 18 */
  font-weight: var(--ooc-weight-semibold);
  line-height: 18px;              /* 18 at BOTH widths */
  letter-spacing: -0.28px;        /* 230:3909; desktop is -0.36 */
  color: var(--ooc-navy-blue);
}

.numbered-list__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 32px;                /* 32 at BOTH widths — not on the fluid ramp */
  font-weight: 400;
  line-height: 1;                 /* 32/32 solid at both */
  letter-spacing: -1px;
  /* The nodes carry `capitalize`: 100:6749's characters are "Direct sourcing"
     and it renders "Direct Sourcing". Presentational, so it lives here and the
     stored value stays sentence case. */
  text-transform: capitalize;
  width: 100%;
}

.numbered-list__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  width: 100%;
}


@media (min-width: 1025px) {
  .numbered-list {
    padding-block: 100px 200px;
    padding-inline: var(--ooc-container-pad-lg);
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }

  .numbered-list__heading { line-height: 1; }   /* 100/100 exactly */

  .numbered-list__list { margin-top: 80px; }    /* 1467 - 1387 */

  /* The rule sits at the row's own 200, with nothing between. */
  .numbered-list__item { gap: 0; }

  /* Four columns at the design's offsets. The 1fr is the 256 of empty space
     between the body and the photograph (118 + 474 + 335 + 256 + 217 = 1400);
     naming it as a track rather than a margin keeps every real column a plain
     percentage of the content box. */
  .numbered-list__row {
    display: grid;
    grid-template-columns:
      8.4286%      /* 118 / 1400 — the badge column */
      33.8571%     /* 474 / 1400 — title to the body's edge */
      23.9286%     /* 335 / 1400 — the body */
      1fr          /* 256 / 1400 — deliberate empty space */
      15.5%;       /* 217 / 1400 — the photograph */
    align-items: start;
    gap: 0;
    /* % of the content box, so the row stays proportional between the
       references instead of holding a 40px band while the photo shrinks. */
    padding-bottom: 2.8571%;      /* 40 / 1400 */
  }

  .numbered-list__badge {
    grid-column: 1;
    width: 77px;                  /* 100:6754 */
    height: 61px;
    padding-top: 1px;             /* numeral at 22 of 61, centring gives 21.5 */
    margin-bottom: 0;
  }
  .numbered-list__number {
    font-size: 18px;              /* 100:6755 */
    letter-spacing: -0.36px;
  }

  .numbered-list__title {
    grid-column: 2;
    margin-top: 15px;             /* 56 - 41 */
    white-space: nowrap;          /* 100:6749 — each title is one line here */
  }

  .numbered-list__body {
    grid-column: 3;
    margin-top: 15px;
  }

  .numbered-list__media {
    grid-column: 5;
    order: 0;
    width: 100%;                  /* the column IS the 217 */
    margin-bottom: 0;
  }
}


/* == COVERAGE ================================================================
   Figma desktop `1` 100:6824 (1400x1000, block A of `Frame 1976` 100:6823) ·
   mobile `1` 230:4214 (353x852) inside `Frame 1976` 230:4213.
   Mobile-first; the base block is the 393 design.

   `Frame 1976` is TWO blocks, like `Frame 1975` before it — this card and a
   newsletter panel 20 (desktop) / 10 (mobile) below it. The gap is split evenly
   and the newsletter carries the other half. The frame has NO padding of its
   own at either end: the card starts at its very top and the newsletter ends at
   its very bottom, hard against the footer.

   ⚠️ THE PANEL'S LAYOUT CHANGES, it does not reflow. At 393 the panel is a flow
   column (intro, then cities, 40 apart) with the arc floated behind them. At
   1440 it is a positioning context and all three sit at fixed offsets across a
   172-tall band. Same markup, two layouts.
   ========================================================================== */
/* ⚠️ #FFF9E9, NOT TRANSPARENT — fixed 2026-08-02, Vitaliy: "section coverage has
   a wrong background color should be same as chefs and footer". It shipped
   transparent, showing the page's #F2E6D5 through, and the spec agreed with it
   (`backgroundColor: rgba(0,0,0,0)`) so the gate scored 100% on a wrong colour.

   The design is unambiguous and this is a DEFECT, not a deviation. `Frame 1976`
   100:6823 is the parent of BOTH this section and the newsletter below it, and
   every point sampled from its render is #FFF9E9 at full opacity — the corners,
   the left gutter beside the coverage card, the gap between the two blocks, and
   the gutter beside the newsletter panel. The city page's own `Frame 1976`
   360:1640 samples identically, which is why this rule is unscoped: `coverage`
   appears on exactly those two pages and both sit on the cream frame.

   The band is therefore CONTINUOUS from `testimonials` through the footer —
   100:6776 samples #FFF9E9 too — where the build had a page-cream stripe
   between them. */
/* ⚠️ THE TOP PADDING IS A DERIVATION, ADDED 2026-08-07 — the file gives this
   section `padding-top: 0`, and that was CORRECT until testimonials was cut.

   In the design the order is `numbered-list` (transparent) -> `testimonials`
   (cream, 240 top / 240 bottom) -> `coverage` (cream, 0 top). Coverage needs no
   top padding there because 240px of testimonials' CREAM already sits above its
   photograph. Remove testimonials and coverage inherits a neighbour that is
   transparent, so the band above the image becomes the body's #F2E6D5 and the
   photograph butts against the very top of its own cream panel — which is what
   Vitaliy photographed: "after we removed testimonials we need to fix padding
   in the next section to match all other spacings."

   240px is not invented. It is what EVERY cream section in this file uses at
   the top — `products` 240/240 and `testimonials` 240/240 — and `products` is
   the design's own example of a cream section following a transparent one.

   ⚠️ This makes `coverage.desktop.json`'s `paddingTop: 0px` wrong, and that
   assertion is re-baselined with this reason rather than silently updated. The
   design's value was right for the design's neighbour; the neighbour is gone. */
.coverage {
  /* ⚠️ MOBILE KEEPS THE FILE'S 0, and that is not an oversight — see the
     desktop rule below. `HP Mobile` never contained a testimonials section at
     all, so on mobile `numbered-list` -> `coverage` is the arrangement the
     design itself draws, with no top padding. Only DESKTOP lost a neighbour. */
  padding-block: 0 5px;           /* half the 10 gap to the newsletter */
  padding-inline: var(--ooc-container-pad);
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
}

.coverage__card {
  position: relative;
  width: 100%;
  aspect-ratio: 353 / 852;        /* 230:4214; desktop is 1400/1000 */
  border-radius: 20px;
  /* `clip`, not `hidden` — this must never become a scroll container. */
  overflow: clip;
}

/* The photograph (230:4216 / 136:1307).
   ⚠️ ROTATED 90 IN THE FILE — a portrait 2734x4096 inside a `rotate-90`. The
   rotation is BAKED INTO THE ASSET: doing it here would need a counter-rotated
   box and would fight object-fit. And the box comes from get_design_context
   (left -161 / -21), never get_metadata, which reports the rotated origin
   corner (x=1202.34 in a 353-wide card, x=1586 in a 1400-wide one).
   The geometry is a % of the CARD, so the framing holds at every width. */
.coverage__photo {
  position: absolute;
  inset-inline-start: -45.6091%;  /* -161 / 353;  desktop  -21 / 1400 */
  inset-block-start: -4.6948%;    /*  -40 / 852;  desktop  -28 / 1000 */
  width: 386.2141%;               /* 1363.336 / 353;  desktop 1607 / 1400 */
  height: 106.8075%;              /*      910 / 852;  desktop 1073 / 1000 */
  /* The global img { max-width: 100% } would cancel an oversize fill. */
  max-width: none;
  object-fit: cover;
}

.coverage__panel {
  position: absolute;
  inset-inline-start: 2.8329%;    /*  10 / 353; desktop   20 / 1400 */
  inset-block-start: 58.5681%;    /* 499 / 852; desktop  808 / 1000 */
  width: 94.3343%;                /* 333 / 353; desktop 1360 / 1400 */
  height: 40.2582%;               /* 343 / 852; desktop  172 / 1000 */
  display: flex;
  flex-direction: column;
  gap: 40px;                      /* 230:4271 itemSpacing */
  padding: 24px 20px;             /* 230:4271 sits at (20, 24) */
  background: var(--ooc-light-2);
  border-radius: 12px;            /* 230:4217; desktop is 20 */
  /* The arc is drawn taller than the panel and the panel CLIPS it — that is
     the design, not an accident. */
  overflow: clip;
}

.coverage__intro {
  display: flex;
  flex-direction: column;
  gap: 8px;                       /* 230:4268; the same 8 on desktop (84 - 76) */
}

.coverage__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 32px;                /* 32 at BOTH widths — not on the fluid ramp */
  font-weight: 400;
  line-height: 36px;              /* 36/32, looser than the card titles' 32/32 */
  /* ⚠️ NO letter-spacing. Every other display heading in this theme is tracked
     (-1 or -2); 100:6839 and 230:4230 carry no tracking class at all. Read, not
     assumed by analogy. */
  letter-spacing: normal;
}

.coverage__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}

/* Decorative (230:4231 / 100:6840). Same aspect at both widths, so only the
   width changes; the panel clips whatever hangs below. */
.coverage__arc {
  position: absolute;
  inset-inline-start: 15.6156%;   /*  52 / 333; desktop 409.128 / 1360 */
  inset-block-start: 76.0550%;    /* 260.87 / 343; desktop 25.806 / 172 */
  width: 68.9075%;                /* 229.462 / 333; desktop 414.823 / 1360 */
  aspect-ratio: 414.823 / 288.194;
  background: url("../images/s7-arc.svg") 0 0 / 100% 100% no-repeat;
  pointer-events: none;
}

/* ONE list, columned by CSS — the design's two columns are layout, not content.
   A wrapping flex COLUMN rather than `columns`, because each column then widths
   to its own longest city (91 and 113 in the file) instead of being forced to a
   shared track width. The height is exactly four rows, which is what makes it
   break 4 + 4. */
.coverage__cities {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  align-content: flex-start;
  height: 92px;                   /* 4 rows of 20 + 3 gaps of 4 */
  row-gap: 4px;                   /* 230:4220 itemSpacing */
  column-gap: 40px;               /* 131 - 91; desktop is 145 */
}

.coverage__city {
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: 20px;
  white-space: nowrap;
}


@media (min-width: 1025px) {
  /* ⚠️ THE SECTION BLEEDS NOW THAT IT HAS A FILL (2026-08-02). It carried
     `max-width` + `margin-inline: auto` itself, which was invisible while the
     background was transparent and becomes the "1440-wide island with page
     cream either side" defect the moment it is painted — the same thing Vitaliy
     called on `statement` and on `carousel`. `Frame 1976` is x0 w1440, a fill
     the design draws edge to edge, so it qualifies on the §4 test.

     The CONTENT still freezes: `.coverage__card` carries the cap instead, which
     is the shape `products` / `testimonials` / `site-footer` already use. At
     1440 the card is viewport − 40 = 1400 either way, so nothing moves at the
     reference width. */
  /* ⚠️ 240 TOP IS A DERIVATION, ADDED 2026-08-07, AND DESKTOP-ONLY.
     Vitaliy: "after we removed testimonials we need to fix padding in the next
     section to match all other spacings."

     The file gives this section `padding-top: 0`, which was correct while
     `testimonials` sat above it — 240px of testimonials' CREAM led into the
     photograph. Cutting that section left `numbered-list` above, which is
     TRANSPARENT, so the band above the image became the body's #F2E6D5 and the
     photograph butted against the very top of its own cream panel.

     240 is read, not chosen: it is the top padding of every cream section in
     this file — `products` 240/240 and the removed `testimonials` 240/240 — and
     `products` is the design's own case of a cream section following a
     transparent one.

     ⚠️ MOBILE IS DELIBERATELY NOT CHANGED. `HP Mobile` has no testimonials
     section, so there this pairing is what the design draws, and its 0 is
     correct. Applying 240 to both would have "fixed" a breakpoint that was
     never broken — the base rule above says so too. */
  .coverage {
    padding-block: 240px 10px;
    padding-inline: var(--ooc-container-pad-lg);
  }

  .coverage__card {
    aspect-ratio: 1400 / 1000;   /* 100:6824 */
    max-width: 1400px;           /* the CONTENT COLUMN, not the 1440 frame */
    margin-inline: auto;
  }

  .coverage__photo {
    inset-inline-start: -1.5%;    /*  -21 / 1400 */
    inset-block-start: -2.8%;     /*  -28 / 1000 */
    width: 114.7857%;             /* 1607 / 1400 */
    height: 107.3%;               /* 1073 / 1000 */
  }

  /* The panel stops being a column and becomes a canvas. */
  .coverage__panel {
    inset-inline-start: 1.4286%;  /*   20 / 1400 */
    inset-block-start: 80.8%;     /*  808 / 1000 */
    width: 97.1429%;              /* 1360 / 1400 */
    height: 17.2%;                /*  172 / 1000 */
    display: block;
    padding: 0;
    border-radius: 20px;
  }

  .coverage__intro {
    position: absolute;
    inset-inline-start: 2.9412%;  /*  40 / 1360 */
    inset-block-start: 23.2558%;  /*  40 / 172  */
    width: 26.0294%;              /* 354 / 1360 — 100:6839's own width */
  }

  .coverage__cities {
    position: absolute;
    inset-inline-start: 68.1618%; /* 927 / 1360 */
    inset-block-start: 23.2558%;  /*  40 / 172  */
    column-gap: 145px;            /* 1163 - (927 + 91): the file's own pitch */
  }

  .coverage__arc {
    inset-inline-start: 30.0829%; /* 409.128 / 1360 */
    inset-block-start: 15.0035%;  /*  25.806 / 172  */
    width: 30.5017%;              /* 414.823 / 1360 */
  }
}


/* == NEWSLETTER ==============================================================
   Figma desktop `Frame 1981` 100:6850 (1400x536, block B of `Frame 1976`
   100:6823) · mobile `Frame 1981` 230:4241 (353x400).
   Mobile-first; the base block is the 393 design.

   The last section on the page: `Frame 1976` has no trailing space of its own,
   so this panel sits hard against the footer.
   ========================================================================== */
.newsletter {
  /* NO top padding by default. `pre-footer` 210:2011 and 230:6342 both start
     their panel at top 0; only the HOME page inset it, and there only because
     half the 20px gap up to the `coverage` card above belongs to this section.
     That is a boundary between two sections, not a property of this one.

     It was written the other way round — 5/10 here with `.page-products`
     opting out — which meant every new inner page had to remember to opt out
     too. The About page did not, and the gate caught it as six failures
     (section 546 vs 536, panel y 10 vs 0, dots -159 vs -169, inner 148 vs 138).
     Inverted so the exception is the page that actually has one. */
  padding-block: 0;
  padding-inline: var(--ooc-container-pad);
}
/* The 20px gap between the coverage card and the newsletter panel is split
   10/10: `.coverage` carries the bottom half unscoped, and the top half is
   scoped to the pages that actually put a newsletter under a coverage card.
   `Frame 1976` is the same shape on both — home's 100:6823 and a city page's
   360:1640 are each 1556 = 1010 + 546. */
/* THE BACKGROUND IS NOT SCOPED — every pre-footer in the file carries #FFF9E9.
   ⚠️ It WAS scoped to home + city until 2026-08-03, on the stated grounds that
   "on the others (`pre-footer` 210:2011 / 230:6342) it sits on the page's own
   #F2E6D5". That was an assumption, not a reading, and it was wrong. Each node
   rendered in isolation and sampled in a corner clear of content:

     services       230:6326   #fff9e9  alpha 255  100% of 168px
     products       210:2011   #fff9e9  alpha 255  100%
     about          230:6342   #fff9e9  alpha 255  100%
     product-detail 223:1537   #fff9e9  alpha 255  100%   (had no recorded node)

   all three sample points per node, plus a vertical scan of the whole
   `Services & Logistics V2` frame showing an unbroken #fff9e9 from y8850 through
   y9700 — arc-callouts, the pre-footer and the footer are one continuous cream
   band. We were painting a 536px #F2E6D5 stripe across the middle of it.

   This is the SECOND time this exact section shipped the wrong background: on
   2026-08-02 `coverage` and `newsletter` were transparent where `Frame 1976`
   samples #FFF9E9, and its spec recorded `rgba(0,0,0,0)` so it could never fail.
   Both times the cause was reasoning by analogy about a fill instead of reading
   it. Read every frame's own fill — see the skill's group K. */
.newsletter { background: var(--ooc-light-2); }

.page-home .newsletter,
.page-new-york-delivery .newsletter { padding-top: 5px; }   /* desktop 10 */

.newsletter__panel {
  position: relative;
  width: 100%;
  aspect-ratio: 353 / 400;        /* 230:4241; desktop is 1400/536 */
  background: var(--ooc-navy-blue);
  border-radius: 20px;
  /* `clip`, not `hidden` — never a scroll container. */
  overflow: clip;
  color: var(--ooc-light-2);
}

/* The dot field (230:4242 / 100:6851).
   ⚠️ A DIFFERENT ASSET from `s3-dots.webp`, which is the navy-on-cream field
   the collage and the carousel use. This one is light, for a navy panel: mean
   RGB 10.83/14.1/12.52 against that one's 0.13/3.69/10.88, and a 4-channel
   diff of 17.74 between them. Reusing the shared token here would have been
   invisible in a fills dump and wrong on screen.
   The image is STRETCHED to the box in the file (`size-full`, no object-fit),
   so this is a plain 100% 100% background with no position arithmetic — unlike
   the carousel's band, which windows a much larger fill.
   opacity 1 because the export already carries the node's 10% — see the note
   on .carousel__pattern. */
.newsletter__dots {
  position: absolute;
  inset-inline-start: -100.2833%; /* -354 / 353; desktop 0 */
  inset-block-start: -23.25%;     /*  -93 / 400; desktop -169 / 536 */
  width: 272.5212%;               /*  962 / 353; desktop 1440 / 1400 */
  height: 136.5%;                 /*  546 / 400; desktop  816 /  536 */
  background: url("../images/s7-dots-light.webp") 0 0 / 100% 100% no-repeat;
  filter: blur(20px);             /* 20 at BOTH widths, not on the fluid ramp */
  pointer-events: none;
}

/* `360:1684` — the city variant's control: 220x48, radius 4, the same light-2
   fill, the same 20px inset and the same arrow as the subscribe button, which
   is why it reuses .newsletter__submit-label and the shared icon. */
.newsletter__cta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 220px;
  height: 48px;
  padding-inline: 20px;
  border-radius: 4px;
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  text-decoration: none;
}
.newsletter__cta--inert { cursor: default; }

/* ⚠️ THE CTA'S LABEL IS VISIBLE AT BOTH WIDTHS, and the shared one is not.
   `.newsletter__submit-label` is clipped to 1px because the home panel's
   subscribe button draws the arrow alone at 393 — but `360:1685` draws
   "Request a Price" in full, and a 220px button with no words in it is not the
   same control. Un-hidden here and given its own type: 16/16 at -0.32px in
   Medium, which the shared label never sets because it is never seen.
   Measured before the fix: 400 weight, 20 line-height, `normal` tracking —
   the panel's body styles showing through a suppressed element. */
.newsletter__cta .newsletter__submit-label {
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip-path: none;
  font-size: 16px;
  font-weight: var(--ooc-weight-medium);
  line-height: 16px;
  letter-spacing: -0.32px;
  color: var(--ooc-navy-blue);
  text-align: center;
}

.newsletter__inner {
  position: absolute;
  inset-inline-start: 10.4816%;   /*  37 / 353; desktop  289 / 1400 */
  /* ⚠️ CENTRED, NOT top-anchored. 230:6345 says `top: calc(50% - 10px)` with a
     -50% translate, and the 110 that home and products measure is simply what
     centring produces for THEIR inner height — it is not a spec.

       home / products  inner 296 (200 heading + 40 + 56 form) -> 268-10-148 = 110
       about            inner 240 (144 heading + 40 + 56 form) -> 268-10-120 = 138
       mobile           inner 200, panel 400, no -10 nudge     -> 200-100   = 100

     Encoded as `inset-block-start: 20.5224%  (110/536)` it reproduced the two
     pages that existed when it was written and would have put About's shorter
     block 28px high — with every existing probe still passing, because 110 is
     what they assert. Same failure as the hero's `top: N%` for `bottom: 20`.
     A comment showing a division is the smell. */
  inset-block-start: 50%;
  transform: translateY(-50%);
  width: 79.3201%;                /* 280 / 353; desktop  851 / 1400 */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40px;                      /* 230:4243 itemSpacing */
}

/* Heading + optional subline: one block with its own 24px gap (230:6346),
   nested inside the inner's 40 rather than flattened into it. */
.newsletter__headings {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
  width: 100%;
}

/* About only — 230:6348, Suisse Book 14/20 centred under the heading. */
.newsletter__subheading {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  text-align: center;
}

.newsletter__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100, exact at both references */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop is 100/100 */
  letter-spacing: -2px;
  color: var(--ooc-light-2);
  text-align: center;
  width: 100%;
}

/* The capture pill (230:4245 / 100:19636).
   OUTLINE, not border, and for the same reason as the product card: the file's
   frame is 56 tall with 4 + 48 + 4 of content, so its stroke does not expand
   the box. A CSS border would make it 58 and push the submit off-centre. */
.newsletter__form {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;                    /* desktop caps it at 420 of the 851 inner */
  padding: 4px 4px 4px 20px;
  outline: 1px solid #fff;        /* 100:19636 — white, NOT the panel's cream */
  outline-offset: -1px;
  border-radius: 6px;
}

.newsletter__input {
  flex: 1;
  /* Without this a long value makes the flex item refuse to shrink and the
     submit gets pushed out of the pill. */
  min-width: 0;
  /* Stretched to the pill's 48 rather than left at its 16 of text, so the
     click target is the whole height the design draws. */
  align-self: stretch;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--ooc-light-2);
  font-family: inherit;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-book);
  line-height: 16px;
  letter-spacing: -0.32px;
}
.newsletter__input::placeholder {
  color: var(--ooc-light-2);
  /* Firefox dims placeholders by default; the design's is full strength. */
  opacity: 1;
}
/* ── The form-result toast ────────────────────────────────────────────────
   main.js moves the server-rendered `role="status"` paragraph in here. There
   is no Figma frame for this — the file draws no result state for either form
   (recorded in FIGMA.md for the city page's coverage block) — so it is a
   Hard-rule-2 derivation built ONLY from tokens the design system already
   defines: the navy panel, the cream text, the 12px radius the data-table
   panel uses, and the 20px container pad.

   Fixed to the BOTTOM. The redirect carries a `#newsletter` / `#contact-form`
   anchor, so the page lands scrolled to the form; a top-anchored toast would
   compete with the sticky header, and on a phone the bottom is also nearer the
   thumb that just pressed submit.

   `.ooc-toast` is created by script, so nothing here applies without JS and the
   paragraph keeps its original inline styling. */
.ooc-toast {
  position: fixed;
  z-index: 1000;
  left: var(--ooc-container-pad);
  right: var(--ooc-container-pad);
  bottom: var(--ooc-container-pad);
  margin-inline: auto;
  max-width: 420px;
  display: flex;
  align-items: flex-start;
  gap: 16px;
  padding: 20px;
  border-radius: 12px;
  background: var(--ooc-navy-blue);
  color: var(--ooc-light-2);
  box-shadow: 0 8px 32px rgba(0, 39, 101, 0.28);
  /* Starts hidden and OFF the pointer, so the shell cannot swallow a tap in the
     frame between being appended and being revealed. */
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
}
.ooc-toast.is-visible {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* The moved paragraph. Its own section-scoped rules still apply — the
   newsletter's is centred cream, the contact form's is inherited body-S — so
   both are normalised here to the toast's own type rather than left to
   whichever section happened to render them. */
.ooc-toast .newsletter__status,
.ooc-toast .contact-cards__status {
  margin: 0;
  flex: 1;
  text-align: left;
  color: var(--ooc-light-2);
  font-size: var(--ooc-body-m-size);
  line-height: var(--ooc-body-m-lh);
  font-weight: var(--ooc-weight-medium);
}

.ooc-toast__close {
  flex: none;
  /* 44x44 is the tap-target floor; the glyph is smaller, the target is not. */
  width: 44px;
  height: 44px;
  margin: -12px -8px -12px 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: 4px;
  background: transparent;
  color: var(--ooc-light-2);
  font-size: 24px;
  line-height: 1;
  cursor: pointer;
}
.ooc-toast__close:focus-visible {
  outline: 2px solid var(--ooc-light-2);
  outline-offset: 2px;
}

/* An error keeps the same shell and swaps the fill, so the two outcomes are
   never told apart by wording alone. */
.ooc-toast--invalid,
.ooc-toast--error { background: #7A1B1B; }

@media (prefers-reduced-motion: reduce) {
  .ooc-toast { transition: none; transform: none; }
}

/* ⚠️ THE FOCUS RING GOES ON THE PILL, NOT ON THE INPUT INSIDE IT.
   Vitaliy, from an iPhone, 2026-08-06: "when i use email subscribtion box i see
   weird selection arround input is that a bug?" — a fair reading of what it
   looked like. It was this rule: a 2px CREAM outline at +2px offset drawn round
   an input that already sits inside a pill with its own 1px white outline. Two
   concentric rings 2px apart, the inner one bleeding over the outer, on navy.

   It reads as a selection artefact on touch because a text input is
   `:focus-visible` whenever it is focused — browsers assume typing follows — so
   TAPPING the field draws the full keyboard focus ring, which the design never
   anticipated on a phone.

   Removing it is not an option: a control with no visible focus is a real
   keyboard-accessibility failure. So the indicator moves to the element that is
   visually the control — the pill — and thickens the outline it already has
   rather than adding a second one. One ring, no bleed, and `:focus-within`
   covers the submit button as well, which had no indicator at all. */
.newsletter__form:focus-within {
  outline-width: 2px;
}
/* The input keeps a ring only where the pill cannot show one — i.e. never, as
   long as the input is inside the form. Left explicit so a future refactor that
   moves the input out does not silently ship an unfocusable-looking field. */
.newsletter__form:focus-within .newsletter__input:focus-visible {
  outline: none;
}

.newsletter__submit {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;        /* desktop spreads label and icon apart */
  width: 60px;                    /* 230:4247; desktop is 160 */
  height: 48px;
  padding: 0;                     /* desktop adds the design's 20 */
  border: 0;
  border-radius: 4px;
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  font-family: inherit;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-medium);
  line-height: 16px;
  letter-spacing: -0.32px;
  cursor: pointer;
}

/* ⚠️ HIDDEN, NOT ABSENT. 230:4247 draws the arrow alone, but a button whose
   accessible name is a glyph tells a screen-reader user nothing — so the label
   stays in the DOM and is only visually suppressed. Same technique as
   .screen-reader-text, applied at one breakpoint. */
.newsletter__submit-label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.newsletter__submit-icon {
  flex: none;
  width: 30.363px;                /* 230:4249 / 100:19640 */
  height: 24px;
}

/* Unconfigured: the design's control, drawn but not operable. */
.newsletter__form--inert { cursor: default; }
.newsletter__form--inert .newsletter__input {
  display: flex;
  align-items: center;
}
.newsletter__form--inert .newsletter__submit { cursor: default; }

/* The honeypot. Off-screen rather than display:none — some bots skip anything
   a stylesheet has hidden outright. */
.newsletter__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.newsletter__status {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  color: var(--ooc-light-2);
  text-align: center;
}


@media (min-width: 1025px) {
  /* THE PANEL'S DOT FIELD — `230:6328` inside `Frame 1981`, which CLIPS it, so
     what is visible is exactly the panel's 1400x536 box. Vitaliy supplied that
     composited result on 2026-08-04, so the element is simply the panel box and
     the asset fills it.

     ⚠️ NO `filter: blur()`. This used to stretch the generic `s7-dots-light`
     tile over a 1440x816 box at y-169 and blur it 20px at runtime to
     reconstruct the clip. The export carries its own blur and alpha; keeping
     the filter would composite twice and costs a full-section repaint besides.

     ⚠️ DESKTOP ONLY. The mobile rule below places a much larger box at
     (-354,-93) against a 353x400 panel — the mobile design's own arrangement,
     which this 1400x536 desktop crop cannot express. Left untouched. */
  .page-home .newsletter,
  .page-new-york-delivery .newsletter { padding-top: 10px; }
  /* Bleeds for the same reason as `.coverage` above — on home and the city page
     it sits inside `Frame 1976`'s #FFF9E9 fill, and a capped section would paint
     a 1440 island. On the other four pages the section is transparent, so
     bleeding it there is invisible and changes no composition. The panel keeps
     the cap either way. */
  .newsletter {
    padding-block: 0;
    padding-inline: var(--ooc-container-pad-lg);
  }

  .newsletter__panel {
    aspect-ratio: 1400 / 536;    /* 100:6850 */
    max-width: 1400px;
    margin-inline: auto;
  }

  /* THE PANEL'S DOT FIELD. `230:6328` is 1440x816 at y-169, but it lives INSIDE
     `Frame 1981`, which CLIPS it — so what the file actually shows is exactly
     the panel's 1400x536 box. Vitaliy supplied that composited result on
     2026-08-04, so the element is simply the panel box and the asset fills it,
     instead of reconstructing the clip from an oversized blurred tile.

     ⚠️ NO `filter: blur(20px)`. The old rule stretched the generic
     `s7-dots-light` tile over the full 1440x816 and blurred it at runtime; this
     export carries its own blur and alpha, so keeping the filter would
     composite twice — and blurring a 1440x816 layer on every paint is not free.

     ⚠️ DESKTOP ONLY: the base rule places a 962x546 box at (-354,-93) against
     the 353x400 mobile panel, the mobile design's own arrangement, which this
     desktop crop cannot express. Untouched. */
  .newsletter__dots {
    inset-inline-start: 0;
    inset-block-start: 0;
    width: 100%;
    height: 100%;
    background: url("../images/newsletter-dots.png") 0 0 / 100% 100% no-repeat;
    filter: none;
  }

  .newsletter__inner {
    inset-inline-start: 20.6429%; /* 289 / 1400 */
    /* The desktop node nudges the centre 10px up (230:6345 is
       `top: calc(50% - 10px)`); mobile centres exactly. No top value here —
       the block places itself from its own height. */
    inset-block-start: calc(50% - 10px);
    width: 60.7857%;              /* 851 / 1400 */
  }

  .newsletter__heading { line-height: 1; }           /* 100/100 exactly */

  .newsletter__form { width: 49.3537%; }             /* 420 / 851 */

  .newsletter__submit {
    width: 160px;                 /* 100:19638 */
    justify-content: space-between;
    padding-inline: 20px;
  }

  /* The label comes back — 100:19638 draws it beside the arrow. */
  .newsletter__submit-label {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip-path: none;
  }
}


/* == PAGE HEADING ============================================================
   Figma desktop `Frame 5` 234:6996 / text 234:6997, inside Contact's body
   `6` 234:6417 (which itself starts at page y80, i.e. exactly under the nav —
   `body.nav-light main` already supplies that 80px, so this section's own
   padding starts from the body frame's origin, not the page's).

   851 wide, centred at x295 (centre 720.5 = 1440/2), y160 within `6`, 200 tall.

   ⚠️ MOBILE IS DERIVED, NOT READ (Hard rule 2). No inner page has a mobile
   artboard. Derived from `HP Mobile`'s own heading language: the block becomes
   the 353 content column (every mobile section in the file does), the type
   rides the already-measured h2 ramp (100 → 50, docs/mobile-first.md §2), and
   the 160/100 vertical rhythm halves to 80/50 — the same halving the statement
   and products intros use between their two frames.

   BOUNDARY: the 100px gap down to `forms` (heading ends 360, forms starts 460)
   is SPLIT EVENLY — 50 here, 50 on contact-cards — so each section carries its
   own breathing room and marketing can reorder or drop either without the
   other collapsing against its neighbour. Same call as carousel/numbered-list.
   ========================================================================== */
.page-heading {
  padding-block: 80px 50px;
  padding-inline: var(--ooc-container-pad);
  color: var(--ooc-navy-blue);
}

.page-heading__title {
  margin: 0;
  /* 851 centred on desktop; the full content column on mobile. Capped rather
     than gutter-bound, exactly like .statement__inner and .products__intro —
     the block is NARROWER than the content column, so a max-width plus auto
     margins is the shape, not padding. */
  max-width: 851px;
  margin-inline: auto;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 → 100, exact at both references */
  font-weight: 400;
  line-height: 1;                   /* 234:6997 lineHeight PIXELS 100 at 100 */
  letter-spacing: -2px;
  text-align: center;
}

@media (min-width: 1025px) {
  .page-heading {
    padding-block: 160px 50px;      /* Frame 5 y160 within `6` */
    padding-inline: var(--ooc-container-pad-lg);
    /* DESKTOP COMPOSITION CAP (§4). No fill, so capping is invisible apart from
       the alignment it buys. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }
}


/* == CONTACT CARDS ===========================================================
   Figma desktop `forms` 234:7746 — 1400 wide at x20/y460 within `6`, gap 20,
   two `flex-[1_0_0]` cards of 690 each. Form 234:7747, details 234:7786.

   ⚠️ THE TWO CARDS ARE DIFFERENT HEIGHTS — 896 and 545, and the row is
   `items-start`, so they TOP-align and the shorter one leaves 351px of page
   colour beneath it. That is the design, not a bug to "fix" by stretching them.
   It is also the exact case the fidelity skill flags for contact pages: the
   section box is the TALLER column, so the footer can butt the shorter one.
   Measure `contentBottomToFooter`, never `section.bottom`.

   ⚠️ MOBILE IS DERIVED, NOT READ (Hard rule 2). The cards stack to the 353
   content column and the padding collapses to a single 20 — the design's own
   mobile rhythm unit, which every `HP Mobile` section insets by. The desktop
   40-vs-32 split exists to balance two different content densities inside a
   690 card; stacked at full column width that distinction stops paying for
   itself, so one value is the honest derivation rather than two scaled ones.
   The radius stays 20 at both widths, as the newsletter panel's does.
   ========================================================================== */
.contact-cards {
  padding-block: 50px 100px;        /* 50 = the other half of the 100 gap above */
  padding-inline: var(--ooc-container-pad);
  color: var(--ooc-navy-blue);
}

/* GRID, not flex, and that is a correctness choice rather than a preference.
   The node says `flex-[1_0_0]` on both cards, which reads as "translate to
   flex: 1 1 0" — and that measured 698 / 682 instead of 690 / 690. With
   box-sizing: border-box a zero flex-basis floors at the item's own padding, so
   the two cards' DIFFERENT paddings (40 vs 32) each survive the split and the
   16px difference lands entirely in the widths: 1400 − 20 − 80 − 64 = 1236,
   split 618/618, plus each card's own padding. Figma's auto-layout has no such
   coupling, which is why the file shows 690/690.

   `1fr` sizes the TRACK, so padding cannot leak into it. min-width: 0 on the
   card keeps a long unbroken value (the 64px email) from blowing the track
   past its share. */
.contact-cards__row {
  display: grid;
  gap: 20px;                        /* 234:7746 itemSpacing, same at both */
}

.contact-cards__card {
  min-width: 0;
  padding: 20px;
  border-radius: 20px;
}

.contact-cards__card--form    { background: var(--ooc-white); }
.contact-cards__card--details {
  background: var(--ooc-navy-blue);
  display: flex;
  flex-direction: column;
  gap: 40px;                        /* desktop 80; see the media query */
}

/* -- Card headings (234:7752 / 234:7791) ---------------------------------- */
.contact-cards__heading {
  margin: 0 0 40px;                 /* Frame 126: top ends 48, fields start 88 */
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h4);   /* 40 → 48 */
  font-weight: 400;
  line-height: 1;                   /* 48/48 at desktop */
  letter-spacing: -1px;
}
/* The details heading is #FFFFFF — NOT the #FFF9E9 its own sibling values use.
   Two different creams in one card; see the token note in §1. */
.contact-cards__heading--invert { color: var(--ooc-white); margin-bottom: 0; }

/* -- The form (234:7754) --------------------------------------------------- */
.contact-cards__form {
  display: flex;
  flex-direction: column;
  gap: 24px;                        /* 234:7754 itemSpacing */
}

.contact-cards__field {
  display: flex;
  flex-direction: column;
  gap: 12px;                        /* 234:7879 itemSpacing */
  margin: 0;
}
/* Message alone uses 8, not 12 — 234:7762 label ends 20, Text Area starts 28.
   ⚠️ KEYED OFF THE MODIFIER, not `:last-of-type`. As `:last-of-type` this rule
   matched only while the message `<p>` was the last `<p>` in the form; the live
   branch renders a honeypot `<p>` after it, the rule stopped applying, and the
   gap silently reverted to 12 — measured +4px on the field, the card (896→900)
   and the section (1146→1150). It survived undetected because the live branch
   could not render until 2026-08-05, so the gate had only ever seen the inert
   form. See the note in contact-cards.php, and `--contacts` at line ~1424 where
   this same trap is already documented. */
.contact-cards__field--message { gap: 8px; }

.contact-cards__label {
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
  color: var(--ooc-navy-blue);
}

.contact-cards__control {
  width: 100%;
  min-height: 52px;                 /* 234:7885 h=52 — also clears the 44 tap
                                       target, so never shrink it */
  padding: 12px;                    /* `p-[var(--space/2,12px)]` */
  /* NO fill. The node has no background: the card's #FFFFFF shows through, and
     only the hairline describes the box. Probed at 100% modal #ffffff. */
  background: transparent;
  border: 1px solid var(--ooc-hairline);
  border-radius: 6px;               /* 234:7885 */
  font-family: var(--ooc-font-sans);
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  color: var(--ooc-navy-blue);
  /* Safari gives inputs a system font and its own radius unless told not to. */
  appearance: none;
}
.contact-cards__control--area {
  min-height: 180px;                /* 234:7764 h=180 */
  border-radius: 8px;               /* 8 here, 6 on the inputs — really */
  resize: vertical;                 /* the node draws the native grip */
}
/* 234:7764's placeholder sits at opacity 0.5. The four inputs above it have no
   VISIBLE placeholder at all (theirs is at opacity 0), so this deliberately
   does not target them — they ship with no placeholder attribute. */
.contact-cards__control--area::placeholder {
  color: var(--ooc-navy-blue);
  opacity: 0.5;
}
/* Inert mode must not look broken — the design has no disabled state, so it
   keeps the enabled paint and only stops accepting input. */
.contact-cards__control:disabled { opacity: 1; cursor: not-allowed; }

/* -- Submit (235:509) ------------------------------------------------------
   Not the shared .ooc-button geometry: this one is 160x48 with a 4px radius,
   where the header CTA is auto-width at 6px. Only the paint is shared, which is
   why the markup carries .ooc-button--primary and this overrides the box. */
.contact-cards__submit {
  align-self: flex-start;
  width: 160px;
  min-height: 48px;
  border-radius: 4px;               /* 235:509 — 4, not the button default 6 */
  padding-inline: 20px;
  justify-content: space-between;
  gap: 0;
  /* 16, NOT 40, and the arithmetic is the point. The design's 40 (Frame 126
     ends 728, button starts 768) is a gap at the OUTER level — Frame 2019 holds
     [fields, button] at gap 40 while Frame 123 holds the fields at gap 24. Our
     markup flattens both into one flex column, so the button already inherits
     the 24 and only needs the remaining 16. Setting 40 here measured the card
     at 920 against the node's 896 — exactly the 24 counted twice. */
  margin-top: 16px;                 /* 24 (inherited gap) + 16 = the node's 40 */
}
.contact-cards__submit-label {
  font-weight: var(--ooc-weight-medium);
  line-height: 16px;                /* 235:510 lineHeight PIXELS 16 */
  letter-spacing: -0.32px;
}
.contact-cards__submit-icon {
  flex: none;
  width: 30.363px;                  /* 235:511, the same glyph the products and
                                       collage CTAs use */
  height: 24px;
}
span.contact-cards__submit { cursor: default; }

/* Off-screen, not display:none — some bots skip hidden fields, and some
   assistive tech announces them. aria-hidden + tabindex -1 in the markup keeps
   it away from real users; this only removes it from the layout. */
.contact-cards__honeypot {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.contact-cards__status {
  margin: 16px 0 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* -- The details card (234:7786) ------------------------------------------ */
.contact-cards__details-top {
  display: flex;
  flex-direction: column;
  gap: 12px;                        /* 234:7790 itemSpacing */
}
.contact-cards__lede {
  margin: 0;
  max-width: 640px;                 /* 234:7792 */
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  color: var(--ooc-white);
}

/* Its own element, not a border: 234:7793 is a 1px node whose NODE opacity is
   0.2 while its fill is solid #FFF9E9. As a border the opacity would have to be
   baked into the colour, and the value would stop matching the node. */
.contact-cards__rule {
  display: block;
  width: 100%;
  height: 1px;
  background: var(--ooc-light-2);
  opacity: 0.2;
}

.contact-cards__details {
  display: flex;
  flex-direction: column;
  gap: 40px;                        /* 234:7953 itemSpacing */
  margin: 0;
}
.contact-cards__detail {
  display: flex;
  flex-direction: column;
  gap: 12px;                        /* 234:7942 itemSpacing */
}
.contact-cards__detail-label {
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
  color: var(--ooc-white);
}
.contact-cards__detail-value {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-detail);  /* 32 → 64; mobile end DERIVED, see §1 */
  font-weight: 400;
  line-height: 1.0625;              /* 68/64 — kept as a ratio so it rides the ramp */
  /* −0.64px at 64 is −1 PERCENT, the h3 token's one non-px tracking value.
     Written in em so it stays −1% at every size on the ramp; as px it would
     drift 0.36px per character at the mobile end. */
  letter-spacing: -0.01em;
  color: var(--ooc-light-2);        /* #FFF9E9 — NOT the #FFFFFF above it */
  /* A 22-character email at 64px will not fit 626 in every font; the design's
     own line does. Break rather than overflow the card if the real string grows
     or the body font changes. */
  overflow-wrap: anywhere;
}
.contact-cards__detail-value a {
  color: inherit;
  text-decoration: none;
}
.contact-cards__detail-value a:hover { text-decoration: underline; }


@media (min-width: 1025px) {
  .contact-cards {
    padding-block: 50px 200px;      /* forms ends 1356, `6` ends 1556 */
    padding-inline: var(--ooc-container-pad-lg);
    /* DESKTOP COMPOSITION CAP (§4). No fill on the section — the cards carry
       their own — so capping only buys alignment with the heading above. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }

  /* THE ROW. `align-items: start`, so the 545 card does NOT stretch to the 896
     one — the height difference is the design (`items-start` on 234:7746). */
  .contact-cards__row {
    grid-template-columns: 1fr 1fr;
    align-items: start;
  }

  .contact-cards__card--form    { padding: 40px; }   /* 235:515 inset */
  .contact-cards__card--details { padding: 32px; gap: 80px; }

  .contact-cards__heading { font-size: 48px; line-height: 1; }
}


/* == INTRO ===================================================================
   Figma `Frame 5` 230:5741 / `Frame 6` 230:6204 (About 230:5104).
   A centred display heading over a 400-wide lede + body.

   Mobile-first; the base block is the 393 derivation and
   @media (min-width: 1025px) is the 1440 enhancement.

   ⚠️ MOBILE IS DERIVED — no inner page has a mobile artboard. The ratios are
   the design's OWN, taken from the home page where both widths exist:
   section padding scales 0.5 (240 -> 120) and inner gaps scale 0.8 (40 -> 32).
   So 212/100 -> 106/50 and gap 60 -> 48. Nothing here is freestyled.         */
.intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-block: 106px 50px;      /* desktop 212 / 100 */
  padding-inline: var(--ooc-container-pad);
  gap: 48px;                      /* desktop 60 */
  color: var(--ooc-navy-blue);
  text-align: center;
}

/* The dot band, `pattern 1` 230:5164 — 1440x629 across the top of a page body.
   Shares `section-pattern.png` with the products page via multiply, so ONE
   asset serves both fills; see the note above .page-products .products.

   ⚠️ background-blend-mode blends with this element's OWN background-color, so
   the page colour has to be painted here rather than left to show through. The
   section's Figma fill is empty and #F2E6D5 is what it shows, so this is the
   same pixel either way — it is what makes the blend resolve. */
.intro--pattern {
  background-color: var(--ooc-light);   /* ⚠️ NOT --ooc-cream, which does not exist */
  background-image: url("../images/section-pattern.png");
  background-repeat: no-repeat;
  background-position: top center;
  background-size: 100% auto;
  background-blend-mode: multiply;
}

.intro__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;                /* 52/50 mobile; desktop overrides to 100/100 */
  letter-spacing: -2px;
  width: 100%;
}

.intro__copy {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 230:5744 itemSpacing */
  width: 100%;
}
.intro__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.intro__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

@media (min-width: 1025px) {
  .intro {
    padding-block: 212px 100px;   /* 230:5741 sits at y212; gap to the band 100 */
    padding-inline: var(--ooc-container-pad-lg);
    gap: 60px;                    /* 230:5741 itemSpacing — NOT the products
                                     intro's 40, which is the measurable
                                     difference between the two blocks */
     /* DESKTOP COMPOSITION CAP (§4). Capping the SECTION, which is what
       statement / collage / carousel / numbered-list / coverage / newsletter
       already do — this frame has an empty fill, so there is nothing to bleed
       and the cap is invisible apart from the alignment it buys.
       Caught by scripts/check-composition-cap.mjs, which reported this section
       growing 1440 -> 1880 -> 2520 with every scorecard still green. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }
  .intro__heading { max-width: 851px; line-height: 1; }   /* 100/100 exactly */
  .intro__copy    { max-width: 400px; }                   /* B1Text 230:5744 */
}


/* ⚠️ /services SCOPES THE INTRO'S PADDING, it does not change the shared one.
   `Frame 5` 210:2692 sits at y906 of `6` 210:2111 with the figure above it
   ending at 786, so the 120 gap is already the figure's bottom padding — a
   212 top here would add to it. And the gap DOWN to the first alternating row
   is 200 (1290 -> 1490), not /about's 100.
   The section's own content is identical on both pages (851x200 heading, 60
   gap, 400 copy), which is why the layout is reused and only the boundaries
   are scoped — the rule CLAUDE.md states for exactly this case. */
.page-services .intro { padding-block: 0 100px; }   /* desktop 0 / 200 */

/* ⚠️ THE SERVICES-SCOPED `intro` RULES ARE GONE (2026-08-04). They painted
   `Dot Pattern — Warm Ink` behind this page's intro and uncapped it so the band
   could reach the left edge. Merging `figure` + `intro` into `figure_intro`
   retired `.intro` on /services, which left them dead — and dead CSS describing
   an arrangement that no longer exists is exactly what misled the last three
   reads of this file. The band now lives on `.alternating-blocks`, where the
   design puts it (210:2112 sits over the "Direct sourcing" row). */
@media (min-width: 1025px) {
  .page-services .intro { padding-block: 0 200px; }
}


/* == IMAGE BAND ==============================================================
   Figma `1` 230:6138 (1400x999) on About 230:5104.
   A rounded, clipped photograph with a cream note card inset bottom-right.     */
.image-band {
  position: relative;             /* the card's containing block at 1440 */
  padding-inline: var(--ooc-container-pad);
  padding-bottom: 110px;          /* desktop 219 */
}

.image-band__frame {
  position: relative;
  width: 100%;
  /* An aspect canvas rather than a height, so the band stays proportional
     between the reference widths (Hard rule 3). */
  aspect-ratio: 1400 / 999;
  border-radius: 20px;
  overflow: clip;
}

.image-band__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
}

/* ⚠️ MOBILE IS DERIVED. A 393-wide card cannot overlay a 353-wide band, so it
   drops below the photograph and takes the full column — the design system's
   own mobile language everywhere else in this file is to stack. */
.image-band__card {
  margin-top: 20px;
  background: var(--ooc-light-2);
  border-radius: 12px;
  /* ⚠️ ASYMMETRIC, and it is the design's: `pl-20 pr-32 py-32`. The narrow left
     inset is what lets the 16px icon sit near the card edge while the text
     keeps a 32 right margin. A uniform 32 moves every line 12px. */
  padding: 32px 32px 32px 20px;
}

.image-band__notes {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* 32 + the 1px rule + 32. The rule is drawn in the gap by the ::before
     below, so ONE gap of 65 reproduces the node's three-item column. */
  gap: 65px;
}

.image-band__note {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 8px;                       /* 230:6173 itemSpacing */
}

/* The rule, 230:6178 — #002765 at 10%, FULL WIDTH of the card's content box.
   Drawn on the note (which is full width) rather than on the text beside the
   icon, which would be 24px short — the mistake the product card's divider
   already made once, where the probe passed because it asserted the border's
   colour and thickness but never its extent. */
.image-band__note + .image-band__note::before {
  content: "";
  position: absolute;
  top: -33px;                     /* 32 below the previous note's bottom */
  left: 0;
  right: 0;
  height: 1px;
  background: var(--ooc-navy-blue);
  opacity: 0.1;
}

.image-band__note-icon {
  flex: none;
  width: 16px;
  height: 16px;
  background: url("../images/about-note-icon.svg") center / contain no-repeat;
}

.image-band__note-text {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;                   /* so a long word cannot widen the flex item */
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
  color: var(--ooc-navy-blue);
}

@media (min-width: 1025px) {
  .image-band {
    padding-inline: var(--ooc-container-pad-lg);
    padding-bottom: 219px;        /* band ends 1655 -> next block 1874 */
     /* DESKTOP COMPOSITION CAP (§4). Capping the SECTION, which is what
       statement / collage / carousel / numbered-list / coverage / newsletter
       already do — this frame has an empty fill, so there is nothing to bleed
       and the cap is invisible apart from the alignment it buys.
       Caught by scripts/check-composition-cap.mjs, which reported this section
       growing 1440 -> 1880 -> 2520 with every scorecard still green. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }
  /* Back inside the frame, inset 20 from its bottom-right corner:
     987 + 393 = 1380 of 1400, and 670 + 309 = 979 of 999. */
  /* Back inside the frame's bottom-right corner. Measured from the SECTION's
     padding box now that the card is a sibling, so both insets are the design's
     own constants added up rather than a percentage of a box that changes:
       right  = 20 gutter + 20 inset                       = 40
       bottom = 219 section padding + 20 inset             = 239
     which lands the card at 987+393 = 1380 of 1400 and 670+309 = 979 of 999. */
  .image-band__card {
    position: absolute;
    margin-top: 0;
    right: 40px;
    bottom: 239px;
    /* Of the SECTION's padding box (1440), not the content column (1400) — the
       card is a sibling of the frame now, so that is what a percentage resolves
       against. At 28.0714% it measured 404.22, which widened the first note
       from six lines to five and took 20px off the card's height with it: one
       wrong denominator, five failed assertions. */
    width: 27.2917%;              /* 393 / 1440 */
  }
}


/* == FEATURE CARDS ===========================================================
   Figma `Frame 2017` 230:6264 inside `Frame 2005` 230:5779 (About).
   A heading with a side note, over icon cards.                                */
.feature-cards {
  position: relative;             /* the containing block for the dot band */
  display: flex;
  flex-direction: column;
  padding-block: 120px;           /* desktop 240 */
  padding-inline: var(--ooc-container-pad);
  gap: 48px;                      /* desktop 80 */
  color: var(--ooc-navy-blue);
}

/* The dot band — `186:1017`, 1440x240 across the top of /services' 186:928.
   OPT-IN: /about's 230:6264 has no such child, so this is a modifier rather
   than something every use of the block gets.

   ⚠️ THE NODE MIRRORS IT. `186:1017`'s wrapper carries `scaleY(-1)` AND
   `rotate(180deg)`, and those compose to scaleX(-1) — a horizontal flip, not
   the vertical one either transform suggests on its own. The offsets below are
   the pre-transform ones, so the flip goes on the element after them.

   ⚠️ The fill is placed FAR outside its box: 143.89% x 864.11% at
   (-42.82%, -316.26%) of the 1440x240 band. Those are `left`/`top` percentages
   of the CONTAINER, which background-position is not — it aligns the image's
   P% point with the container's P%. Converted:
       x: -616.6 / (1440 - 2072.0)  = 97.56%
       y: -759.0 / ( 240 - 2073.9)  = 41.39%
   Getting this wrong shifts the field without changing any probed number.

   opacity 0.1 IS applied here, unlike .feature-cards__pattern above: this
   export measures maxA 255, so the node's 10% is NOT baked into it. Same
   question, opposite answer — which is why it gets measured per asset. */
.feature-cards__dots {
  position: absolute;
  inset-inline: 0;
  /* Vitaliy's own export of `186:1017` (2026-08-04), 1440x320 = 40 + the node's
     240 + 40 of blur bleed, its ink at rows 38..217 and cols 33..1108.

     -20 (desktop -40) maps the export's row 40 — the node's own top edge — onto
     the section's top, and lets the bleed above it paint. A background-image is
     painted only inside its own box, so without the overflow that fade would be
     cut level with the section's top edge, which is the defect the
     alternating-blocks bands had to be rebuilt for.

     ⚠️ It replaced a NODE EXPORT I pulled myself, which measured ink across the
     whole canvas (rows 0..319, cols 0..1439) with its mass centred at 49.9%
     where his sits at 33%. Downloading a node is not the same as being given
     the asset — when both exist, the supplied one wins. */
  inset-block-start: -20px;
  z-index: 0;
  height: 160px;                  /* desktop 320 = 40 + the node's 240 + 40 */
  background: url("../images/services-dots-warm-ink-4.png") left top / auto no-repeat;
  pointer-events: none;
}


/* ⚠️ MOBILE IS DERIVED: the side note stacks under the heading. At 353 a 335
   note and an 851 heading cannot share a row. */
/* position/z-index so the header and grid sit OVER the absolutely-positioned
   dot band, which would otherwise paint above them (positioned elements beat
   in-flow content in the same stacking context regardless of DOM order). */
.feature-cards__header {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 20px;
  width: 100%;
}

.feature-cards__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;
  letter-spacing: -2px;
}

.feature-cards__note {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 230:6267 itemSpacing */
}
.feature-cards__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.feature-cards__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

.feature-cards__grid {
  position: relative;
  z-index: 1;
  list-style: none;
  margin: 0;
  padding: 0;
  width: 100%;
  display: grid;
  grid-template-columns: 1fr;     /* desktop is 3 */
  gap: 20px;                      /* 230:6270 itemSpacing */
}
.feature-cards__item { display: flex; }

.feature-cards__card {
  position: relative;
  display: flex;
  flex-direction: column;
  /* 230:6271 — icon pinned top, meta pinned bottom, whatever is between them
     is space. Not a gap: the card's height is fixed and the space is the
     remainder. */
  justify-content: space-between;
  width: 100%;
  aspect-ratio: 453.33 / 526;     /* a canvas, so the card scales with the grid */
  padding: 32px;
  /* OUTLINE, not border — the same fidelity fix the product card needed, and
     for the same reason. In the file the card is 453 wide and its `product im`
     child (230:6272) is ALSO 453, sitting at left:-1: the stroke is drawn ON
     the boundary and does not inset the frame. A CSS border with border-box
     takes 1px off each side, and EVERY percentage child then resolves against
     a box 2px narrower — measured the pattern at 493.17 against 495.3 and the
     meta at 307.58 against 360. An outline draws on the edge without consuming
     layout, and -1px offset pulls it just inside. */
  outline: 1px solid var(--ooc-navy-blue);
  outline-offset: -1px;
  border-radius: 20px;
  overflow: clip;
}

/* 230:6273 — 495 wide in a 453 box, blurred 40, at 20%.
   ⚠️ THIS IS NOT THE PRODUCT CARD'S PATTERN. Same idea, different picture and
   a different baked alpha: a 4-channel diff against s4-card-pattern.webp is
   40.55, and their alpha means are 13.52 against 23.62. RGB alone would have
   looked close — alpha is what distinguishes two exports (extract rule 15). */
.feature-cards__pattern {
  position: absolute;
  top: 0;
  left: 0;
  width: 109.2715%;               /* 495 / 453 — deliberately overflows, clipped */
  height: 100%;
  /* Percentages resolve against the card's PADDING box. With the stroke as an
     outline that box is the full 453.33, so these land on the node's 495.36 and
     526 without any border arithmetic. */
  /* ⚠️ ONE TEXTURE PER CARD — `--ooc-fc-pattern` is set per row by the
     renderer. Both pages that use this block draw three DIFFERENT pictures
     (about card 1 vs 2 vs 3 diff 57.14 / 69.97 / 64.76 mean), and this was a
     single hard-coded URL until 2026-08-01, so /about shipped card 1's texture
     three times at a 100% scorecard. The fallback keeps a card added without
     one painting rather than resolving url("") against the page. */
  background: var(--ooc-fc-pattern, url("../images/fc-pattern-1.png"))
              bottom center / 100% 100% no-repeat;
  /* ⚠️ NO OPACITY — and the node says 0.2. FIGMA BAKES A NODE'S FILL OPACITY
     INTO ITS EXPORT, the same rule .products__pattern above records. Measured
     on all three files: maxA = 51 = 0.20 x 255 exactly, so the export IS the
     20% layer. Re-applying 0.2 composited to 4% and the texture vanished —
     the cards read as flat cream against a design that shows clear blurred
     blobs. /about shipped that way at a 100% scorecard, because the probes
     asserted the pattern's width, height and opacity and every one of them
     was "right": 0.2 was faithfully what the node said, and faithfully wrong.

     ⚠️ 12.5, NOT the node's 40 — MEASURED, and deliberately not generalised.
     Swept the radius against the node render over a texture-only region of
     card 1 AND of card 2, which agree on a true interior minimum:
         blur   40    30    20    18    16    15    14    13    12    11     6
         card1  3.50  3.13  2.27  2.06  1.81  1.69  1.61  1.56  1.55  1.59  2.60
         card2  3.43    —   2.02    —     —   1.51    —   1.45   —    1.53    —
     Rising on BOTH sides is what makes 12-13 an optimum rather than a floor.
     Alignment was verified first, not assumed: the node render is 1326 tall
     for a 1286 frame, and sweeping the crop offset against the heading put the
     bleed at +40 exactly (36.1 at 0, 16.62 at 40, 27.08 at 46).

     ⚠️ DO NOT turn this into a ratio. 40 -> 12.5 is 3.2x, and the SAME sweep on
     .product-hero__pattern — node blur 20 — runs the other way: 10.2 at 4, 9.24
     at 8, 7.7 at 20, 7.35 at 30, 7.30 at 40, 7.34 at 55. Monotonic to a flat
     floor is blur smearing a DIFFERENT error into the background, not a match,
     so that section's open diff is not a blur problem and this number is not
     its answer. Two nodes, two behaviours: measure each one. */
  filter: blur(12.5px);
  pointer-events: none;
}

.feature-cards__icon {
  position: relative;
  z-index: 1;
  flex: none;
  width: 80px;
  height: 80px;
  /* 230:6274 clips its icon — the third is 96.123 wide in this 80 box. */
  overflow: clip;
  display: flex;
  align-items: center;
  justify-content: center;
}
.feature-cards__icon-img {
  width: var(--ooc-icon-w, 96.25%);
  height: auto;
  /* ⚠️ The global img { max-width: 100% } would SQUASH an over-size icon
     instead of letting the box crop it — extract rule 9, which cost a cycle on
     the product photograph. */
  max-width: none;
  display: block;
}

.feature-cards__meta {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 20px;                      /* 230:6278 itemSpacing */
  width: 100%;
}
.feature-cards__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-product);  /* 40 -> 48, the design's own 48 ramp */
  font-weight: 400;
  line-height: 1;                 /* 48/48 set solid */
  letter-spacing: -1px;
  text-transform: capitalize;
}
.feature-cards__text {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

@media (min-width: 1025px) {
  .feature-cards__dots { inset-block-start: -40px; height: 320px; }   /* 186:1017 */

  .feature-cards {
    padding-block: 240px;         /* Frame 2005 is 240 / 706 / 240 */
    padding-inline: var(--ooc-container-pad-lg);
    gap: 80px;                    /* 230:6264 itemSpacing */
    /* ⚠️ THE CAP IS GONE (2026-08-04). Its comment said "empty fill, so there
       is nothing to bleed" — the FIFTH place on this page to say so and the
       fifth to stop being true the moment a band went on. `186:1017` spans the
       frame edge to edge, and a capped section made it a 1440 island with bare
       page either side above 1440. The grid and the cards inside are capped
       independently and are both asserted in check-composition-cap.mjs, so
       uncapping the section moves no content. */
  }

  /* ⚠️ THE HEADER IS AN OVERLAID GRID, NOT A FLEX ROW (230:6265). Both children
     are in row 1, placed by margin: the heading at ml 0 / 851 wide, the note at
     ml 1065 / mt 5 / 335 wide. 1065 + 335 = 1400, so the note is flush to the
     content column's right edge while the heading keeps its own 851 measure.
     space-between would put the note wherever the heading's text happened to
     end, which is a different number on every page that uses this block. */
  .feature-cards__header {
    display: block;
    position: relative;
    /* ⚠️ CAPPED EXPLICITLY, because the SECTION no longer is (2026-08-04, so its
       dot band can reach the page edges). The header used to inherit the
       section's cap; without one of its own it measured 1400 -> 1880 -> 2520
       from 1440 to 2560 — the composition splitting in two, on /services AND
       /about, both caught by check-composition-cap.mjs. 1400 is `Group 21`'s
       own width (202:31 / 230:6265). The grid and the cards were already capped
       independently. */
    width: 100%;
    max-width: 1400px;
    margin-inline: auto;
    /* HUGS THE HEADING rather than a fixed 100. Group 21 230:6265 is 100 tall
       on /about, whose heading is one line, and 200 on /services `202:31`,
       whose heading is two — and the heading is set solid here, so its own
       height IS the row's on both. A pinned 100 would have overlapped the
       cards on /services by exactly one line. Section arithmetic still lands:
       240 + 100 + 80 + 526 + 240 = 1186 (about) and
       240 + 200 + 80 + 526 + 240 = 1286 (services). */
  }
  .feature-cards__heading { max-width: 851px; line-height: 1; }
  .feature-cards__note {
    position: absolute;
    left: 76.0714%;               /* 1065 / 1400 */
    /* ⚠️ THE NOTE HANGS OFF THE HEADING'S LAST LINE, not off the header's top.
       about  230:6267  y 245 = heading y 240 + 5, one line above it
       svc    202:33    y 345 = heading y 240 + 100 + 5, two lines above it
       Both are "last line's top, plus the node's own mt 5". Because the
       heading is set solid, one line is exactly --ooc-fluid-h2 tall, so
       (lines - 1) x that is the last line's offset — and it stays right
       through the fluid band, where a literal 105px would not. */
    top: calc((var(--ooc-fc-heading-lines, 1) - 1) * var(--ooc-fluid-h2) + 5px);
    width: 23.9286%;              /*  335 / 1400 */
  }

  .feature-cards__grid {
    grid-template-columns: repeat(3, 1fr);
    /* DESKTOP COMPOSITION CAP (§4) — same 1400 as every other grid.
       ⚠️ `margin-inline: auto` IS REQUIRED, and was missing. While the SECTION
       was capped and centred, the grid inherited that centring for free and a
       bare `max-width` was enough. Uncapping the section on 2026-08-04 (so its
       dot band could reach the page edges) left the grid hard against the left
       padding edge while the header — which had just been given its own
       `margin-inline: auto` — centred correctly: at 2560 the header sat at
       x580 and the cards at x20, 560px apart. That is the composition splitting
       in two, and it is what "layout is broken" looked like.
       Capping content is only half of bleeding a section; the other half is
       centring it. */
    max-width: 1400px;
    margin-inline: auto;
  }
  /* 360 of the card's CONTENT box (453.33 - 64 padding = 389.33), not of the
     card itself — the design's meta does not fill the padded width. */
  .feature-cards__meta { width: 92.4671%; }   /* 360 / 389.33 */
}


/* == DATA TABLE ==============================================================
   Figma `4` 186:829 (1440x1491) on Services & Logistics V2.
   A heading with a side note, over a bordered panel of tabular data.

   Section 1491 = 240 + 200 (two-line heading) + 80 + 771 (panel) + 200.

   ⚠️ THIS SECTION HAS ITS OWN FILL — 186:829 is #FFF9E9 (`light 2`) where the
   frames around it are empty and show the page's #F2E6D5. Same trap `.products`
   records: carrying "no background" over by analogy puts the whole band 13
   units too dark, which is invisible at a section boundary and only shows up
   when an empty margin strip is diffed against the node render.

   ⚠️ EVERY TYPE SIZE HERE IS 17px and the design system has no 17 — `body M` is
   16/20, `body S` is 14/20. Built as drawn: 17 on a 20 leading is not
   internally incoherent the way the feature cards' `text-[101px]` on a 100
   leading is, and it is consistent across all 36 cells AND the side note, so
   snapping it to 16 would be the invention rather than the fidelity. Flagged in
   the spec's __doc__ rather than silently resolved either way.

   Mobile is DERIVED (Hard rule 2): padding halves as everywhere else
   (240/200 -> 120/100) and the gap follows the feature cards' 80 -> 48. The
   table itself keeps every semantic and SCROLLS inside its own panel — the
   project rule for wide content, and the only honest answer when the file has
   no mobile artboard for it. Its width comes from the design's own
   `whitespace-nowrap` cells rather than from a min-width anyone made up.       */
.data-table {
  position: relative;             /* containing block for the dot band */
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-block: 120px 100px;     /* desktop 240 / 200 */
  padding-inline: var(--ooc-container-pad);
  gap: 48px;                      /* desktop 80 */
  color: var(--ooc-navy-blue);
  background: var(--ooc-light-2);
}

/* `210:3035` — the SAME asset as .feature-cards__dots (byte-identical, diff
   0.00 against dots-warm-ink.png) at this frame's own offsets, and with NO
   mirror: 186:1017 composes scaleY(-1) with a 180° rotation, this one carries
   neither. Fill 143.89% x 864.11% at (-31.45%, -666.15%) of the 1440x240 band,
   converted to background-position the same way:
       x: -452.9 / (1440 - 2072.0) = 71.66%
       y: -1598.8 / ( 240 - 2073.9) = 87.18%                                    */
/* `210:3035`, x0 y0 w1440 h240 in frame `4` — the band across the top of the
   cities table. ANCHORED TOP RIGHT (Vitaliy, 2026-08-04): the asset's ink runs
   cols 205..1439, i.e. it is weighted to the right and fades down-left, so the
   right edge is the one it belongs to.

   ⚠️ THIS USED TO RECONSTRUCT THE BAND AT RUNTIME from the generic 1024²
   `dots-warm-ink.png` tile — `71.66% 87.18% / 143.89% 864.11%` with
   `opacity: 0.1` and a `blur(2.5px)`, four constants solved against a 1440 box.
   It is now his own pre-composited export, which carries its own alpha (and its
   own blur) exactly as `pattern 1` and the two row bands do. So NO opacity and
   NO filter here: re-applying either composites twice, which is the trap that
   has cost this project a cycle before.

   `.arc-callouts__dots` still carries the old reconstruction. It is a separate
   rule with duplicated values, not a shared one, so this change does not reach
   it — and that section has not been reviewed. */
.data-table__dots {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 0;
  height: 120px;                  /* desktop 240 */
  background: url("../images/services-dots-warm-ink-3.png") right top / auto no-repeat;
  pointer-events: none;
}

.data-table__header,
.data-table__panel {
  position: relative;
  z-index: 1;
  width: 100%;
  /* DESKTOP COMPOSITION CAP (§4). The SECTION keeps bleeding — its #FFF9E9 is
     a full-width band like `products`' — and only the content freezes. */
  max-width: 1400px;
}

.data-table__header {
  display: flex;
  flex-direction: column;
  gap: 20px;                      /* desktop overlays instead */
}

.data-table__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;                /* desktop overrides to 100/100 */
  letter-spacing: -2px;
}

.data-table__note {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 186:1600 itemSpacing */
}
.data-table__lede {
  margin: 0;
  font-size: 17px;                /* 186:1601 — off-token, see above */
  font-weight: var(--ooc-weight-semibold);
  line-height: 20px;
}
.data-table__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* The panel — 186:1604, 1400x771.
   OUTLINE, not border, and for the reason .feature-cards__card records: the
   stroke is drawn ON the frame's boundary, so a border-box border would take
   1px off each side and every column percentage below would resolve against a
   1398 box instead of 1400. */
.data-table__panel {
  background: var(--ooc-light-2);
  outline: 1px solid var(--ooc-navy-blue);
  outline-offset: -1px;
  border-radius: 12px;            /* 186:1604 */
  overflow: clip;
  padding-bottom: 32px;           /* 186:1604 pb — what closes the last row */
  /* This used to be `overflow-x: auto`, making the panel a horizontal scroll
     container for a table 216px wider than the phone. That is the behaviour the
     restacking block below replaces — nothing overflows now, so `clip` holds at
     both widths and keeps the navy header bar inside the 12px radius. */
}

.data-table__table {
  /* ⚠️ LOAD-BEARING. The global reset sets `table { display: block }` so an
     undesigned table cannot overflow the page, and a block box is not a table:
     `table-layout` stops applying and the column percentages below resolve
     against the shrink-to-fit content width. Measured 186/116/224/186 against
     366/334/334/366, in a table whose own `width` probe still reported a
     correct 1400 — the columns were wrong while the table was right, which is
     why the probes measure every column box and not just the table.
     Opting back in is safe HERE because `.data-table__panel` is the scroll
     container the global rule was trying to provide. */
  display: table;
  width: 100%;
  border-collapse: collapse;
  /* Mobile: columns size to their content, which the design already declares
     by putting `whitespace-nowrap` on the cells. Desktop switches to fixed. */
  table-layout: auto;
}

.data-table__table th,
.data-table__table td {
  text-align: left;
  white-space: nowrap;            /* 186:1611 et al */
}

/* The header bar — 186:1668, #002765 at 60 tall, its fill spanning the panel's
   full width while the text sits inside the 32px inset. */
.data-table__table thead th {
  height: 60px;
  padding: 0;
  background: var(--ooc-navy-blue);
  color: var(--ooc-light-2);
  font-size: 17px;
  font-weight: var(--ooc-weight-semibold);
  line-height: 20px;
  vertical-align: middle;
}

.data-table__table tbody td {
  /* 32 above, 24 of line, 32 below, then the 1px rule = an 89px row pitch,
     which is what 186:1685 -> 186:1744 measure (92 -> 181). */
  padding: 32px 0 33px;
  font-size: 17px;
  line-height: 24px;
  font-weight: var(--ooc-weight-regular);
  color: var(--ooc-navy-blue);
  vertical-align: middle;
}
/* The city column is Book where the other three are Regular (186:1611 against
   186:1632/1631/1606) — a real distinction in the file, not a rendering. */
.data-table__table tbody td:first-child {
  font-weight: var(--ooc-weight-book);
}

/* The gap BETWEEN columns, split half onto each neighbour so the design's
   `gap-[80px]` lands as 40 + 40 and the content columns stay equal. Zero on
   /services, where the four columns butt. */
.data-table__table th,
.data-table__table td { padding-inline: calc(var(--ooc-dt-gap, 0px) / 2); }

/* The 32px inset on the first and last columns. Applied as cell padding rather
   than as padding on the panel, because the header's navy fill has to reach the
   panel's edges while its text does not. */
.data-table__table th:first-child,
.data-table__table td:first-child { padding-left: 32px; }
.data-table__table th:last-child,
.data-table__table td:last-child  { padding-right: 32px; }

/* `359:1564` / `359:1571` / `359:1533` — the three "Not Available" rows on the
   city page carry `opacity-50` on the ROW, not on a cell. A state the design
   draws, so it is a field rather than something inferred from a cell's text.
   ⚠️ On the <tr>, so the rule underneath (a background on the same element)
   dims with it — which is what the file does. */
.data-table__row--muted > * { opacity: 0.5; }

/* The row rule — 186:1662, #002765 at opacity 0.1, 1px, spanning the padded
   1336 rather than the full 1400. A background gradient rather than a
   border-bottom: a border runs the cell's whole width including its padding,
   so the first and last columns would push it out to the panel edges.
   ⚠️ The LAST row draws none (186:1809 is the only frame without one) and the
   panel's 32px bottom padding closes the table instead. */
.data-table__table tbody tr:not(:last-child) {
  background-image: linear-gradient(
    to right,
    transparent 0 32px,
    rgba(0, 39, 101, 0.1) 32px calc(100% - 32px),
    transparent calc(100% - 32px)
  );
  background-repeat: no-repeat;
  background-position: bottom left;
  background-size: 100% 1px;
}
.data-table__table tbody tr:last-child td { padding-bottom: 0; }

/* ── BELOW 1025px THE TABLE RESTACKS INTO ONE CARD PER ROW ────────────────
   Vitaliy, from an iPhone, 2026-08-06: "city table doesnt look good on mobile
   think of best practices or way to display table on mobile".

   Measured on live at 393 before touching it: the panel is 353 wide and the
   table is 569 — it overflowed by 216px, 61% of the viewport. Two things went
   wrong at once, and only one of them was the scrolling:

     · `--ooc-dt-gap` is 0 on /services because the design BUTTS the four
       columns at 1400 wide, where 334px of column is its own separation. At 353
       there is no such room, so the cells ran straight into each other and the
       screenshot read `Omakase / MichelinNext-day (Tue & Fri)` — one string.
     · The identifying column is the FIRST one, so scrolling to reach `Window`
       pushes `City` off the left. The screenshot caught exactly that: a header
       reading `ub code` with no city beside any row.

   ⚠️ THERE IS NO MOBILE FRAME TO BUILD TO. The services page is desktop-only in
   the file (FIGMA.md: "Desktop only; every mobile value is a Hard-rule-2
   derivation"), so this is derived from the design system rather than read, and
   recorded as a DECISION in figma-specs/services/data-table.mobile.json.

   What it derives FROM, so nothing here is invented: the panel, its 12px radius
   and its 1px navy outline are the design's; the row separator is the same
   `rgba(0,39,101,.1)` hairline at the same inset; the type stays the section's
   own 17px; the label takes body-S, the one smaller step the system defines.
   The only new idea is the arrangement, and it is the ordinary responsive-table
   pattern: the first column becomes the card's title and every other cell
   carries its column name beside its value.

   Semantics survive because the roles are explicit in the partial — `display:
   block` strips implicit table roles, which is precisely why they are stated. */
.data-table__panel {
  /* Nothing overflows any more, so the scroll container is not needed and
     `clip` can go back to keeping the header bar inside the 12px radius. */
  overflow-x: clip;
}
.data-table__table { display: block; }
.data-table__table tbody { display: block; }

/* Visually hidden, NOT removed — the columnheaders still name each cell. */
.data-table__table thead {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.data-table__table tbody tr {
  display: block;
  padding: 24px 20px;
}
.data-table__table tbody td {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 0;
  /* The design's nowrap is a 1400px affordance. Here a long window like
     `Next-day (Tue & Fri)` has to be allowed to wrap rather than widen the
     card. */
  white-space: normal;
  font-size: 17px;
  line-height: 24px;
}
.data-table__table tbody td + td { margin-top: 8px; }

/* ⚠️ SPECIFICITY, NOT ORDER. The design's 32px edge inset is set by
   `.data-table__table td:first-child` (0,2,1), which OUTRANKS the
   `.data-table__table tbody td` block above (0,1,2) no matter that it is
   written later. Left alone, the card's title sat 52px in (the row's own 20
   plus this 32) while every cell under it started at 20 — measured as cell1
   paddingLeft 32 when the restack had asked for 0. The row's padding is the
   card's inset now, so both edge insets are zeroed at matching specificity. */
.data-table__table tbody td:first-child,
.data-table__table tbody td:last-child {
  padding-left: 0;
  padding-right: 0;
}

/* The first cell is the card's title: no label beside it, and it carries the
   row on its own. */
.data-table__table tbody td:first-child {
  display: block;
  margin-bottom: 12px;
  font-size: 20px;
  line-height: 26px;
  font-weight: var(--ooc-weight-semibold);
}
.data-table__table tbody td:first-child .data-table__cell-label { display: none; }

.data-table__cell-label {
  flex: none;
  font-size: var(--ooc-body-s-size);
  line-height: var(--ooc-body-s-lh);
  font-weight: var(--ooc-weight-regular);
  opacity: 0.6;
}
.data-table__cell-value { text-align: right; }
.data-table__table tbody td:first-child .data-table__cell-value { text-align: left; }

/* The separator keeps the design's colour and hairline, at the card's own 20px
   inset rather than the desktop 32. Still absent from the last row. */
.data-table__table tbody tr:not(:last-child) {
  background-image: linear-gradient(
    to right,
    transparent 0 20px,
    rgba(0, 39, 101, 0.1) 20px calc(100% - 20px),
    transparent calc(100% - 20px)
  );
}
/* The base rule zeroes the last row's bottom padding to let the panel's 32px
   close the table. Restacked, that padding IS the card's, so it stays. */
.data-table__table tbody tr:last-child td { padding-bottom: 0; }
.data-table__panel { padding-bottom: 0; }

@media (min-width: 1025px) {
  /* Back to a real table. Every display the stacking changed is restored here
     explicitly — an omission would leave the desktop table as a stack of blocks
     with the column percentages silently inert. */
  .data-table__table { display: table; }
  .data-table__table thead {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
    display: table-header-group;
  }
  .data-table__table tbody { display: table-row-group; }
  .data-table__table tbody tr { display: table-row; padding: 0; }
  .data-table__table tbody td {
    display: table-cell;
    margin-top: 0;
    white-space: nowrap;
    /* ⚠️ RESTORED EXPLICITLY. The restack sets `padding: 0` in the BASE block,
       which is not inside a max-width query — so leaving it out here did not
       leave the desktop cell alone, it left it flat. The gate caught it as
       row1 height 24 vs 89 and cell1 paddingTop 0 vs 32: the 89px row pitch
       is 32 + 24 of line + 33, and with no padding the whole 1491px section
       collapsed to 1004. Anything the restack sets must be undone here. */
    padding: 32px 0 33px;
  }
  .data-table__table tbody tr:last-child td { padding-bottom: 0; }
  /* The matching restore for the zeroed edge insets above. */
  .data-table__table tbody td:first-child { padding-left: 32px; }
  .data-table__table tbody td:last-child  { padding-right: 32px; }
  .data-table__table tbody td:first-child {
    display: table-cell;
    margin-bottom: 0;
    font-size: 17px;
    line-height: 24px;
    font-weight: var(--ooc-weight-book);
  }
  /* Back to the design's 32px inset — the restack narrows it to the card's 20. */
  .data-table__table tbody tr:not(:last-child) {
    background-image: linear-gradient(
      to right,
      transparent 0 32px,
      rgba(0, 39, 101, 0.1) 32px calc(100% - 32px),
      transparent calc(100% - 32px)
    );
  }
  .data-table__cell-label { display: none; }
  .data-table__cell-value { text-align: left; }
  .data-table__panel { padding-bottom: 32px; }

  .data-table {
    padding-block: 240px 200px;   /* 186:829 pt/pb */
    padding-inline: var(--ooc-container-pad-lg);
    gap: 80px;                    /* 186:829 itemSpacing */
  }

  .data-table__dots { height: 240px; }   /* 210:3035 */

  /* ⚠️ AN OVERLAID GRID, NOT A FLEX ROW — 186:1598 puts both children in row 1
     and places them by margin: the heading at ml 0 / 851 wide, the note at
     ml 1065 / 335 wide, and 1065 + 335 = 1400 exactly. Same shape as the
     feature cards' header.
     ⚠️ BUT THE NOTE'S TOP IS THIS FRAME'S OWN: mt 125. The feature cards' note
     is mt 5 and hangs off the heading's last line, which would put this one at
     105. It is 125, so it is a scoped number rather than the shared rule
     wearing a different value — do not "unify" them. */
  .data-table__header {
    display: block;
    position: relative;
    height: 200px;                /* 186:1598 — the two-line heading */
  }
  .data-table__heading { max-width: 851px; line-height: 1; }
  .data-table__note {
    position: absolute;
    left: 76.0714%;               /* 1065 / 1400 */
    top: 125px;                   /* 186:1600 mt */
    width: 23.9286%;              /*  335 / 1400 */
  }

  .data-table__panel {
    /* No scrolling at this width — the table fits, and `clip` is what keeps the
       header bar's corners inside the 12px radius. */
    overflow-x: clip;
  }

  .data-table__table {
    /* Equal quarters, and fixed so the widths below govern rather than the
       content. */
    table-layout: fixed;
    /* The design lets `Omakase / Michelin` and `Next-day (Tue & Fri)` sit on
       one line each at 334 wide; nowrap is inherited from the base block and
       still holds here. */
  }
  /* ⚠️ THE EDGE COLUMNS ARE DERIVED, NOT PINNED, and that is what lets one rule
     serve two very different tables in this file:
        /services `186:1604`  1400 wide · 4 columns · gap  0 -> edges 366
        /cities   `359:1500`   572 wide · 2 columns · gap 80 -> edges 286
     `--ooc-dt-edge` arrives from the renderer as a PURE PERCENTAGE — see the
     derivation in data-table.php. It has to be pure: a fixed-layout table DROPS
     THE PX TERM of a mixed calc(), measured in the page as `26.142857%` -> 366
     and `366px` -> 366 while `calc(25% + 16px)` -> 350, i.e. the percentage
     alone, with every column falling back to an equal share.
     Only the edges are set; the middle columns share the remainder, which is
     334 each for the design's four and still divides sanely at five. */
  .data-table__table th:first-child,
  .data-table__table td:first-child,
  .data-table__table th:last-child,
  .data-table__table td:last-child { width: var(--ooc-dt-edge, 26.142857%); }
}


/* == ARC CALLOUTS ============================================================
   Figma `5` 186:1018 (1440x1220) on Services & Logistics V2.
   A centred display sentence over a thin arc, callouts stacked inside it.

   Section 1220 = 240 + 204 (a three-line h3) + 80 + 455.46 (the dial) + 240.
   The node draws the arc's box at y524.49 rather than 524; the .49 is the
   rotated bounding box's own rounding, not an 80.49 gap, so the gap is 80.

   ⚠️ THE DIAL IS FLOW, NOT ABSOLUTE PLACEMENT. Its top and bottom insets are
   PERCENTAGES OF ITS OWN WIDTH — 189.51/655.56 = 28.91% and 29.95/655.56 =
   4.57% — which land the callouts on the node's 714 and 862 at the reference
   width and stay proportional below it. Placing all four text blocks by
   absolute y-percentage reproduces 1440 and collides at 393, because the type
   ramps and the percentages do not.
      189.51 + (88 + 60 + 88) + 29.95 = 455.46  ✓ the arc's own bbox height

   ⚠️ THE ARC IS OFF-CENTRE BY 10.78px AND THE TEXT IS NOT: the arc's box
   centres on 730.78 of the frame, the callouts on 720. The DIAL centres and the
   ARC is nudged inside it — the other way round gives every callout a 10.78px
   error that no type or colour probe can see.

   Mobile is DERIVED (Hard rule 2): padding halves, the gap follows the 80 -> 48
   the feature cards already derive, and the dial widens to the content column.
   The arc STRETCHES rather than letterboxing, which is the asset's own
   instruction — Figma exported it with preserveAspectRatio="none".            */
.arc-callouts {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-block: 120px;           /* desktop 240 */
  padding-inline: var(--ooc-container-pad);
  gap: 48px;                      /* desktop 80 */
  color: var(--ooc-navy-blue);
  background: var(--ooc-light-2); /* 186:1018 — its own fill, as `4` above */
}

/* `186:1064`, x0 y0 w1440 h240 — the band across the top of the cut-off
   callouts. ANCHORED TOP RIGHT (Vitaliy, 2026-08-04), matching the cities
   table: the asset's ink runs cols 205..1439 with its mass at 70.8%, so the
   right edge is the one it belongs to.

   ⚠️ IT SHARES `services-dots-warm-ink-3.png` WITH .data-table__dots, and that
   is a measured fact rather than an assumption: the file Vitaliy supplied for
   this section diffs 0.00 against the one he supplied for the cities table.
   210:3035 and 186:1064 are the same artwork, as this rule's previous comment
   already claimed.

   ⚠️ NO opacity, NO filter. This used to reconstruct the band at runtime from
   the generic 1024² tile — `71.66% 87.18% / 143.89% 864.11%` with
   `opacity: 0.1` and `blur(2.5px)`. The export carries its own alpha and blur;
   re-applying either composites twice. */
.arc-callouts__dots {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 0;
  height: 120px;                  /* desktop 240 */
  background: url("../images/services-dots-warm-ink-3.png") right top / auto no-repeat;
  pointer-events: none;
}

/* 186:1021 — h3, centred, in an 851 measure. */
.arc-callouts__heading {
  position: relative;
  z-index: 1;
  margin: 0;
  width: 100%;
  max-width: 851px;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h3);   /* 32 -> 64 */
  font-weight: 400;
  line-height: 1.0625;              /* 68 / 64 */
  /* ⚠️ h3's tracking is a PERCENT where h2's is pixels — the file reports -1 and
     renders -0.64px at 64px, i.e. -1%. Written in em so it stays -1% down the
     ramp; a flat -0.64px would be -2% at the mobile size. */
  letter-spacing: -0.01em;
  text-align: center;
}

.arc-callouts__dial {
  position: relative;
  z-index: 1;
  width: 100%;
  /* ⚠️ THE INSETS ARE ON THE LIST, NOT HERE. Percentage padding resolves
     against the CONTAINING BLOCK's inline size, not the element's own — put it
     on the dial and 28.909% is 28.909% of the section's 1400 content box
     (404.7px), not of the dial's 655.56 (189.51). Measured exactly that:
     paddingTop 404.72 against 189.51, and a dial 704.67 tall against 455.46.
     On the list they resolve against the dial, which is what was meant. */
}

/* 210:68 — the `Subtract` boolean, positioned by its UNROTATED box and then
   turned. Its 0.5 opacity is baked into the export (extract rule 14), so none
   is applied here. */
.arc-callouts__arc {
  position: absolute;
  z-index: -1;
  left: 5.126%;                   /* (425.82 - 392.22) / 655.56 */
  top: 8.879%;                    /*  (564.44 - 524)   / 455.46 */
  width: 93.038%;                 /*  609.923 / 655.56 */
  height: 82.453%;                /*  375.542 / 455.46 */
  background: url("../images/arc-callouts-arc.svg") center / 100% 100% no-repeat;
  transform: rotate(7.86deg);
  pointer-events: none;
}

/* 210:75 — the marker blob, a sibling of the arc rather than a child: 210:74
   sits on the dial's own coordinates, and nesting it inside the arc would have
   inherited the 7.86° rotation. */
.arc-callouts__marker {
  position: absolute;
  z-index: 1;
  left: 80.264%;                  /* (918.4 - 392.22) / 655.56 */
  top: 28.114%;                   /* (652.05 - 524)   / 455.46 */
  width: 5.805%;                  /*  38.055 / 655.56 */
  aspect-ratio: 38.055 / 30.081;
  background: url("../images/arc-callouts-marker.svg") center / 100% 100% no-repeat;
  pointer-events: none;
}
/* 210:76 — the glyph on the blob, placed as a % OF THE BLOB so the pair moves
   as one unit. */
.arc-callouts__glyph {
  position: absolute;
  left: 32.242%;                  /* (930.67 - 918.4) / 38.055 */
  top: 30.318%;                   /* (661.17 - 652.05) / 30.081 */
  width: 36.957%;                 /*  14.064 / 38.055 */
  height: 38.503%;                /*  11.582 / 30.081 */
  background: url("../images/arc-callouts-glyph.svg") center / 100% 100% no-repeat;
}

.arc-callouts__list {
  position: relative;
  list-style: none;
  margin: 0;
  /* 189.51 / 655.56 and 29.95 / 655.56 — percentages of the DIAL's width, which
     is what these resolve against from here. This is the whole mechanism: the
     dial's height falls out of the callouts plus the design's own proportional
     insets, so the composition scales instead of needing a second set of
     absolute y-positions for mobile.
         189.51 + 88 + 60 + 88 + 29.95 = 455.46 = the arc's own bbox height. */
  padding: 28.909% 0 4.569%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 48px;                      /* desktop 60 — the 0.8 inner-gap ratio */
}
.arc-callouts__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;                      /* 210:59 itemSpacing */
  width: 100%;
  max-width: 360px;               /* 210:59 / 210:78 */
  text-align: center;
}
.arc-callouts__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h4);   /* 40 -> 48 */
  font-weight: 400;
  line-height: 1;                   /* 48/48 set solid */
  letter-spacing: -1px;
}
.arc-callouts__text {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

@media (min-width: 1025px) {
  .arc-callouts {
    padding-block: 240px;         /* 186:1018 */
    padding-inline: var(--ooc-container-pad-lg);
    gap: 80px;                    /* 444 -> 524 */
    /* DESKTOP COMPOSITION CAP (§4) applies to the CONTENT, not the section —
       186:1018's #FFF9E9 is a full-width band like `4` and `products`. */
  }

  .arc-callouts__dots { height: 240px; }   /* 186:1064 */

  /* NO heading override. --ooc-fluid-h3 already resolves to exactly 64 at 1440
     and ramps below it; restating 64px here would pin the middle of the band to
     the desktop size, which is the fixed-px-across-the-range mistake Hard rule 3
     exists to catch and the reason the structural checks run at 860 and 1100. */

  .arc-callouts__dial {
    /* ⚠️ 655.56 / 1400, NOT / 1440. The section carries 20px of inline padding,
       so a child's percentage width resolves against the 1400 content box —
       45.525% measured 637.34. Centred by the section's align-items, which puts
       it at 392.22; the arc's own +10.78 offset lives on .arc-callouts__arc. */
    width: 46.8257%;              /* 655.56 / 1400 */
    max-width: 655.56px;
  }

  .arc-callouts__list { gap: 60px; }   /* 862 - 714 - 88 */
}


/* == FIGURE + INTRO ==========================================================
   Figma `6` 210:2111 (Services V2): `image 14` 210:2744 (808x574 at 316,212)
   over `Frame 5` 210:2692 (851x384 at 295,906), with `pattern 1` 210:2114 —
   a 1440x629 backdrop — behind BOTH.

   ⚠️ THIS SECTION EXISTS BECAUSE THE BAND CANNOT BELONG TO EITHER HALF.
   `pattern 1` is a sibling of the photograph and the copy, not a background of
   the photograph. While they were two sections the band was scoped to the first
   and clipped at its edge; once it scales with the viewport that clip lands
   inside the fade and rules a hard line across the page — at 2560 the band
   wants 1157px against the figure's 906. Merged, the box is 1490 and the same
   band fits until 3297px. Vitaliy, 2026-08-04.

   Section 1490 = the figure's 906 (212 + 574 + 120) plus the intro's 584
   (0 + 384 + 200), i.e. exactly the two boxes it replaces, so nothing below it
   moves. Mobile is DERIVED, carried over unchanged from the two blocks.        */
.figure-intro {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-inline: var(--ooc-container-pad);
  /* 106 above the photograph, then the copy block owns the rest — the gaps are
     the two former sections' own paddings, kept so the rhythm is unchanged. */
  padding-block: 106px 50px;
  color: var(--ooc-navy-blue);
  text-align: center;
}

/* The band. Same asset and the same rules the split version used, now painted
   across the whole merged box — which is the entire point of the merge. No
   blend mode and no extra opacity: the PNG carries its own alpha (dots at
   rgba(0,38,102,20)); compositing it again is the double-composite trap. */
.figure-intro--pattern {
  background-color: var(--ooc-light);
  background-image: url("../images/services-pattern-1.png");
  background-repeat: no-repeat;
  background-position: top center;
  background-size: 100% auto;
}

.figure-intro__figure { width: 100%; display: flex; justify-content: center; }
.figure-intro__img {
  width: var(--ooc-figure-w, 100%);
  max-width: var(--ooc-figure-max, 808px);
  height: auto;
  display: block;
}

.figure-intro__copy-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  gap: 48px;                      /* desktop 60 */
}

.figure-intro__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;                /* desktop overrides to 100/100 */
  letter-spacing: -2px;
  width: 100%;
}
.figure-intro__copy {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 210:2695 itemSpacing */
  width: 100%;
}
.figure-intro__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.figure-intro__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}


/* == FIGURE ==================================================================
   Figma `image 14` 210:2744 (808x574 at 316,212) inside `6` 210:2111.
   One centred photograph.

   Section 906 = 212 above + 574 + 120 below, which is the gap down to the intro
   that follows it. ⚠️ NO CORNER RADIUS — read from the node, which is a plain
   rectangle where every other photograph on the page sits in a 20-radius frame.

   Mobile is DERIVED: the padding halves and the photograph takes the content
   column, because 57.7% of 353 is 204px of picture.                            */
.figure {
  position: relative;
  display: flex;
  justify-content: center;
  padding-block: 106px 60px;      /* desktop 212 / 120 */
  padding-inline: var(--ooc-container-pad);
}

/* `pattern 1` 210:2114, from Vitaliy's own export (2026-08-03).
   1440x789 — TALLER than the node's 1440x629, because the export carries the
   blur's bleed past the node box. Anchored to the top, which is where the file
   draws it and what he asked for; the section is 906 tall, so the band occupies
   the top 789 and fades out before the photograph.

   ⚠️ NO background-blend-mode, and NO extra opacity. The old asset was OPAQUE
   WHITE (corner rgba 255,255,255,255) and needed `multiply` against a painted
   background-color to look like a tint. This one is genuinely transparent —
   corner alpha 0, and its dots are the navy rgb(0,38,102) at ALPHA 20, i.e. the
   ~8% the design applies, already baked in. Re-applying either would composite
   twice; that trap has cost this project a green cycle before (extract 14).
   background-color stays only because it is the page colour the band sits on
   and the section's own probe asserts it. */
.figure--pattern {
  background-color: var(--ooc-light);   /* ⚠️ NOT --ooc-cream, which does not exist */
  background-image: url("../images/services-pattern-1.png");
  background-repeat: no-repeat;
  background-position: top center;
  /* 100% WIDTH AT EVERY WIDTH (Vitaliy, 2026-08-04), height proportional, no
     breakpoint override — the band always spans the section and the dots are
     never distorted.

     ⚠️ Natural size (`auto`) is NOT an option, which is what the previous
     attempt got wrong: the asset is 1440x651, so at 393 it renders 1440 WIDE
     inside a 393 viewport and 651 TALL inside a 416.7 section — overflowing
     sideways and clipping through its own fade.

     ⚠️ THE FADE IS CUT ON VERY WIDE SCREENS, AND THAT IS ACCEPTED. Because the
     height tracks the width while the section stays 906, the band outgrows the
     section past ~2004px: 2560 shows the top 78% of it, 3440 the top 58%. That
     is the same condition that drew a line across the page on 2026-08-03 — but
     that defect was on the UNTRIMMED asset, whose ink was still dense where the
     cut fell. Trimming to the ink extent moved the fade down, so the cut now
     lands in faint texture. Measured on the rendered page at both widths, in a
     column clear of the intro's own spray. If it ever does show, the fix is to
     pin the height above a breakpoint (`background-size: 100% 651px`), which
     costs mild horizontal stretch — not to take the width away. */
  background-size: 100%;
}

/* ⚠️ THE WIDTH IS NEVER OVERRIDDEN — the band is 100% wide at every width
   (Vitaliy, 2026-08-04). Only its HEIGHT is pinned, and only past the width
   where leaving it proportional draws a visible line across the page.

   The band is `100%` wide with a proportional height, so it outgrows the
   section's fixed 906 once w x 651/1440 > 906 — i.e. **past 2004px**. Beyond
   that the fade is cut part-way and the texture stops dead. Measured on the
   rendered page, in a column right of centre clear of the intro's own spray:

       @2560  mean 10px above the boundary 224.496, below 228.298 — step 3.802
       @3440  224.143 / 228.072 — step 3.930

   and visible in the capture, not just in the numbers. 2005px is where that
   starts, so that is where the pin starts: below it the band stays
   proportional and undistorted, above it the height holds at the asset's own
   651 and the fade always completes inside the section. The cost is horizontal
   stretch on very wide screens, which on blurred dots peaking at alpha 20/255
   is not detectable — a line across the page is. */
@media (min-width: 2005px) {
  .figure--pattern { background-size: 100% auto; }
}

.figure__img {
  width: 100%;                    /* desktop takes its own share */
  height: auto;
  display: block;
}


/* == ALTERNATING BLOCKS ======================================================
   Figma `Group 22` 210:2698 / `Group 23` 210:2718 / `Group 24` 210:2708.
   A photograph beside a numbered text column, swapping sides down the page.

   Section 2920 = 760 + 200 + 760 + 200 + 760 + 240. The 240 is the frame's
   bottom padding; the gap ABOVE the first row belongs to the intro before it.

   Row 1164 = image 572 + gap 137 + text 455, centred in the 1440 frame
   ((1440 - 1164)/2 = 138, which is the x the file gives all three).

   ⚠️ The text column is CENTRED in its row, not offset — see the note in the
   template. ⚠️ The photographs are over-sized fills their windows crop, and
   each crop is per row.                                                       */
/* THE DOT BANDS ARE DESKTOP-ONLY — see the desktop block below.
   `Dot Pattern — Warm Ink` 210:2112 and 210:2113 are HAND-PLACED against rows
   that only sit side by side above 1025: their y offsets are pixel positions in
   the 1440 frame, and the mobile stack has neither those rows nor those
   distances. Placing them here would be inventing geometry the file does not
   draw, so the mobile section carries no band at all. */

.alternating-blocks {
  position: relative;             /* the bands are positioned against this */
  padding-block: 0 120px;         /* desktop 0 / 240 */
  padding-inline: var(--ooc-container-pad);
}

/* THE DOT BANDS — hidden below 1025. They are hand-placed pixel positions in
   the 1440 frame, and the mobile stack has neither those rows nor those
   distances, so drawing them there would be inventing geometry the file does
   not contain. */
.alternating-blocks__band { display: none; }

.alternating-blocks__list {
  list-style: none;
  margin: 0 auto;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 100px;                     /* desktop 200 */
  /* ⚠️ FULL COLUMN AT MOBILE. The 1164/1400 below is a DESKTOP number and it
     leaked into this base block first time round: 83.1429% of 353 is 293.48,
     so every row, window and text block measured 293 wide at x49.75 instead of
     353 at x20 — seventeen failing assertions from one misplaced declaration. */
  width: 100%;
}

.alternating-blocks__row {
  position: relative;
  display: flex;
  flex-direction: column;         /* desktop is a two-column row */
  /* DERIVED: there is no vertical photo-to-text gap in the file, because the
     file never stacks this row. 48 is the 0.8 inner-gap ratio applied to the
     body's own 60, reused rather than a third number invented for the same
     kind of space. */
  gap: 48px;
}

/* The two dot bands are `.alternating-blocks__band--1` and `--2` above — real
   elements carrying Vitaliy's own exports. A `::before` reconstruction lived
   here until 2026-08-04, disabled with `content: none` and never removed: it
   rebuilt both bands from the generic 1024² tile with per-row percentage
   offsets, an `opacity: 0.1` and a runtime blur. It is gone rather than left
   inert, because dead CSS describing a retired arrangement is what misled the
   last several reads of this file. */
/* 210:2699 / 210:2719 / 210:2709 — a 572x760 window with a 20 radius that CROPS
   an over-sized fill. */
.alternating-blocks__window {
  position: relative;
  overflow: clip;
  border-radius: 20px;
  aspect-ratio: 572 / 760;
  width: 100%;
}
.alternating-blocks__img {
  position: absolute;
  left: var(--ooc-fill-x, 0%);
  top: var(--ooc-fill-y, 0%);
  width: var(--ooc-fill-w, 100%);
  height: var(--ooc-fill-h, 100%);
  object-fit: cover;
  /* ⚠️ The global `img { max-width: 100% }` would SQUASH a deliberately
     over-size fill instead of letting the window crop it — extract rule 9,
     which has now cost a cycle on three separate sections. */
  max-width: none;
  display: block;
}

.alternating-blocks__body {
  display: flex;
  flex-direction: column;
  gap: 48px;                      /* desktop 60 — the 0.8 inner-gap ratio */
  color: var(--ooc-navy-blue);
}

/* 210:2702 — the SAME blob `numbered_list` masks (path matches from M34.3144),
   at the same 77x61 with the same 18/18/-0.36 numeral. */
.alternating-blocks__badge {
  flex: none;
  display: grid;
  place-items: center;
  width: 60px;                    /* desktop 77 */
  height: 48px;                   /* desktop 61 */
  padding-top: 4px;
  background: var(--ooc-light-2);
  -webkit-mask: var(--ooc-badge-blob) center / 100% 100% no-repeat;
          mask: var(--ooc-badge-blob) center / 100% 100% no-repeat;
}
.alternating-blocks__number {
  font-size: 14px;                /* desktop 18 */
  font-weight: var(--ooc-weight-semibold);
  line-height: 18px;              /* 18 at BOTH widths */
  letter-spacing: -0.28px;        /* desktop -0.36 */
  color: var(--ooc-navy-blue);
}

.alternating-blocks__meta {
  display: flex;
  flex-direction: column;
  gap: 20px;                      /* 210:2705 itemSpacing */
}
.alternating-blocks__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h3);   /* 32 -> 64 */
  font-weight: 400;
  line-height: 1.0625;              /* 68 / 64 */
  /* h3's tracking is a PERCENT, not pixels — see .arc-callouts__heading. */
  letter-spacing: -0.01em;
}
.alternating-blocks__text {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  /* ⚠️ 22, NOT the `body S` token's 20. Read from 210:2707 and CONFIRMED by the
     three bodies' own heights: 198 / 132 / 154 are 9 / 6 / 7 lines at 22 and
     are not whole line counts at 20. Same off-token leading as content flag 26
     (the product description's 16/22). */
  line-height: 22px;
}

@media (min-width: 1025px) {
  /* ⚠️ THIS SECTION BLEEDS — it did not until 2026-08-03, and the cap that used
     to sit here cropped its pattern band. The removed comment read "the frame
     has an empty fill, so there is nothing to bleed and the cap is invisible",
     which was wrong: `.figure--pattern` paints `pattern 1` 210:2114, a 1440x629
     band. Capping the SECTION capped the band with it, so above 1440 the dots
     stopped at the cap and plain #F2E6D5 ran to both edges — measured at 1920 as
     a 1440 section at x240, leaving 240px of bare page each side. Vitaliy, 2026-08-03.

     Bleed the section, cap the thing inside it — the same move `collage` needed
     on the home page, and for the same reason: the cap was doing a REAL job
     here. Uncapped, the photograph is 57.714% of an ever-wider column and drags
     the section's height with it (905.98 -> 1102.78 at 1920, pushing everything
     below down by 196.8). `max-width` on the image freezes it at the node's own
     808 instead, so the height holds at 906 while the band runs edge to edge. */
  /* FIGURE + INTRO, desktop. The two former sections' desktop values, summed:
     212 above the photograph, 120 down to the heading, then the intro's own
     0/200. Total 1490 — the same height the pair occupied, so the sections
     below do not move. */
  .figure-intro {
    padding-block: 212px 200px;
    padding-inline: var(--ooc-container-pad-lg);
  }
  .figure-intro__copy-block {
    /* 120 from the photograph down to the heading (906 - 786), and the intro's
       own 60 between heading and copy. */
    margin-block-start: 120px;
    gap: 60px;
  }
  /* ⚠️ THE COPY IS CAPPED, because the SECTION bleeds. This is the
     bleed-the-section / cap-the-content pair the composition rule requires:
     the band needs the full width, the text must not follow it. Left
     uncapped the heading measured 851 -> 1143 -> 1532 -> 2067 from 1440 to
     3440 and the section's height fell with the shorter wraps, which is the
     composition splitting in two — exactly what the cap exists to stop. */
  .figure-intro__heading {
    line-height: 1;               /* 100/100 exactly */
    /* Frame 5 is 851 of the 1400 content column, centred. */
    width: 60.7857%;
    max-width: 851px;
  }
  .figure-intro__copy {
    /* B1Text 400 of 1400. */
    width: 28.5714%;
    max-width: 400px;
    margin-inline: auto;
  }

  .figure {
    padding-block: 212px 120px;   /* 210:2744 sits at y212; 786 -> 906 */
    padding-inline: var(--ooc-container-pad-lg);
  }
  /* 808 / 1400 by default, carried as a field so the block is reusable.
     The cap is the node's own width, so above 1440 the photograph is exactly
     what the file draws rather than a proportion of a viewport the file has no
     opinion about. */
  .figure__img {
    width: var(--ooc-figure-w, 57.714%);
    max-width: var(--ooc-figure-max, 808px);
  }

  /* ── THE TWO DOT BANDS ────────────────────────────────────────────────
     `Dot Pattern — Warm Ink` 210:2112 and 210:2113, both x0 w1440, from
     Vitaliy's own exports. Each sits ABOVE the row it belongs to, which is what
     he asked for on 2026-08-04 — "background should go above the image".

     EVERY NUMBER HERE IS DERIVED, not nudged:

       section starts at frame-6 y1490 (figure_intro is 0..1490)
       band 1  node y1350 h428  ->  1350 - 1490 = -140   (140 above row 1)
       band 2  node y3202 h484  ->  3202 - 1490 = 1712   (208 above row 3,
                                                          which starts at 1920)

     …then corrected for each export's own transparent margin, because the
     assets are TRIMMED to their ink and the ink does not start at the node's
     edge. The filter pads 40px each side (confirmed in the supplied SVG:
     `filter y=0 height=508` around a `translate(0 40)` 428-tall rect), so:

       band 1  export 508 = 40 + 428 + 40, ink at row 47  ->  7 below the node
               top   ->  -140 + 7  = -133px
       band 2  export 564 = 40 + 484 + 40, ink at row 81  -> 41 below the node
               top   ->  1712 + 41 = 1753px

     Anchored left at natural size: these are corner sprays that fade down and
     to the right, so stretching them would grow the dots. No blend mode and no
     opacity — both PNGs carry their own alpha already composited. */
  .alternating-blocks__band {
    display: block;
    position: absolute;
    inset-inline-start: 0;
    width: 100%;
    background-repeat: no-repeat;
    background-position: left top;
    pointer-events: none;
  }
  /* -140 is the node's own offset (1350 - 1490); +7 corrects for the export's
     transparent margin, since the asset is trimmed to its ink and the ink
     starts 7px below the node's top. 441 is that trimmed height. */
  .alternating-blocks__band--1 {
    top: -133px;
    height: 441px;
    background-image: url("../images/services-dots-warm-ink.png");
  }
  /* 1712 = 3202 - 1490, +41 for this export's own margin; 465 trimmed height. */
  .alternating-blocks__band--2 {
    top: 1753px;
    height: 465px;
    background-image: url("../images/services-dots-warm-ink-2.png");
  }

  .alternating-blocks {
    padding-block: 0 240px;       /* 4170 -> 4410 */
    padding-inline: var(--ooc-container-pad-lg);
    /* ⚠️ THE CAP IS GONE (2026-08-04). Its comment said "empty fill, so nothing
       to bleed" — the fourth place on this page that said so and the fourth
       that was wrong once a band went on. This section now paints
       `Dot Pattern — Warm Ink` anchored LEFT, and a capped section is centred,
       so at 1920 "left" would have meant 240px in. The list inside was ALREADY
       capped at 1164 independently, so uncapping the section moves no content —
       verified, not assumed. */
  }
  .alternating-blocks__list {
    gap: 200px;                   /* 1490 -> 2450 -> 3410 */
    /* DESKTOP COMPOSITION CAP (§4) — 1164 of the 1440 frame. */
    width: 83.1429%;              /* 1164 / 1400 */
    max-width: 1164px;
  }

  .alternating-blocks__row {
    flex-direction: row;
    /* ⚠️ CENTRED, and this is the whole reason there is no offset field: the
       file's 177 / 210 / 165 are (760 - 407)/2, (760 - 341)/2 and (760 - 431)/2
       to within 0.5px. */
    align-items: center;
    gap: 11.7698%;                /* 137 / 1164 */
  }
  .alternating-blocks__row--right { flex-direction: row-reverse; }

  .alternating-blocks__window { width: 49.1409%; }   /* 572 / 1164 */
  .alternating-blocks__body   { width: 39.0893%; gap: 60px; }   /* 455 / 1164; 61 + 60 = 121 */

  /* The dot bands are `.alternating-blocks__band--1` / `--2`, declared above in
     this block — they are desktop-only for the reason kept here: they are
     hand-placed against rows that only sit side by side, so "140 above row 1"
     describes nothing a 393 reader sees, and the file has no mobile artboard to
     place them from.

     ⚠️ Two `::before` rules used to live here, setting `content: ""` with
     percentage offsets against the retired reconstruction. Removing that
     reconstruction's BASE rule without them turned each into an in-flow FLEX
     ITEM carrying 36.77% of top padding — every row lost 101px of height and
     the photo window shrank 572 -> 496. Caught by the gate, not by eye. When
     you delete a pseudo-element's base rule, grep for its overrides. */


  .alternating-blocks__badge {
    width: 77px;                  /* 210:2702 */
    height: 61px;
    padding-top: 1px;             /* numeral at 22 of 61; centring gives 21.5 */
  }
  .alternating-blocks__number {
    font-size: 18px;              /* 210:2704 */
    letter-spacing: -0.36px;
  }
}


/* ⚠️ A CITY PAGE CROPS THE COVERAGE PHOTO DIFFERENTLY, and it is page-scoped
   rather than made into four more fields. `360:1681` is 1488x2229 at
   (-44,-1032) in the 1400x1000 card where home's `100:6825` is 1607x1073 at
   (-21,-28) — a portrait window on a landscape source, so the card shows a
   narrow vertical slice. Everything else about the block is identical to the
   pixel (panel 1360x172 at 40,808 · arc at 449.13,833.81 · cities at x967),
   which is why the section is reused and only this one crop is scoped.
   ⚠️ The source is 1024x679 and the design blows it up 3.28x. That softness is
   the file's, not the build's — recorded so nobody re-derives it as a defect. */
.page-new-york-delivery .coverage__photo {
  inset-inline-start: -3.1429%;   /*   -44 / 1400 */
  inset-block-start: -103.2%;     /* -1032 / 1000 */
  width: 106.2857%;               /*  1488 / 1400 */
  height: 222.9%;                 /*  2229 / 1000 */
  object-position: bottom;        /* 360:1681 `object-bottom` */
}


/* == SPLIT TABLE =============================================================
   Figma `6` 359:519 (1440x1162) on `City Page` 359:495.
   A text column beside a data table.

   Section 1162 = 240 + 682 + 240, where 682 is the TABLE — the taller of the
   two columns. The text column is 542 and top-aligns with it at y240.

   Columns of the 1400 content box: text 454 (32.4286%), gap 374 (26.7143%),
   panel 572 (40.8571%). 454 + 374 + 572 = 1400 ✓

   ⚠️ The PANEL is `data_table`'s partial, unchanged — see the note in the
   template. Everything under `.data-table__` here is that shared CSS.

   Mobile is DERIVED (Hard rule 2): the two columns stack, padding halves, and
   the table scrolls inside its own panel exactly as it does on /services.      */
.split-table {
  position: relative;
  display: flex;
  flex-direction: column;         /* desktop is a two-column row */
  align-items: flex-start;
  gap: 48px;
  padding-block: 120px;           /* desktop 240 */
  padding-inline: var(--ooc-container-pad);
  color: var(--ooc-navy-blue);
}

/* `pattern 1` 359:522, 1440x629 — the same band and the same mechanism the
   figure and the about intro use. */
.split-table--pattern {
  background-color: var(--ooc-light);   /* ⚠️ NOT --ooc-cream, which does not exist */
  background-image: url("../images/section-pattern.png");
  background-repeat: no-repeat;
  background-position: top center;
  background-size: 100% auto;
  background-blend-mode: multiply;
}

.split-table__text {
  display: flex;
  flex-direction: column;
  gap: 48px;                      /* 360:2767 itemSpacing */
  width: 100%;
}
.split-table__intro {
  display: flex;
  flex-direction: column;
  gap: 20px;                      /* 360:2766 itemSpacing */
}
.split-table__heading {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;                /* desktop overrides to 100/100 */
  letter-spacing: -2px;
}
.split-table__note {
  display: flex;
  flex-direction: column;
  gap: 4px;                       /* 359:1102 itemSpacing */
}
.split-table__lede {
  margin: 0;
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.split-table__body {
  margin: 0;
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

/* `360:1598` — three ticked lines, 14px Medium set SOLID at 14/14, 14 apart.
   ⚠️ 14-on-14 is the design's own: `body S` is 14/20, and these are `leading-
   [14px]`. Read, not snapped — the 70px block height (3 x 14 + 2 x 14) only
   works at 14. */
.split-table__points {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;                      /* 360:1598 itemSpacing */
}
.split-table__point {
  display: flex;
  align-items: center;
  gap: 10px;                      /* 360:1585 itemSpacing */
  font-size: 14px;
  font-weight: var(--ooc-weight-medium, 500);
  line-height: 14px;
  color: var(--ooc-navy-blue);
}
.split-table__point::before {
  content: "";
  flex: none;
  width: 10px;
  height: 8px;                    /* 360:1586 */
  background: url("../images/tick.svg") center / contain no-repeat;
}

@media (min-width: 1025px) {
  .split-table {
    flex-direction: row;
    /* `Frame 2033` and `Frame 14` BOTH start at y240 — top-aligned, not
       centred, which is what makes the section 1162 rather than taller. */
    align-items: flex-start;
    gap: 26.7143%;                /* 374 / 1400 */
    padding-block: 240px;
    padding-inline: var(--ooc-container-pad-lg);
    /* DESKTOP COMPOSITION CAP (§4). The frame has an empty fill — the dot band
       above is a background on this element and caps with it. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }

  .split-table__text  { width: 32.4286%; }   /* 454 / 1400 */
  .split-table .data-table__panel { width: 40.8571%; }   /* 572 / 1400 */

  .split-table__heading { line-height: 1; }
}


/* == PARTNERS ================================================================
   Figma `fish` 230:6261 + `img` 230:6222 / 230:6227 (About 230:5104).
   A subject photograph with tilted logo cards laid over it.                    */
.partners {
  padding-inline: var(--ooc-container-pad);
  padding-bottom: 62px;           /* desktop 123 */
}

.partners__stage {
  position: relative;
  width: 100%;
  /* One canvas for both axes, so the cards keep their position relative to the
     photograph as the block scales — a tilted card placed in fixed px drifts
     off the subject between the reference widths. */
  aspect-ratio: 1400 / 818.79;
}

.partners__subject {
  position: absolute;
  left: 11.6429%;                 /* (183 - 20) / 1400 */
  top: 0;
  width: 74.7857%;                /* 1047 / 1400 */
  height: auto;
  max-width: none;
  display: block;
}

/* ⚠️ POSITION BY THE UNROTATED BOX — trap [J]. `left`/`top` place the element
   BEFORE the transform, and the design gives bounding-box origins, so the seed
   supplies back-computed values (box_left = bbox_left + bbox_w/2 - box_w/2).
   Asserting the bbox afterwards proves the angle for free.

   Both angles were RECOVERED, not read: neither Figma tool reports a rotation
   here (metadata gives the tilted bbox, design-context renders the node in
   isolation and strips the transform). scripts/rotated-box.mjs fits the top
   edge and solves w/h from the bbox — R² 1.0, Δ0x0 round-trip, whole-degree
   angles, and both cards resolving to the same ~205x262 card. */
.partners__logo {
  position: absolute;
  left: var(--ooc-logo-x, 0%);
  top: var(--ooc-logo-y, 0%);
  width: var(--ooc-logo-w, 14.6%);
  aspect-ratio: 205.4 / 262.44;
  transform: rotate(var(--ooc-logo-rotate, 0deg));
  transform-origin: center;
  background: var(--ooc-light-2);
  border-radius: 12px;
  overflow: clip;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Per logo — 113.93/204.94 = 55.59% and 123.672/205.86 = 60.08%. The two marks
   are genuinely different widths, so one shared value would stretch one. */
.partners__logo-img {
  width: var(--ooc-logo-img-w, 55.6%);
  height: auto;
  max-width: none;
  display: block;
}

@media (min-width: 1025px) {
  .partners {
    padding-inline: var(--ooc-container-pad-lg);
    padding-bottom: 123px;        /* content ends 3136.8 of the frame's 3260 */
     /* DESKTOP COMPOSITION CAP (§4). Capping the SECTION, which is what
       statement / collage / carousel / numbered-list / coverage / newsletter
       already do — this frame has an empty fill, so there is nothing to bleed
       and the cap is invisible apart from the alignment it buys.
       Caught by scripts/check-composition-cap.mjs, which reported this section
       growing 1440 -> 1880 -> 2520 with every scorecard still green. */
    max-width: var(--ooc-container-max);
    margin-inline: auto;
  }
}


/* == UTILITY PROSE ===========================================================
   The fallback in page.php: a page with no ACF sections renders its editor
   content inside `.container`, and 404.php uses the same shell.

   ⚠️ THERE IS NO FIGMA FRAME FOR ANY OF THIS. `privacy-policy`, `terms` and 404
   are named in the sitemap and drawn nowhere, so every value here is a
   DERIVATION from the design system's own tokens, not a reading. Recorded as
   such in docs/content-flags.md rather than presented as spec.

   What is derived, and from what:
     title    the `h4` rank (48/48, tracking -1) — the same rank the product
              name and "Featued Products" use. NOT the 100px page-title rank:
              that is drawn for a marketing statement of two or three words,
              and a legal document's title is not that.
     subheads the `h5` rank (32), then body M semibold for the level below.
     body     body M (16) — but at 1.6 line-height rather than the token's 20
              (= 1.25). THIS IS THE ONE VALUE THAT IS NOT A TOKEN. 1.25 is set
              for two-line ledes and would be unreadable over paragraphs of
              legal text. Flagged for the designer.
     measure  --ooc-prose-measure, 68ch. A full 1400 column of 16px text is
              ~180 characters per line; 60-75 is the readable band.           */
:root { --ooc-prose-measure: 68ch; }

.page-prose {
  padding-block: 120px;           /* desktop 200 */
  color: var(--ooc-navy-blue);
}

.page-prose__body { max-width: var(--ooc-prose-measure); }

.page-prose__title {
  margin: 0 0 40px;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-product);   /* 40 -> 48, the design's own 48 ramp */
  font-weight: 400;
  line-height: 1;
  letter-spacing: -1px;
}

.page-prose__body > * + * { margin-top: 1em; }

.page-prose__body h2,
.page-prose__body h3 {
  margin-top: 48px;
  margin-bottom: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-weight: 400;
  letter-spacing: -1px;
}
.page-prose__body h2 { font-size: var(--ooc-fluid-h5); line-height: 1.125; }  /* 28 -> 32 */
.page-prose__body h3 {
  font-family: var(--ooc-font-sans);
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
  letter-spacing: 0;
}

.page-prose__body p,
.page-prose__body li {
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-book);
  /* ⚠️ NOT the body M token's 20 (1.25) — see the note above. */
  line-height: 1.6;
}

.page-prose__body ul,
.page-prose__body ol { padding-left: 1.25em; }
.page-prose__body li + li { margin-top: 0.5em; }

/* Underlined, not colour-only: #002765 on #F2E6D5 gives a strong contrast for
   the TEXT, but a link that differs from its surroundings by colour alone is
   invisible to a reader who cannot distinguish them. */
.page-prose__body a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (min-width: 1025px) {
  .page-prose { padding-block: 200px; }
  .page-prose__title { margin-bottom: 60px; }
}


/* == TESTIMONIALS ============================================================
   Figma `5` 100:6776 (1440x1412) — home section 6.

   ⚠️ THE ROW OVERFLOWS BY DESIGN. Four 354 cards with 20 gaps is 1476 in a 1400
   column, and `see more` 122:296 parks the drag cue at (875,275). So it scrolls,
   exactly like the carousel in 5a — scroll-snap for touch momentum, keyboard and
   screen-reader semantics, with the pointer affordance layered on top rather
   than scrolling reimplemented.

   Mobile-first: the base block is the 393 derivation, @media 1025px is 1440.
   ⚠️ MOBILE IS DERIVED — the home frame's mobile artboard has no section 6 (it
   is one of the blocks `HP Mobile` omits), so the card takes the 353 content
   column and the row keeps scrolling, which is what every other overflowing row
   in this design does at 393.                                                */
.testimonials {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 60px;                      /* desktop 100 */
  padding-block: 120px;           /* desktop 240 */
  padding-inline: var(--ooc-container-pad);
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  overflow: clip;
}

/* ⚠️ SUPPLIED ASSET, 2026-08-02, anchored TOP RIGHT. Vitaliy exported this band
   directly — "giving you background dot patter stick it to the top right" — and
   it supersedes node 100:6822 (1440x540) exactly as `s5-dots.webp` superseded
   100:6775 on the carousel above. Same treatment, same reasoning, and the same
   three properties retired because they are already in the pixels:

     opacity   0 pixels opaque, max alpha 26/255 = 10.2%. Baked in, so a CSS
               `opacity: 0.1` would composite it to 1% and the band would
               vanish — which is precisely the bug the 2026-08-01 rebuild noted
               below fixed by swapping to an UN-baked source. The supplied
               export is baked, so the CSS opacity goes away again.
     blur      measured rather than assumed this time: max alpha-gradient 12 and
               mean 2.68 over the ink, against `dots-warm-ink.png`'s 506 / 151.4
               on the same scale. It is pre-blurred, softer even than s5-dots
               (16 / 4.19), so `filter: blur(2.5px)` would double-apply.
     window    the four solved constants (71.66% 104.23% / 143.89% 384.05%) are
               retired, not moved. They windowed a 1024² square source; a
               pre-cut band needs no window at all.

   ⚠️ TRIMMED to its ink: `cwebp -crop 203 0 1237 211` from the original PNG.
   The export is 1440x580 with 203 dead columns on the LEFT and 369 dead rows at
   the BOTTOM — its top and right margins were already flush, which is what it
   anchors to, so the trim cannot shift the anchor. Verified: all four margins
   are 0 and the ink box fills the asset. 33,032 bytes.

   ⚠️ A REPLACEMENT EXPORT MUST BE TRIMMED THE SAME WAY — the same standing
   caveat as `.carousel__dots`, which had a 37px transparent top margin that
   read as "the background isn't starting from the top".

   ── What the 2026-08-01 rebuild found, kept because it still explains the
      failure mode: the section scored 100% with THREE things wrong, because the
      probes asserted the band's box and its opacity and never what was inside
      it — a baked-opacity asset composited to 1%, a window showing the wrong
      corner of the source, and the wrong blur. A geometry probe cannot see any
      of those. */
.testimonials__dots {
  position: absolute;
  inset: 0 0 auto 0;
  height: 211px;                  /* the trimmed asset's own height */
  background: url("../images/s6-dots.webp") right top / auto no-repeat;
  pointer-events: none;
}

.testimonials__heading {
  position: relative;
  margin: 0;
  align-self: center;
  max-width: 851px;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 -> 100 */
  font-weight: 400;
  line-height: 1.04;                /* desktop overrides to 100/100 */
  letter-spacing: -2px;
  text-align: center;
}

/* -- The drag cue (122:296) -----------------------------------------------
   The file draws the SAME cursor over this row as over the menu carousel —
   122:296 at (875,275), 77.68x61.40, identical geometry to the carousel's
   122:292. It needs a non-scrolling positioned ancestor: inside the track it
   would scroll away with the cards, and on the section it would light up over
   the heading too. Hence this wrapper, which is a plain block and takes the
   track's place as the section's flex item, so no gap or edge moves. */
.testimonials__gallery { position: relative; }

.testimonials__cursor {
  display: none;
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 77.6816px;               /* 122:296 */
  height: 61.4034px;
  background: url("../images/s5-drag.svg") center / contain no-repeat;
  transform: translate(calc(var(--ooc-cursor-x, 0px) - 50%),
                       calc(var(--ooc-cursor-y, 0px) - 50%));
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease;
  z-index: 2;
}
.testimonials__gallery.is-cursor-active .testimonials__cursor { opacity: 1; }

.testimonials__track {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  gap: 20px;                        /* 100:6780 itemSpacing */
  /* The scroll itself. `clip` is not an option here — this one genuinely IS a
     scroll container, which is the difference from every other overflow: clip
     in this file. */
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  /* NO negative margin. An earlier pass pulled the track to the viewport edge so
     the row would "read as continuing" — an embellishment, not the design.
     `100:6780` sits plainly inside the section's 20px padding and the row simply
     overflows to the right. The gate caught it as card1.x 0 vs 20 at both
     widths. The design's own edge behaviour is the one that ships. */
}
.testimonials__track::-webkit-scrollbar { display: none; }
.testimonials__track:focus-visible { outline: 2px solid var(--ooc-navy-blue); outline-offset: 4px; }

.testimonials__card {
  flex: none;
  width: 354px;                     /* 100:6781 — fixed at BOTH widths */
  max-width: calc(100vw - var(--ooc-container-pad) * 2);
  display: flex;
  flex-direction: column;
  gap: 40px;                        /* 100:6781 itemSpacing */
  scroll-snap-align: start;
}

.testimonials__media {
  position: relative;
  width: 100%;
  height: 420px;                    /* 100:6782 */
  border-radius: 20px;
  overflow: clip;
}

/* 100:6783 — 495x383 at 0,0, blurred 20, at 10%. Same asset family as the
   product card's; that one bakes 0.5, so 0.2 here lands on the node's 10%. */
.testimonials__pattern {
  position: absolute;
  top: 0;
  left: 0;
  width: 139.83%;                   /* 495 / 354 */
  height: 91.19%;                   /* 383 / 420 */
  background: url("../images/s4-card-pattern.webp") bottom center / 100% 100% no-repeat;
  opacity: 0.2;
  filter: blur(20px);
  pointer-events: none;
}

/* Per row, because the file frames each portrait differently — 432x455 at
   (-50,-10), a centred 448 square, 697x442 at (-327,-10) and (-268,-10). A
   shared object-position would crop three of the four wrongly.
   ⚠️ max-width: none — the global img rule would squash an oversized portrait
   instead of letting the card crop it (extract rule 9). */
/* ⚠️ HEIGHT IS THE FILL'S, NOT THE WINDOW'S — `height: 100%` CROPPED EVERY
   PORTRAIT AT THE BOTTOM. The design oversizes each fill in BOTH axes and lets
   the 354x420 window crop it (455, 448, 442, 442 tall against the window's 420),
   then nudges it up by `--ooc-p-y`. Pinning the element to 100% made it exactly
   420 tall while still starting at -10px, so its bottom edge landed at 410 and
   the last 10px of the card showed the pattern through — and `object-fit: cover`
   re-cropped the source into that short box on top.

   The default is `auto` so a portrait with no recorded fill height simply keeps
   its own aspect. Where the file states one, the row passes it and the element
   IS the design's fill box, which is what makes `cover` a no-op rather than a
   second, competing crop. Left as `auto` for every row, a wide replacement photo
   would reopen the same gap: at 697 wide, an aspect of 2.0 is only 349 tall and
   -10px + 349 < 420 again.

   Unprobed until 2026-08-03, which is why it shipped — `media1` asserted the
   WINDOW and nothing asserted the photograph inside it. */
.testimonials__photo {
  position: absolute;
  left: var(--ooc-p-x, 0%);
  top: var(--ooc-p-y, 0%);
  width: var(--ooc-p-w, 122%);
  height: var(--ooc-p-h, auto);
  max-width: none;
  object-fit: cover;
  display: block;
}

.testimonials__copy {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;                        /* 100:6786 itemSpacing */
  text-align: center;
}

.testimonials__quote {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: 32px;                  /* 100:6787 — 32/36 at both widths */
  font-weight: 400;
  line-height: 36px;
}

.testimonials__meta {
  display: flex;
  flex-direction: column;
  gap: 4px;                         /* 100:6788 itemSpacing */
  width: 335px;
  max-width: 100%;
}
.testimonials__name {
  font-size: var(--ooc-body-m-size);
  font-weight: var(--ooc-weight-semibold);
  line-height: var(--ooc-body-m-lh);
}
.testimonials__role {
  font-size: var(--ooc-body-s-size);
  font-weight: var(--ooc-weight-book);
  line-height: var(--ooc-body-s-lh);
}

@media (prefers-reduced-motion: reduce) {
  .testimonials__track { scroll-behavior: auto; }
}

@media (min-width: 1025px) {
  .testimonials {
    gap: 100px;                     /* 100:6776 itemSpacing */
    padding-block: 240px;
    padding-inline: var(--ooc-container-pad-lg);
  }
  .testimonials__heading { line-height: 1; }   /* 100/100 exactly */

  /* ⚠️ THE ROW BLEEDS ABOVE THE CAP — amended 2026-08-02, Vitaliy, applying the
     same call he made on the carousel above ("make it full width").

     It carried `max-width: 1400px; margin-inline: auto`, capped on the 2026-08-01
     reasoning that "uncapped, a scroll container of fixed 354 cards does not
     stretch them, it just reveals MORE of them: 4 cards at 1440 became 7 at
     2560". That observation is correct and is no longer a defect: revealing
     more cards IS what a card carousel does, and it is why this section reads
     as a boxed 1400 widget on a wide display with empty #FFF9E9 either side.

     ⚠️ NOTHING MOVES AT 1440. The cap was invisible there — the track already
     filled the 1400 content column and the row already overflowed past it — so
     this changes the composition only above the reference width. `card1.x`
     stays 20 and the spec still asserts it.

     ⚠️ PADDING, NOT MARGIN — corrected 2026-08-02 after Vitaliy: "it is not
     moving all way to the left, expand it left so it goes all the way left
     until carousel reached end like right now".

     This first shipped as `margin-inline-start`, which SHRINKS THE TRACK'S BOX:
     at 2560 the scroll container itself began at 580, so scrolled cards were
     clipped there and the 560px to its left was dead — while the right side
     ran to the section gutter as it should. The row could reach one edge and
     not the other, which is what he saw.

     Padding keeps the box the full content column (20 -> W-20, symmetric with
     the right) and makes the inset a RESTING offset instead: at scrollLeft 0
     the first card sits at the content column's start, and as the row scrolls
     the cards travel through that padding to the box's own left edge. This is
     the same shape `.carousel__track` uses, and the reason it uses it.

     `scroll-padding-inline-start` must match or scroll-snap aligns cards to the
     scrollport edge rather than to the padded resting position — the row would
     rest at 580 and then snap to 20 on first interaction.

     `100%` resolves against the section's content box for padding and against
     the scrollport for scroll-padding; here they are the same number. At 1440
     it is (1400 - 1400) / 2 = 0, so the reference width is untouched and the
     track stays x20 w1400 exactly as 100:6780 draws it. At 2560 it is 560,
     putting the first card at 580 = (2560 - 1400) / 2. */
  .testimonials__track {
    padding-inline-start:        max(0px, (100% - 1400px) / 2);
    scroll-padding-inline-start: max(0px, (100% - 1400px) / 2);
  }

}

/* == PRODUCT DETAIL ==========================================================
   Figma `Products (Detailed)` 223:243 (1440×3055) — the approved plain variant.
     hero        223:280   1440×800   the split gallery/info hero, below
     "You May Also Like" 223:302      the `products` grid, browse variant
     pre-footer  223:1537  1440×536   the `newsletter` layout, top padding 0

   ⚠️ NO MOBILE FRAME EXISTS for this page, as for every inner page. Everything
   below 1025px is a Hard-rule-2 DERIVATION from docs/mobile-first.md §1 (393
   base, 20px gutter, 353 content column) and from how the file's own mobile
   frames treat a two-column desktop block — `HP Mobile` 226:2032 stacks the
   hero's columns rather than shrinking them, and the contact frame does the
   same. A 720 + 560 split cannot survive 393 by any scaling, so it stacks.
   Recorded, not freestyled.

   Mobile-first: this block is 393 and the min-width:1025px block re-splits it.
   Writing it the other way round is what produces the ~860px dead space the
   structural checks exist to catch.
   ------------------------------------------------------------------------- */

.product-hero {
  position: relative;
  /* ⚠️ NO CAP — this section BLEEDS, and that is a 2026-08-05 decision by
     Vitaliy reviewing the page, not a reading of `223:243`.

     It used to be `max-width: var(--ooc-container-max)`, which froze the band
     at 1440 and centred it, so at 2560 the gallery started at x=560 and left
     **560px of bare #F2E6D5 to the left of the product background** — measured,
     and it is what he asked to remove: "the left side where we have product
     background should continue going all the way to the left, so maybe apply it
     outside of the grid."

     The band now behaves exactly as `hero` does on the four other inner pages
     after his 2026-08-04 call: the fill bleeds and the height rides it
     proportionally, while the TYPE stays capped. His words here were "hero
     should have same height as services page", and the option he picked was the
     services BEHAVIOUR at this hero's own ratio rather than its literal 640 —
     the node draws 800 and that is kept at the reference width.

     Nothing here declares the ratio, because nothing needs to: the gallery is
     50% of the band carrying `aspect-ratio: 720/800`, so the band's height is
     0.5w × 800/720 = 0.5556w, i.e. 1440/800 = 1.8 at every width. It falls out
     of the two numbers the design already gives.

         width   band height   gallery x   left gap
          1440       800            0          0
          1920      1066.67         0          0
          2560      1422.22         0          0

     The gallery's right edge is the viewport centre at EVERY width, which is
     not a coincidence worth re-deriving later: capped, it would sit at
     (w−1440)/2 + 720 = w/2 — the same place 50% puts it. That is why the halves
     can bleed without the composition disagreeing with itself.

     ⚠️ `product-hero` is therefore in check-composition-cap's BLEEDS + SCALES
     (ratio 1.8) now, and `.product-hero`/`__gallery`/`__photo-box`/`__thumbs`
     LEFT its TARGETS — asserting them frozen would assert the thing he asked to
     remove, the same move `.carousel__track` and `.testimonials__track` made.
     `.product-hero__info` stays and is now the page's only composition guard:
     it is capped at 560, so if it ever tracks the viewport again the right-hand
     column has gone back to stretching. */
}

/* ---- The gallery — `223:1323`, 720×800 in the frame --------------------- */

.product-hero__gallery {
  position: relative;
  /* 720/800. An aspect ratio rather than a height, so the picture keeps its
     proportions at every width instead of letterboxing between the references. */
  aspect-ratio: 720 / 800;
  /* radius 20 on the TOP corners ONLY — it meets the section's bottom edge
     square, so this is not the uniform 20 the product cards use. */
  border-radius: 20px 20px 0 0;
  /* Load-bearing: the thumb strip is drawn 810 wide inside 720 and the design
     cuts the fifth thumbnail with this. */
  overflow: clip;
  /* NO fill. `223:1323` rendered in isolation (contentsOnly) is 0% opaque —
     mean alpha 9-12/255, which is only the dot ink — so the frame is empty and
     what the design shows here is the PAGE's #F2E6D5, exactly as `.figure` and
     `.products` do (extract rule 11, and the note at the §"No background" above).

     This had been `--ooc-light-2` (#FFF9E9), read from nowhere, and it is the
     whole of the gallery pattern's unexplained diff — open since 2026-08-01 and
     wrongly chased as a blur radius. #FFF9E9 - #F2E6D5 = (+13,+19,+20); the
     pattern region measured (+12.87,+18.43,+19.40) against the node render,
     which is that difference seen through the pattern's own 20% alpha (the
     0.949 transmission this file already derives for compositing over #F2E6D5).
     The blur sweep "ruled blur out" correctly but then looked in the wrong
     place: blurring flattens the region toward its mean, so a flat WRONG colour
     scores better on MAE than a dotted wrong colour — the metric was rewarding
     the error. Probed now via `gallery.backgroundColor`, so it cannot recur
     silently the way `coverage` and `newsletter` did. */
}

.product-hero__pattern {
  position: absolute;
  /* (−12, 0) 789×924 as % of the 720×800 gallery. */
  left: -1.6667%;
  top: 0;
  width: 109.5833%;
  height: 115.5%;
  /* ⚠️ WITHOUT THIS THE PATTERN IS 720 WIDE, NOT 789. The global
     `img { max-width: 100% }` clamps any fill deliberately sized past its
     window, squashing it instead of letting it bleed — extract rule 9, and the
     gate caught it here at exactly 789 → 720. Every over-size fill needs this. */
  max-width: none;
  object-fit: cover;
  object-position: bottom;
  /* 4, NOT the node's 20. Same trap as the services dot bands: the EXPORT
     already carries the layer blur, so a CSS blur on top applies it twice —
     `blur(20px)` rendered a featureless smear where the design draws distinct
     soft discs.

     Chosen on the texture statistic, because MAE cannot decide this: the discs
     are randomly placed, so per-pixel MAE always rewards a flatter field, and
     it sits in a meaningless bowl (7.06 at 16px, 7.14 at 20, 7.25 at 24). What
     does discriminate is contrast AMPLITUDE over a pattern-only region
     (x380 y90 320x110), which is position-independent:

         blur  0  sd 11.80    blur 3  sd 10.62    blur 20  sd  3.68  ← shipped
         blur  2  sd 11.13    blur 4  sd 10.09    FIGMA    sd 10.07
                                     ^ 0.2% off the design

     blur(20px) had ONE THIRD of the design's texture. 0-2px additionally expose
     the WebP quantisation speckle inside the larger discs, which 4 removes.
     Confirmed by eye against the node render — out/review/pd-pattern-strip.png. */
  filter: blur(4px);
  pointer-events: none;
  /* ⚠️ NO `opacity` HERE, and that is the whole point. The node carries a 20%
     FILL opacity, and Figma bakes fill opacity into the export — measured, not
     assumed: this asset's alpha channel peaks at 51/255 = 0.20 exactly.
     Re-applying it would composite twice to 4% and the pattern would disappear
     under 8-bit quantisation. Extract rule 14. */
}

/* ---- The capped stage — Vitaliy 2026-08-05 ----------------------------- */

.product-hero__stage {
  /* "inside have same grid as the rest of the content." The gallery bleeds and
     the pattern bleeds with it; the fish, the tags and the thumb strip do not.

     This box runs from the PAGE's content gutter to the gallery's right edge.
     The gallery's right edge is the viewport centre (w/2) and the content
     column's left edge is (w−1400)/2 = w/2 − 700, so the stage is **exactly
     700px — half the content column — at every width from 1440 up**, and every
     child of it freezes without one fixed pixel being written. Below 1440 the
     gutter is a flat 20 and the stage narrows with the gallery, which is the
     fluid rule.

         width   gallery   stage x..right   stage w
          1440     720        20..720         700
          1920     960       260..960         700
          2560    1280       580..1280        700

     Uncapped, measured at 2560: thumbs 544.5px left of the grid, photograph
     494.7px left of it and inflated 624 → 1109.3.

     ⚠️ THE STAGE IS A DESKTOP CONSTRUCT AND IS INERT HERE, mobile-first. Below
     1025 the hero STACKS — the gallery is full width, capped at 560 and
     centred — so there is no "half the content column" to align to and no
     second column to be out of step with. Insetting it anyway moved twelve
     mobile assertions (photo x 26.2 → 34.9, thumbs x 10.92 → 20, and their
     widths with them) and dropped the mobile gate to 76/88. So this is a plain
     `inset: 0` here and the gutter arrives with the desktop grid. */
  position: absolute;
  inset: 0;
}

.product-hero__photo-box {
  position: absolute;
  /* MOBILE: centred in the gallery, 86.6667% = 624/720 carried across. The
     desktop block re-anchors this to the stage — see there for why. */
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 86.6667%;          /* 624/720 */
  aspect-ratio: 624 / 352;
}
.product-hero__photo {
  width: 100%;
  height: 100%;
  /* CONTAIN, not cover — Vitaliy 2026-08-05, once all six products carried
     detail content. These are CUT-OUTS OF WHOLE ANIMALS on a transparent
     ground, not photographs in a window, so the box exists to place them, not
     to crop them. `cover` was invisible while Maguro was the only populated
     page — its 1024x579 is 1.769 against the box's 624/352 = 1.773, so it fills
     with nothing to spare and cover and contain are the same picture there.
     The other five are not that shape, and cover was cutting them:

         maguro  1024x579  AR 1.769   no crop — matches the box
         madai    762x382     1.995   78.2px of WIDTH
         crab     776x544     1.426   85.4px of HEIGHT
         hamachi  820x440     1.864   32px of WIDTH
         uni      616x410     1.502   63.3px of HEIGHT
         shrimp   789x605     1.304   126.5px of HEIGHT  ← legs and antennae

     Contain letterboxes inside the same 624x352 box instead, so the box's
     geometry — which IS read from `223:1325` — is untouched and every product
     is shown whole. ⚠️ Maguro therefore does not move at all, which is what
     keeps this page's measured fidelity against the node intact.

     ⚠️ `scripts/check-image-cover.mjs` must not be "fixed" to flag this. Its
     question is whether an image covers a box that exists to CLIP it; this box
     does not clip, and the tool already excludes boxes that deliberately hold
     something smaller. */
  object-fit: contain;
  /* The node carries rotate(180deg) AND scaleY(-1). Composed, that is a plain
     horizontal flip — one transform that cannot fall out of step with itself. */
}
.product-hero__photo.is-flipped { transform: scaleX(-1); }

/* ---- The callout tags — `223:1348` / `223:1355` / `223:1349` ------------- */

.product-hero__tags {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  pointer-events: none;
}
.product-hero__tag {
  position: absolute;
  left: var(--tag-x, 50%);
  top: var(--tag-y, 50%);
  display: flex;
  align-items: center;
  gap: 10px;                /* the file's own 10, between a 10×8 mark and the label */
  white-space: nowrap;
}
.product-hero__tag-dot {
  flex: 0 0 auto;
  width: 10px;
  height: 8px;
  color: var(--ooc-navy-blue);
}
.product-hero__tag-label {
  font-family: var(--ooc-font-sans);
  /* Suisse MEDIUM 14/14 — read from 223:1346, and NOT the `body S` token it
     looks like (14/20 Book). Solid leading and a heavier weight; assuming the
     token here would have been wrong in two properties at once. */
  font-size: 14px;
  line-height: 14px;
  font-weight: 500;
  color: var(--ooc-navy-blue);
}

/* ---- The thumbnail strip — `223:1413` ----------------------------------- */

.product-hero__thumbs {
  position: absolute;
  /* MOBILE: 20/720 carried across, the stage being `inset: 0` below 1025. The
     desktop block sets this flush to the stage instead. Anchored to the BOTTOM
     because the design insets it 20 from there (669 + 111 = 780 of 800);
     encoding that as `top: 83.6%` is precisely the lossy translation that
     broke the /products hero — correct only while nothing above it changes
     height. */
  left: 2.7778%;
  right: 0;
  bottom: 2.5%;
  display: flex;
  gap: 1.4286%;             /* 10/700 of the visible strip */
  margin: 0;
  padding: 0;
  list-style: none;
  /* The strip is 790 wide in a 700 window, so it MUST scroll: the fifth
     thumbnail is content, and clipping it without a way to reach it would hide
     it from every reader rather than only from the first glance. Same
     deliberate over-run as the testimonials row and the products carousel —
     but the file gives this one no drag cue, so none is invented. */
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
}
.product-hero__thumbs::-webkit-scrollbar { display: none; }

.product-hero__thumb {
  flex: 0 0 21.4286%;       /* 150/700 */
  aspect-ratio: 150 / 111;
  border-radius: 12px;
  overflow: clip;
  background: var(--ooc-light-2);
  scroll-snap-align: start;
}
.product-hero__thumb-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.product-hero__thumb-img.is-flipped { transform: scaleX(-1); }

/* ---- The info column — `223:1407`, 560×606 at x800 ---------------------- */

.product-hero__info {
  /* Mobile: the content column, gutter both sides. Desktop re-places it. */
  padding: 40px var(--ooc-gutter, 20px) 0;
}

/* The row holding the badge and the availability pill — DERIVED. The file
   draws one pill and therefore no row; two of them need a gap and a wrap rule
   that `223:1366` cannot supply. 10px is the file's own smallest itemSpacing in
   this column (`360:2669`), reused rather than picked.

   `flex-wrap` is insurance, and the measurement says so rather than the guess
   that went here first: at 393 the two pills measure 119.5 + 10 + 158.2 =
   287.7 in a 353 column, so they DO fit today. What they fit is the badge
   string marketing happens to have typed. "Now in season" is a free-text field
   — a longer one with nowrap would push the second pill straight through the
   gutter, and nothing in the layout would warn anyone. */
.product-hero__pills {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

.product-hero__badge {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* 18 left, 18.363 right around the label inside a 120.363×24 pill. */
  padding: 0 18px;
  height: 24px;
  color: var(--ooc-navy-blue);
}
/* ⚠️ NO OVERRIDES, ON PURPOSE — and the empty rule is the record of that.
   The unavailable pill is the badge's component at full strength: same drawn
   outline, same navy, same 12/12 Suisse Medium. Only the label differs, because
   the label is the whole of the difference. Dimming it or recolouring it would
   be inventing a visual state the file does not contain (Hard rule 1), and it
   would make the page's most load-bearing fact its faintest.
   The class exists so the state is addressable — from a test, from a future
   design that does draw it — not because it changes anything today. */
.product-hero__badge--unavailable { /* intentionally empty — see above */ }
/* ⚠️ EMPTY FOR THE SAME REASON, and the reason is worth restating rather than
   cross-referenced, because this one is a step further from the file: the Figma
   draws no origin AT ALL, where it at least draws the badge this borrows.
   Added 2026-08-06 on Vitaliy's approval because One Ocean's own Instagram
   sells on provenance — Nagasaki bluefin, Tsushima nodoguro, Akkeshi oysters.
   Rendering it as `223:1366`'s pill unchanged means the derivation adds a
   FIELD, not a look: no new geometry, no new type size, no new colour, nothing
   for the gate to re-measure. The class is a hook for a future design that
   does draw one.

   ⚠️ MEASURED 2026-08-06, because a third pill is what finally makes this row
   wrap — and NO PROBE COVERS IT. `product-hero.probes.json` has no `pills`
   entry, so the section scores 100% either way. This is the footer-wordmark
   blind spot, so the numbers live here instead:

     origin + badge          desktop  row 560x24, 1 line, title y=156
                             mobile   row 353x24, 1 line, title y=513.3
     + "Currently unavailable"
                             desktop  row 560x24, 1 line, title y=156   (unchanged)
                             mobile   row 353x58, 2 LINES, title y=547.3 (+34px)

   Values: origin 142.5px ("Akkeshi, Hokkaido", the longest in the roster),
   badge 119.5px, availability 158.2px. At 393 the first two fit one line with
   81px spare; all three total 420px against a 353px row, so they wrap.

   THE WRAP IS ACCEPTED, not a defect: no page overflow at either width, nothing
   clips, and the alternative is suppressing a pill. The partial deliberately
   does NOT suppress — in season and sold out today is an ordinary week for a
   fishmonger, and origin is true regardless of either. A title 34px lower on
   the rare three-pill product is the cheaper outcome than silently hiding a
   fact a marketer typed. */
.product-hero__badge--origin { /* intentionally empty — see above */ }
.ooc-pill__outline {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The drawn outline stretches to whatever the label needs — see the note on
     preserveAspectRatio in ooc_pill_icon(). */
}
.ooc-pill__label {
  position: relative;
  font-family: var(--ooc-font-sans);
  font-size: 12px;          /* Suisse Medium 12/12 — 223:1370 */
  line-height: 12px;
  font-weight: 500;
}

.product-hero__titles { margin-top: 3.5714%; }   /* 20/560 */

.product-hero__title {
  margin: 0;
  font-family: var(--ooc-font-display);
  font-feature-settings: var(--ooc-font-display-features);
  font-size: var(--ooc-fluid-h2);   /* 50 → 100, the h2 token */
  font-weight: 400;
  line-height: 1;                   /* 100/100, set solid */
  /* ⚠️ PIXELS, NOT em — and this was got wrong once, so it is worth the note.
     h3 stores its tracking as a PERCENT (−1% → −0.64px at 64px), which invites
     the inference that h2's −2 is −2% as well. It is not: §1 records h3 as the
     ONLY percent one, and the home MOBILE artboard keeps −2px at 50px, which is
     a reading rather than an inference. As −0.02em this resolved to −1px at the
     mobile end — invisible at 1440, where both spellings give −2px, and wrong
     only at 393. */
  letter-spacing: var(--ooc-h2-ls);   /* −2px at both reference widths */
  color: var(--ooc-navy-blue);
}

.product-hero__description {
  margin: 5.7143% 0 0;              /* 32/560 */
  font-family: var(--ooc-font-sans);
  font-size: 16px;
  /* ⚠️ 22, NOT the `body M` token's 20. Every other 16px run in this file is
     16/20; this one is detached. Built as read rather than "corrected" to the
     token — docs/content-flags.md. */
  line-height: 22px;
  font-weight: 450;
  color: var(--ooc-navy-blue);
}

/* ---- The accordions — `223:1379` ---------------------------------------- */

.product-hero__panels {
  margin-top: 14.2857%;             /* 80/560, the info column's outer gap */
  border-top: 1px solid rgb(0 39 101 / 0.10);
}
.product-hero__panel {
  padding-block: 20px;
  border-bottom: 1px solid rgb(0 39 101 / 0.10);
}
/* That pair reproduces the file exactly rather than approximately. Against the
   node: rule 0–1, header 21–37, body 47–127, rule 147–148, header 168–184, rule
   204–205, header 225–241, rule 261–262 — a 262-tall block, which is the height
   `223:1379` reports. Every gap in it is the same 20. */

.product-hero__panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  cursor: pointer;
  list-style: none;
}
.product-hero__panel-head::-webkit-details-marker { display: none; }
.product-hero__panel--inert .product-hero__panel-head { cursor: default; }

.product-hero__panel-label {
  font-family: var(--ooc-font-sans);
  font-size: 16px;
  line-height: 16px;
  font-weight: 600;                 /* Suisse Semibold — 223:1396 */
  letter-spacing: -0.02em;          /* −0.32px at 16 = −2%, as em down the ramp */
  color: var(--ooc-navy-blue);
}
.product-hero__panel-icon {
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  color: var(--ooc-navy-blue);
  transition: transform 160ms ease;
}
/* The file draws the OPEN accordion with the same chevron mirrored — it is one
   asset, not two, so the open state is a flip rather than a second icon. */
.product-hero__panel[open] > .product-hero__panel-head .product-hero__panel-icon {
  transform: scaleY(-1);
}
@media (prefers-reduced-motion: reduce) {
  .product-hero__panel-icon { transition: none; }
}

.product-hero__panel-body {
  margin-top: 10px;                 /* the file's gap between header and body */
  font-family: var(--ooc-font-sans);
  font-size: 14px;
  line-height: 20px;                /* the `body S` token, and here it agrees */
  font-weight: 450;
  color: var(--ooc-navy-blue);
}

/* ---- The stacked band's cap -------------------------------------------- */

@media (max-width: 1024px) {

  /* ⚠️ WITHOUT THIS THE STACKED HERO BALLOONS. The gallery is a 0.9 aspect
     canvas, so at full bleed its height tracks the viewport width: measured
     1024×1138, making the hero 1730px tall at 1024 against 580px at 1025 — a
     three-fold jump across one pixel of viewport. A ratio carried unbounded
     across the band is the same class of mistake as a fixed px carried across
     it, and the structural checks at 860/1100 are where both show up.

     The answer is not a new one: `collage` hit this exactly (a 2.7x-tall
     composition that would have been 2809px at 1024) and the approved fix was
     to cap the canvas at 560 and centre it, leaving the page colour either
     side. This is that same cap, for the same reason.

     The info column's 560 is not a judgement at all — it is the column's READ
     width at 1440 (223:1407). Capping there means the copy measure below the
     breakpoint is the measure the design actually specifies, rather than an
     820px line at 860. 600 = 560 + the two 20px gutters. */
  .product-hero__gallery {
    max-width: 560px;
    margin-inline: auto;
  }
  .product-hero__info {
    max-width: 600px;
    margin-inline: auto;
  }

}

/* ---- Page shell -------------------------------------------------------- */

/* The light nav's INK with a dark hero's BLEED. `ooc_nav_variant()` returns
   light for a product single, which is right for the ink, but
   `body.nav-light main` then adds the nav's height as top padding — and this
   hero is drawn with the gallery at y=0 and the nav overlaying it at 0…80,
   with the info column starting at 140. Padding main down would move the
   gallery out from under the nav and make the whole page 80px too tall. */
body.single-product main { padding-top: 0; }

/* ---- Desktop — the 1440 design ----------------------------------------- */

@media (min-width: 1025px) {

  .product-hero {
    display: grid;
    /* The gallery is exactly half the frame and bleeds to x=0; the right track
       is the remaining 720. Neither edge is the content column, which is why
       neither is expressed in terms of it. */
    grid-template-columns: 50% 1fr;
    /* ⚠️ ONE ROW, PINNED TO THE BAND. Without this the row is sized by its
       tallest item — the gallery, a 720/800 aspect canvas — so the section
       honoured `aspect-ratio` and reported 640 while its CONTENT stayed 800 and
       spilled 160px over the section below it, thumb strip and all. Measured
       exactly that way first: section 1440x640, gallery 720x800, thumbs at
       y=669 in a 640 band. `1fr` makes the row the band's own height, which is
       what `align-self: stretch` on the gallery then fills. */
    grid-template-rows: 1fr;
    align-items: start;
    /* ⚠️ THE SAME TWO DECLARATIONS AS `.hero--h640`, VERBATIM — and this is a
       DELIBERATE DEPARTURE FROM `223:280`, which is drawn 1440x800.

       Vitaliy 2026-08-05, third time of asking and the reason given plainly:
       "on single product hero with product is too high you cant even see
       gallery — should fit screen and have same height rules as hero section on
       services or about page." At 1.8 the band was 1422.22 at 2560, which on a
       2560x1440 display is the entire screen and puts the thumb strip at or
       past the bottom edge. At 2.25 it is 1137.77 — 284.45 shorter, and the
       gallery is visible without scrolling.

       So the ratio is the SITE-WIDE inner-page hero rule, not this node's own:

           width   1440    1920    2560     (identical to /services, /products,
           height   640   853.33  1137.77    /about and /new-york-delivery)

       ⚠️ THE 1440 BAND IS THEREFORE 640, NOT THE NODE'S 800. Everything the
       spec records for this section's desktop geometry below the band is
       consequently a CLIENT DECISION and not a reading — product-hero.desktop
       .json says which is which, line by line. This is the first place on the
       project where the build deliberately disagrees with a node at the
       reference width, so it is written down loudly rather than quietly
       re-measured into the spec.

       What absorbs the 160px: the gallery fills the band (`align-self:
       stretch`), so it is 720x640 and the pattern re-covers; the photograph
       keeps its 624x352 and simply re-centres, 224 -> 144; the thumb strip
       stays bottom-anchored at 2.5%, 669 -> 513; and the info column's top
       inset drops 140 -> 112, which is the same 17.5% of the band the design
       uses. The tags moved onto the photograph itself so they stay on the fish
       rather than on a box that changed height — see `.product-hero__tags`. */
    min-height: 640px;
    aspect-ratio: 1440 / 640;
    /* ⚠️ PINS THE INLINE AXIS, and `.hero` does not need it because `.hero` is
       not a grid. Below 1440 the `min-height` wins over the ratio (640 against
       1025/2.25 = 455.6), which makes the BLOCK size definite — and for a grid
       container Chrome will then run the ratio the other way and solve the
       inline size from it. Measured without this: at a 1025 viewport the
       section reported 1440 wide with 415px of horizontal overflow, and 340 at
       1100, 140 at 1300, resolving only at 1440 where the two agree. /services
       at the same widths is 1025/1100/1300 wide with 0 overflow throughout.
       With the inline size definite the ratio can only drive the height. */
    width: 100%;
  }

  /* ---- The capped stage arrives with the two-column grid ---------------- */

  .product-hero__stage {
    /* (1440 − 2×20)/2 = 700, written from the token rather than as a magic
       number so it follows if the container ever changes. `100%` here is the
       GALLERY's width, which is why this is `100% − 700` and not the page-level
       `(100% − 1400)/2`. Below the reference width the `max()` holds it at the
       flat 20px gutter and the stage narrows with the gallery. */
    left: max(20px, calc(100% - (var(--ooc-container-max) - 40px) / 2));
  }

  .product-hero__photo-box {
    /* 28/700 and 624/700 — of the STAGE, not the gallery. The design centres
       the photograph in the 720 gallery (48 either side), and above 1440
       "centred in the gallery" and "on the grid" stop being the same place: at
       2560 centring would put it at x=328 against a content column starting at
       580, which is the overhang Vitaliy circled. Anchored to the stage it is
       48 at 1440 — the node — and 608 at 2560, still 28 in from the column
       edge, and 624 wide at both instead of inflating to 1109.3. */
    left: 4%;                 /*  28/700 */
    width: 89.1429%;          /* 624/700 */
    transform: translateY(-50%);
  }

  .product-hero__thumbs {
    /* Flush to the stage, which already starts at the content gutter — so this
       is still the design's `left: 20` at 1440, and on the grid above it where
       it used to run 544.5px past the column's left edge. */
    left: 0;
  }

  .product-hero__gallery {
    /* Fill the band's height rather than only its own aspect box. At and above
       1440 these are the same number — the gallery is 800 and the info column
       is 746 (140 + 606), so the gallery is what sets the row — and this
       changes nothing there. It matters in the narrow slice just above the
       1025 breakpoint, where the copy wraps enough to make the INFO column the
       taller item: at 1025 the row is 579.77 while the gallery's own ratio
       gives 569.44, so 10.33px of bare page showed under a panel the design
       draws meeting the section's bottom edge square (which is why the radius
       is on the top corners only). Measured ratio 1.768 rather than 1.800 —
       the only width in the range that missed it. */
    align-self: stretch;
    /* ⚠️ WITH ONLY `align-self`, THE ASPECT-RATIO STARTS DRIVING THE WIDTH.
       A stretched height plus `aspect-ratio: 720/800` and an auto width means
       the box solves for width from height: at 1025 the gallery came out
       521.78 against its 512.5 column — 9.28px past the viewport centre, so
       the 50/50 split the design draws quietly stopped being 50/50. Pinning
       the width to the column makes both axes definite, the ratio yields, and
       the only thing that stretches is the height. At 1440 this is 720x800
       either way, so the reference width cannot tell the difference. */
    width: 100%;
    /* ⚠️ AND THE RATIO HAS TO BE RELEASED, or none of the above happens.
       `aspect-ratio: 720/800` with a definite width BEATS `align-self: stretch`
       and `grid-template-rows: 1fr` in Chrome — the item keeps computing its
       own 800 and simply overflows the area it was given. Measured with both of
       those in place and the ratio still set: section 1440x640 (correct) but
       gallery 720x800, spilling 160px, with the thumb strip 140px BELOW the
       band and sitting over the section underneath. The section's own
       `aspect-ratio: 1440/640` is the height rule now, so the gallery must not
       carry a second, contradictory one. Mobile keeps it — there the gallery IS
       the aspect canvas, which is why this is an override and not a deletion. */
    aspect-ratio: auto;
  }

  .product-hero__info {
    /* Inside the right track: the column starts at frame-x 800 (80 into the
       720 track) and is 560 wide, so it ends at 1360 — 60 short of the 1420
       gutter. Percentages of the track, not of the frame.
       Percentage padding resolves against the containing block's INLINE size,
       which is what makes the 140 top ride the width proportionally. */
    /* 112/720. The node insets this column 140 into an 800 band — 17.5% of the
       band height — and the band is 640 now, so the same 17.5% is 112. Kept as
       a % of the TRACK (not the band) because percentage padding resolves
       against the containing block's inline size; 0.175 x (w/2.25) / (w/2) =
       15.5556% is the same relationship expressed where CSS can evaluate it.

       ⚠️ Load-bearing, not cosmetic: at 140 the column measured 655.98 tall
       (140 + 515.98 of content) against a 640 band, so the grid's content
       minimum would have overridden the aspect-ratio and the band would have
       come out 655.98 — the declared rule silently not applying at the one
       width it is defined for. At 112 it is 627.98 and the ratio holds. */
    padding: 15.5556% 0 0;          /* 112/720 — 17.5% of the 640 band */
    /* THE PAGE'S COMPOSITION GUARD, now that the band bleeds. The left half is
       a picture and scales with the viewport; this half is TYPE and must not,
       or the description's measure runs away — 560 → 995.6 at 2560, which is
       ~120 characters at the node's 16/22.

       ⚠️ NOT `margin-inline: auto`. That centres the frozen column in the right
       track — 1640..2200 at 2560 — which overshoots the content column's right
       edge by 220 and pushes the accordions past where the See More arrows
       end. It is the overhang Vitaliy circled on the right of his screenshot,
       and it is why "cap it and centre it" is not the same instruction as "put
       it on the grid".

       The offset is a CONSTANT 80px at every width, which is arithmetic rather
       than a chosen number: the design puts this column 780 into the 1400
       content column, so its left edge is (w/2 − 700) + 780 = w/2 + 80, and the
       track itself starts at w/2. `min()` says "proportional below the
       reference, frozen above" in one expression — below 1440 the percentage is
       the smaller of the two, above it the pixel value is.

       Measured: x800..1360 at 1440 (the node's own 800 and 560), x1360..1920 at
       2560 — 60 short of the column's 1980, the same 60 the design leaves at
       1440. The `padding-top` stays proportional on purpose: the band's HEIGHT
       scales, so the copy rides it exactly as /services' hero content does. */
    width: min(77.7778%, 560px);    /* 560/720, capped at 223:1407's read width */
    margin-inline: min(11.1111%, 80px) 0;   /* 80/720, capped at the node's 80 */
  }

}

/* == PRODUCTS — the browse variant ===========================================
   `Group 30` 223:1438. The same control bar as /products carrying different
   controls: heading left, a See More button and a prev/next pair right.
   ------------------------------------------------------------------------- */

.products--browse .products__controls-heading {
  /* ⚠️ NOT the shared size. `.products__controls-heading` is the h4 token
     (40→48) because /products says "Featued Products"; this one is the h3 token
     read from 223:881 at 64/68/−1%. Inheriting the shared rule would have been
     silently 16px small at 1440 with every probe still passing. */
  font-size: var(--ooc-fluid-h3);   /* 32 → 64 */
  line-height: 1.0625;              /* 68/64, kept as a ratio so it rides the ramp */
  letter-spacing: -0.01em;          /* −0.64px at 64 = −1% */
}

/* ⚠️ THE SHARED BAR'S MARGINS ARE TUNED FOR /products AND ARE WRONG HERE.
   `.products__controls` carries `margin-top: 100px` on desktop with the comment
   "intro ends 544 -> bar 644", and the bar -> grid gap is 32 for the same
   reason. Both numbers describe a page where a lede sits ABOVE the bar. This
   frame has no intro at all: `Group 30` starts at the section's own 240 top
   padding, and `Frame 1976` follows at 348.

   Measured before this rule: controls y340 (+100), grid y440 (+92), section
   1207.28 against the node's 1115 — the +92.28 that the composition-cap run
   surfaced and that nothing else explained. This is extract trap [C]: reusing a
   shared section leaks its tuned defaults, and the fix is to page-scope the
   gaps that differ rather than mutate the shared ones. */
.products--browse .products__controls { margin-top: 0; }
.products--browse .products__controls + .products__grid {
  /* 308 -> 348. The shared rule's 32 belongs to /products' composition. */
  margin-top: 40px;
}
@media (max-width: 1024px) {
  /* DERIVED (Hard rule 2), and not freely: the shared bar already carries this
     same gap at both reference widths — 32 desktop, 20 mobile — so the ratio
     0.625 is the design's own answer for how this particular gap scales.
     40 × 0.625 = 25. Picking any other number would be inventing a second,
     conflicting rule for one gap. */
  .products--browse .products__controls + .products__grid { margin-top: 25px; }
}

.products__browse {
  display: flex;
  align-items: center;
  /* See More ends at 1270 and the prev arrow starts at 1290. */
  gap: 20px;
  /* 260 − 240. The bar is `align-items: flex-start`, so the 48-tall button has
     to be pushed down to sit level with the 68-tall heading: the file bottoms
     them out together, both ending at 308. `.products__filters` does exactly
     this with its own read offset (25px, 669 − 644) rather than switching the
     row to flex-end, which would drag the heading's own box around too. */
  margin-top: 20px;
}
@media (max-width: 1024px) {
  /* The row stacks below the tier switch, so there is nothing to sit level
     with and the offset would just be dead space above the button. */
  .products__browse { margin-top: 0; }
}
.products__browse-label {
  /* ⚠️ WITHOUT `nowrap` THIS BUTTON IS 117 WIDE, NOT 146. "See More" broke at
     the space and the label collapsed to the width of "More" (36.69 × 40 — the
     40 is two lines at the inherited 20 leading, which is what gave it away).
     A flex item will not shrink below its MIN-content width, and min-content
     for a wrappable two-word string is its longest word, so nothing else in the
     layout had to be constrained for this to happen. The node carries
     `whitespace-nowrap`; it is load-bearing, not decoration.

     `line-height: 16` for the same reason it is read that way: `.ooc-button`
     inherits `body M`'s 20, and the node's label is 16/16. */
  white-space: nowrap;
  line-height: 16px;
}

.products__browse-cta {
  /* 146 is the INSTANCE's own width, not the content's. The Button master
     `186:15` is 260×48 and hugs at 20px padding (measured off its render: ink
     21..239, so padLeft 21 / padRight 20 — which is what `.ooc-button` already
     ships, so the shared component is right and nothing else moves). This
     instance is simply resized to 146 with the label centred: rendering
     `223:1528` alone and measuring its ink gives label 70px at x38..107, i.e.
     38px of space each side, against a 20px padding. So the extra 18 per side
     is the instance's width, and a min-width is the honest way to say that.

     ⚠️ It had been reaching 146 by carrying an ICON — 67.53 label + 10 gap +
     30.363 icon + 40 padding = 147.89 — and the icon does not exist here. The
     old comment cited `I223:1528;186:17` for its 30.363×24, which is real: the
     COMPONENT has that child. It is toggled OFF in this instance, and the node
     rendered on its own is bare text. A child read from the component
     definition rather than the instance is the `hidden="true"` trap again.

     min-width, not width, so the label can still push it wider if a marketer
     types a longer one; and 146 < 393 so it needs no mobile exception. */
  min-width: 146px;
  justify-content: center;
}

.products__browse-nav {
  display: flex;
  align-items: center;
  /* prev ends 1350, next starts 1360. */
  gap: 10px;
}
.products__browse-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 60px;
  height: 48px;
  color: var(--ooc-navy-blue);
  /* ⚠️ THE UA BUTTON CHROME HAS TO GO, and it was not needed until 2026-08-05.
     These were `<span role="button">` while they were inert; making the row
     scroll made them real `<button>`s, and a button arrives with a border, a
     background and padding of its own. The result was a grey box drawn around
     each arrow — Vitaliy spotted it against the design, which draws the arrow
     alone on the page. The design's own outline is the ELLIPSE inside the icon
     (`ooc_browse_arrow_icon`), not a rectangle around it. */
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  cursor: default;
}
.products__browse-arrow-icon {
  width: 100%;
  height: 100%;
  /* `overflow: visible` on the svg: the drawn blob runs a fraction past its own
     box at the top, exactly as the file's export does. */
  overflow: visible;
}
/* ONE asset for both arrows. The file draws prev as this rotated 180°, and
   because the chevron inside is symmetric about its own horizontal axis the
   rotation lands its offsets at 20.751/23.44 — which is where the file
   independently draws prev's chevron. Reproduction, not approximation. */
.products__browse-arrow--prev .products__browse-arrow-icon { transform: rotate(180deg); }

/* ---- The related row SCROLLS — Vitaliy 2026-08-05 ----------------------
   "You May Also Like should be scrolling, you have arrows so all products
   should be showing there." Scoped to `.products--browse`, which `show_browse`
   makes product-detail-only, so the home grid and /products are untouched — the
   same scoping the See More button uses.

   The card width is UNCHANGED at 453.33: (1400 - 2x20) / 3 is exactly what the
   3-column grid already produced, so three cards fill the content column and
   land where the design puts them. The rest sit past the right edge and the
   arrows page to them. Expressed against the track's own width rather than as
   453.33px so it stays correct below the reference width, where the column is
   narrower.

   ⚠️ `overflow-x: auto`, not `hidden` — the row must stay reachable by touch,
   trackpad and keyboard even though the arrows exist. The scrollbar is hidden
   because the design draws none, which is only honest while the arrows work. */
@media (min-width: 1025px) {
  .products--browse .products__grid {
    display: flex;
    grid-template-columns: none;   /* release the 3-track grid */
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;
    scrollbar-width: none;
    /* The cards carry the row's bottom spacing; without this the hidden
       scrollbar still reserves its gutter in some engines. */
    -ms-overflow-style: none;
  }
  .products--browse .products__grid::-webkit-scrollbar { display: none; }

  .products--browse .products__item {
    flex: 0 0 calc((100% - 40px) / 3);   /* 453.33 at the 1400 column */
    scroll-snap-align: start;
  }

  /* ⚠️ UNDO THE COLLAPSE. `.js .products__grid:not(.is-expanded)
     .products__item--beyond-desktop { display: none }` is a grid rule and this
     is no longer a grid — a hidden card cannot be scrolled to. `initial_desktop`
     is PHP_INT_MAX for this variant so the class should never be emitted, and
     this is the belt to that braces: if the field is ever set by hand in
     wp-admin the row degrades to fewer cards, not to a broken track. */
  .js .products--browse .products__grid:not(.is-expanded) .products__item--beyond-desktop {
    display: flex;
  }

  .products--browse .products__browse-arrow {
    cursor: pointer;
  }
  /* An arrow at the end of the track cannot move it, and must say so rather
     than looking identical and doing nothing — which is what the whole row did
     before today. */
  .products--browse .products__browse-arrow[disabled] {
    opacity: .35;
    cursor: default;
  }
}

@media (max-width: 1024px) {
  /* DERIVED (Hard rule 2): no mobile frame. A 146px button plus two 60px
     arrows plus their gaps is 296 against a 353 content column, which leaves
     the heading nothing — and the arrows are inert in any case, so the mobile
     derivation drops them rather than shrinking a control that does nothing.
     The See More button stays, because it is the only way onward from the row. */
  .products--browse .products__browse-nav { display: none; }
}

/* ── Gallery lightbox ───────────────────────────────────────────────────────
   ⚠️ NOT IN THE FIGMA. Vitaliy 2026-08-07: "on click these photos should
   enlarge." The file draws the thumbnail strip and no enlarged state, so every
   value here is DERIVED from tokens already in use rather than read from a
   node: the scrim is the navy at low alpha, the radius is the thumbs' own 12px,
   and the close control reuses the pill treatment.

   The thumb BUTTON is styled to disappear completely — the design draws no
   control in the strip, so the photograph must remain the whole target and
   nothing new may appear inside the 150x111 box. */
.product-hero__thumb-open {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
  /* The thumb <li> already clips to the design's radius; this must not add a
     second, differently-rounded box inside it. */
  border-radius: inherit;
  overflow: clip;
}
.product-hero__thumb-open:focus-visible {
  outline: 2px solid var(--ooc-navy-blue);
  outline-offset: 2px;
}

.ooc-lightbox {
  position: fixed;
  inset: 0;
  z-index: 300;                   /* above the header's 100 and the drawer's 200 */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgb(0 39 101 / .82);
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}
.ooc-lightbox.is-open { opacity: 1; pointer-events: auto; }

.ooc-lightbox__img {
  max-width: min(1200px, 92vw);
  max-height: 86vh;
  width: auto;
  height: auto;
  border-radius: 12px;            /* the thumbs' own radius */
  background: var(--ooc-light-2);
}

.ooc-lightbox__close {
  position: absolute;
  inset-block-start: 24px;
  inset-inline-end: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;                    /* the project's minimum tap target */
  height: 44px;
  border: 0;
  border-radius: 999px;
  background: var(--ooc-light-2);
  color: var(--ooc-navy-blue);
  font: 600 18px/1 system-ui, sans-serif;
  cursor: pointer;
}
.ooc-lightbox__close:focus-visible {
  outline: 2px solid var(--ooc-light-2);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .ooc-lightbox { transition: none; }
}
