/* ============================================================
   NHẬT MINH LEATHER SHOES — site.css
   THE stylesheet. Every page links this one file and nothing else.

   Moved out of index.html's inline <style> on 2026-07-30, verbatim and in
   order, so that 19 pages could share it instead of copying it. The previous
   attempt at sharing — assets/css/common.css, a hand-kept duplicate of the
   tokens — had already drifted: --field-2 read #F3F5F8 here and #EBEEF2 there.
   That file is retired. There is one copy of the design system and this is it.

   Deliberately NOT split into site.css + home.css. The homepage-only blocks
   (curtain, banner, turntable, the pinned making track) are ~1,000 lines that
   sub-pages never use, but splitting means duplicating @media queries and
   re-ordering rules across two files, and the cascade is the one thing worth
   protecting here. The homepage is the entry point for nearly every visit, so
   a sub-page arrives with this file already cached; the bytes it does not need
   are comments, which gzip away. One file, one order, no drift surface.

   Two things in here are load-bearing and easy to break by tidying:

   1. @font-face declaration order is latin -> latin-ext -> vietnamese, per
      family. ư U+01B0 and ơ U+01A1 sit in BOTH the vietnamese and latin-ext
      ranges, and "Bình Dương" puts them on the page. Last declaration wins,
      so vietnamese last means latin-ext is never fetched: 415KB -> 272KB.
   2. Rule order below is the original order. Sections are interleaved with
      globals on purpose in places (the ground joins, the responsive tail).
      Moving a block up or down can change which of two equal-specificity
      rules wins without changing a single value.

   ---- THE BREAKPOINT LADDER (added 2026-08-14) ----------------------

   max-width:  1280 · 1080 · 900 · 820 · 720 · 640 · 560 · 430
   min-width:  1280 · 1080 · 901 · 821 · 721 · 641

   USE ONE OF THESE. Nothing else. Before this was written down the sheet had
   33 distinct @media strings and ten of the widths — 1180, 1024, 1000, 860,
   760, 700, 600, 520 — were each one component's private number, invented
   because no list existed to consult. They are now folded onto the ladder.

   Pair min-width with the NEXT INTEGER above its max-width partner (901 not
   900, 821 not 820). At exactly 900px `max-width:900` and `min-width:900`
   BOTH match, and which one wins is decided by source order — a coin toss
   dressed as a cascade. Three blocks were on the wrong side of that.

   430 is deliberate and stays: it is the small-phone step where .stat takes
   its final collapse, below every other rung.

   Two numbers were measured and deliberately NOT moved onto a nearer rung;
   both are documented at the rule:
     - .geo's column flip. Forced to 5 columns at 1100 the cells fall to
       198px and the row grows from 359px to 477px. It flips at 1280.
     - .jobdetail and .geo both moved UP rather than down, which is the safe
       direction for a max-width: the simpler, narrower treatment covers MORE
       widths, never fewer. Moving one down hands a narrow band a layout that
       was never checked there.

   WHEN A COMPONENT SEEMS TO NEED A NEW NUMBER, it usually needs the rung
   above it. The real reason components keep asking is that the type scale
   freezes below 1320 (--step, ~line 311) and every spacing token bottoms out
   between 818 and 909 (--sec-pad..--g-section, ~line 283) — so under ~820 the
   token system stops responding and each block has to do the work by hand.
   Opening those up is a separate job with a separate sign-off; until then,
   expect hand-written blocks and keep them on the ladder.
   ============================================================ */

/* ============================================================
   NHẬT MINH LEATHER SHOES — HOME
   Direction: Industrial Editorial
   Type: Plus Jakarta Sans (sans) + Fraunces (serif, display) + IBM Plex Mono.
        Archivo is now down to ONE role — the NHẬT MINH wordmark. It lost the
         second one (standing in for the sans's missing Vietnamese) when
         Satoshi was replaced on 2026-07-28, because Jakarta ships all 90 of
         U+1EA0-1EF9 itself. See Decision Log in TRACKER.md.
   Colour: white field · navy = structure · brass = hairline only

   Self-contained per BUILD-BRIEF §9: inline CSS/JS, asset-relative
   paths, no CDN dependency. Fonts vendored under ../fonts/.
   ============================================================ */

/* ---------- Fonts (vendored; subset to latin/latin-ext/vietnamese) ----------
   Archivo is one VARIABLE file per subset covering weight 300-700 and width
   75-125%. The font-stretch descriptor is load-bearing, not decorative: the
   `wdth` font-variation-settings below are silently ignored without it.

   Archivo is no longer the page's sans — Plus Jakarta Sans is — and as of
   2026-07-28 it is down to ONE job, the wordmark: `.logo__name` and
   `.intro-curtain__name` stay Archivo on purpose (see those rules), matching
   the text in the supplied logo artwork: NHẬT MINH.

   It used to have a second job, and the reason it lost it is the whole point
   of the font swap. Satoshi's cmap held 2 of the 90 characters in U+1EA0-1EF9
   — no ậ ấ ạ ệ ợ ơ ư — so every one of those letters in Nhật Minh, Bình
   Dương, Phạm Bình, Hiệp Thành fell through to Archivo mid-word. Two
   typefaces inside one word is what "lỗi font" looks like to a Vietnamese
   reader. Jakarta ships 90/90, so the fallback is now a genuine safety net
   rather than a load-bearing part of rendering Vietnamese.

   DECLARATION ORDER IS LOAD-BEARING: latin, then latin-ext, then vietnamese.
   ư U+01B0 and ơ U+01A1 sit in BOTH the vietnamese and the latin-ext range,
   and "Bình Dương" / "thương hiệu" put them on the page. With vietnamese
   declared last, latin-ext ends up matching nothing and is never fetched.
   Measured: reordering these lines alone took the page from 415KB of fonts
   to 272KB, with no visual change. Put vietnamese back on top and you buy
   archivo-latin-ext (84KB) + fraunces-latin-ext (58KB) for two characters. */
@font-face{font-family:'Archivo';font-style:normal;font-weight:300 700;font-stretch:75% 125%;font-display:swap;src:url(../fonts/archivo-var-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'Archivo';font-style:normal;font-weight:300 700;font-stretch:75% 125%;font-display:swap;src:url(../fonts/archivo-var-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}
@font-face{font-family:'Archivo';font-style:normal;font-weight:300 700;font-stretch:75% 125%;font-display:swap;src:url(../fonts/archivo-var-vietnamese.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}
/* Plus Jakarta Sans: one VARIABLE file per subset, weight 200-800, no width
   axis. Unlike the Satoshi file it replaces, these ARE unicode-range gated, so
   the same latin/latin-ext/vietnamese order that governs Archivo above governs
   these too — and for the same reason. Read that note before reordering.

   Satoshi was declared with no unicode-range at all, which is what let it
   claim every glyph it had and silently yield the rest to Archivo. That
   arrangement is exactly the bug: silent per-character yielding is invisible
   in English and disfiguring in Vietnamese. Gating by range instead means
   each subset is fetched only when a character actually needs it. */
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:200 800;font-display:swap;src:url(../fonts/jakarta-var-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:200 800;font-display:swap;src:url(../fonts/jakarta-var-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}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:200 800;font-display:swap;src:url(../fonts/jakarta-var-vietnamese.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../fonts/plex-mono-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../fonts/plex-mono-400-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}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../fonts/plex-mono-400-vietnamese.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../fonts/plex-mono-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../fonts/plex-mono-500-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}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../fonts/plex-mono-500-vietnamese.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}
/* Fraunces — the serif exception. Roman only; no italic file is vendored,
   so nothing on the page may ask for italic or it synthesises a slant.
   Deliberately NOT preloaded: the first serif line sits below the fold, and
   a fourth preload would take bandwidth from the vietnamese Archivo that
   paints the wordmark. swap handles it. */
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url(../fonts/fraunces-var-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url(../fonts/fraunces-var-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}
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url(../fonts/fraunces-var-vietnamese.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}

:root{
  --paper:      #F7F5F2;
  --paper-2:    #EDE9E3;
  /* THE FIELD — the page ground. Tan for one afternoon on 2026-07-28 and
     REVERTED the same day: covering the ground in leather made the site read
     brown end to end and swallowed the white space the layout is built on.
     Leather belongs on the dark and brown BLOCKS, not on the ground between
     them — the ivory is what lets those blocks land as material.

     The token stays separate from --paper rather than collapsing back into it,
     and that separation is the reason this revert is four values instead of a
     39-site find-and-replace: 15 of the 24 `var(--paper)` uses in this sheet
     are `color:`, not `background:` — the light ink on navy. --paper is the
     ink; --field is the ground; they happen to be the same tone again. Keep
     them apart and the ground can be retinted without touching the ink. */
  --field:      #FFFFFF;
  /* Cool, not the old warm #EDE9E3. On an ivory ground a beige band was a
     second paper stock; on a pure-white one it is the single dirty patch on
     the page. Cool keeps it reading as a step in the same white rather than
     as a different material.

     TINTED 2026-07-28. Cool was right, ACHROMATIC was not: #F2F2F3 sat at
     hue 240 with 2% saturation, which belongs to neither of the two hues on
     this page (navy 205, gold 44) — the client read the band as dated, and
     an unaffiliated grey on a branded page reads as a framework default
     rather than as a decision. This value is --navy-ink at 8% over white, so
     the band is now a wash of the footer it runs into instead of a foreign
     grey between the white and the navy.

     LIGHTENED 2026-07-28 on client request ("xanh nhạt hơn"), 8% → ~5% of
     --navy-ink over white. The blue cast is kept deliberately — B still sits
     above G above R — so this is the same wash, thinner, not a slide back
     toward the achromatic grey that was rejected above.

     THE CEILING IS A CEILING, NOT A TARGET, AND IT ONLY BINDS DOWNWARD.
     --brass is ~3.4:1 on pure white, so every percent of tint here comes off
     the accent; 8% left the rail numeral and the → arrows at ~2.90:1 and this
     value hands most of that back. Going the other way (the #E2E7EC that was
     once on the table reads as a real material step) requires darkening the
     accent in this section to hold 3:1 — a change to a colour the client
     signed off on, so it does not happen as a side effect of a background
     tweak. Retint past #EBEEF2 and you owe the gold a value. */
  --field-2:    #F3F5F8;
  /* RETIRED 2026-07-28 with the ground going pure white. The run-up it fed —
     .m-scale / .m-products fading into navy — is a flat white now: a warm
     grey arriving over the last third is exactly the smudge the white was
     chosen to remove. Kept defined only so a stray var(--greige) resolves to
     something sane instead of transparent; nothing on the page uses it. */
  --greige:     #E7E2D8;
  /* Kept, unused on the ground. Bronze is the accent that survives on a brown
     surface, so it stays available for anything placed ON the leather blocks;
     it is no longer the page default. */
  --bronze:     #6E5228;
  --ink:        #14161A;
  /* Darkened from #5C5F66 on 2026-07-28. This token carries .lede and most
     running body copy, and at the old value that text sat around 4.9:1 on
     --paper — over the AA floor, but light enough that whole paragraphs read
     as grey furniture rather than as text. #45484F is ~7.4:1 and is half of
     the fix for the page reading "mảnh"; the other half is the body weight. */
  --ink-soft:   #45484F;
  --navy:       #014C86;
  --navy-deep:  #012E52;
  --navy-ink:   #01223D;
  /* RETIRED 2026-07-28: champagne #DDC79D read as pale ivory on the white
     field — the client's word for it was "ngà ngà". The tokens stay defined
     as a net for a stray var(--gold), but since 2026-08-07 they resolve to
     the SAME values as --brass/--brass-2 below: a token named "gold" holding
     the rejected champagne was a trap for anyone grepping for the accent by
     name. There are zero var(--gold) usages; if one appears it now paints
     the approved colour. */
  --gold:       #B08309;
  --gold-2:     #E8B92B;
  /* THE accent now. True gold — pushed from the first brass #AD7D1E on
     2026-07-28 ("vàng gold hơn"): same hue family with the muddying blue
     channel drained, value held close to the old brass so the small caps
     that carry it stay ~3:1 on the white field. Picked over cordovan
     specifically: the brand carries no red, and cordovan's red-brown read
     as exactly that on screen regardless of how clean it measured against
     the ground in a swatch. */
  --brass:      #B08309;
  /* Only ever the light stop inside a --sheen gradient — a second tone is
     what makes a sweep read as metal catching light rather than as a fade.
     Never a flat fill: the accent on this site stays a hairline and a
     numeral. */
  --brass-2:    #E8B92B;
  /* One gradient, three users: the folio numerals, the quiet-link underline
     and the filled button's edge. Carried as a token so the highlight cannot
     drift between them — three hand-written copies of a sweep is how one of
     them ends up a different tone.

     The stop positions are load-bearing and tied to background-size:300%. At
     that size the visible window is one third of the image, so position 100%
     shows only the 67-100% band and position 0% only the 0-33% band: BOTH ends
     of the travel are flat --accent, and the highlight at 44-56% is a thing
     that passes through rather than a bright state the numeral is left
     sitting in. Widen the window (or move the highlight off centre) and the
     sweep ends mid-flare instead of returning to accent. */
  --sheen: linear-gradient(100deg,
             var(--accent)   0%, var(--accent)   38%,
             var(--paper) 47%, var(--accent-2) 53%,
             var(--accent)  62%, var(--accent)  100%);
  /* THE ACCENT IS A CONTEXT TOKEN, NOT A COLOUR — which tone is correct
     depends on what a thing sits on, not on what it means. Brass is the
     default now that the field is white; the brown region re-declares
     below, and anything nested inherits the right tone without needing to
     know where it is.

     Bronze is still the value a leather block wants: on a brown surface
     brass sits close enough to the leather itself in hue and value that the
     eyebrow loses contrast — the same problem the old pale gold had there.
     That is why the token stays even though the default changed — the
     .tex-leather rule sets it.

     --sheen is built from these rather than from --brass directly, which
     is what makes one token enough: custom properties are substituted at
     the point of USE, so the same --sheen yields the right foil in each
     region with no second gradient. */
  --accent:     var(--brass);
  --accent-2:   var(--brass-2);
  --line:       rgba(20,22,26,.14);
  /* The one colour on the site that exists to say something is wrong. Added
     2026-07-30 with the enquiry form, which is the first thing here that can
     fail. 6.9:1 on --field and 6.2:1 on --paper, so the message reads as text
     before it reads as red — and it is never the ONLY signal: the copy says
     what is wrong and the border doubles in width beside it. */
  --error:      #8C2F16;
  --line-light: rgba(247,245,242,.18);

  /* Archivo still sits second, but its job here changed on 2026-07-28. Under
     Satoshi it was load-bearing — it rendered the Vietnamese the sans lacked,
     mid-word, on every page. Jakarta covers U+1EA0-1EF9 in full, so Archivo is
     now only a safety net for some glyph neither face was chosen for. Nothing
     on the page is expected to reach it. If you ever see Archivo's flat-sided
     letters appear inside a word again, that is the bug signature, not a
     fallback working as designed. */
  --f-display: "Plus Jakarta Sans", "Archivo", "Helvetica Neue", Arial, sans-serif;
  /* The wordmark only. Kept whole in one face so no letter of the company's
     own name comes from a different font than its neighbours. */
  --f-mark:    "Archivo", "Helvetica Neue", Arial, sans-serif;
  --f-mono:    "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
  /* Georgia, not the generic serif keyword, as the swap face: Georgia is
     metrically closer to Fraunces at display size, so the reflow when the
     real file lands is a nudge rather than a jump. */
  --f-serif:   "Fraunces", Georgia, "Times New Roman", serif;
  /* Hovered-heading weight for the four ruled lists (.cap-row, .cert,
     .prod-cap, .route-list). One token because they are one gesture — four
     hand-written 650s is how three of them end up at 600 a month from now.
     Jakarta carries 200-800, so this interpolates rather than snapping to a
     second file; 650 over a 500 rest is the smallest step that still reads as
     emphasis at 17-24px, which is the whole size range these titles cover. */
  --w-hover: 650;

  --pad: clamp(20px, 5vw, 80px);
  /* The LEFT gutter is its own token because the section rails need a lane
     that the symmetric --pad never provides: a rail stands at 40px, its tag
     at ~53px, and the folio's tick reaches back another 50px from the content
     edge — so content has to start past ~120px or the two collide in the
     margin. Equal to --pad until the rails appear — see the 1280px block at
     the foot of this sheet, which is the only place the two diverge. */
  --pad-l: clamp(20px, 5vw, 80px);
  /* Three numbers the rail marker has to agree with, so none of them is
     allowed to be written twice.

     --sec-pad was inline in .sec until the marker needed it. The marker is
     born level with its section's folio, and the folio sits exactly this far
     below the section's top edge — so the day .sec's padding is retuned again
     (it has been once already, see .sec) a marker holding its own copy of the
     number would silently drift off the line it is supposed to name.

     --rail-x is where the hairline stands. The folio's connector has to reach
     it, so the connector's length is now derived rather than eyeballed.

     --hdr-h is the fixed header's measured height. The marker parks under it
     rather than behind it. It does not change on scroll — see .site-head,
     which stopped resizing for exactly that reason. */
  /* Retuned down ~30% in the v2 consolidation (was clamp(52px,6.5vw,104px)).
     The old value was set when the page ran to eleven sections, several of
     them thin; at that density the wide bands read as breathing room. With
     eight denser sections the same gaps read as the page having run out of
     things to say — most visibly where Markets used to sit. Sections should
     feel deliberate, not sparse. The rail marker follows automatically
     because it derives its offset from this token rather than copying it. */
  --sec-pad: clamp(36px, 4.4vw, 72px);
  --g-pair: clamp(10px, 1.1vw, 16px);
  --g-group: clamp(20px, 2.4vw, 34px);
  --g-block: clamp(34px, 4vw, 58px);
  --g-section: clamp(44px, 5.2vw, 82px);
  /* --- body type scale ---------------------------------------------
     THREE TIERS, ONE BAND. The previous scale was three independent
     clamps — 1.02vw / 1.2vw / .9vw — which meant three different
     crossover points: the lede started growing at 1375px, body stayed
     frozen until 1520px and small until 1556px. Between those widths the
     tiers moved relative to each other, so the same page held a
     different type relationship at 1400 than at 1600. That is what read
     as "loạn": not any one size being wrong, but the ratios between them
     changing as the window changed.

     --step is the whole scale's only fluid quantity: 0 → 1px across
     1320–1720. Each tier adds a fixed multiple of it, so every tier
     crosses at the same two widths and the ratios are CONSTANT at every
     viewport — 1.09 between small and body, 1.09 between body and lede.
     Adding a tier means adding one line here, not a fourth clamp with a
     fourth opinion about when to grow.

     The top end came down (was 15/17/19 above 1667px). At that size the
     page read as set for a projector rather than a desk; the lede in
     particular was 19px against 17px body, which is a magazine standfirst
     scale on a paragraph that is often four lines long. Body at the
     working width is unchanged — 15.5px then, 15.54px now — so nothing
     that reads correctly today moves. */
  --step: clamp(0px, calc((100vw - 1320px) / 400), 1px);
  --text-lede:  calc(16.6px + var(--step) * .9);   /* 16.6 → 17.5 */
  --text-body:  calc(15.3px + var(--step) * .8);   /* 15.3 → 16.1 */
  --text-small: calc(14px   + var(--step) * .7);   /* 14.0 → 14.7 */

  /* WHICH TIER A NEW RULE TAKES. The scale was never the real problem —
     the assignments were. --text-small was carrying 280 of the 667 runs of
     text on the site, more than --text-body was, including 100-character
     paragraphs in .marks, the news excerpts, the footer blurb and the
     certification copy. A caption tier used for paragraphs sets two
     paragraphs 1.5px apart on the same screen: far enough apart to see,
     too close to read as a decision.
       --text-lede   the ONE paragraph directly under a heading. One per
                     section. If a section has two, the second is body.
       --text-body   every run of prose, wherever it sits — cards, table
                     cells, list rows, fact rows, footer, excerpts.
       --text-small  labels, captions, hints, disclaimers and anything
                     under about two lines that supports something else on
                     screen. If it is a sentence someone reads for its
                     content, it is body, not small.
     Sizes outside these three are for display type only (.h-display,
     .h-sec, .h-sub, .statement, .pull, .mono). No component invents a
     fourth body size. */
  --lh-body: 1.66;
  /* The ONE line-height for --text-small. It was 1.4, 1.5, 1.55, 1.58 and
     1.6 across the rules that used the token, which is a second axis of
     drift on top of the size one: two paragraphs at the same font-size
     and 1.5 vs 1.66 differ by 2.5px a line, and the looser one reads as
     the larger type. */
  --lh-tight: 1.5;

  /* --- the cell title ------------------------------------------------
     The name of a card, a row, a fact cell, a step, an article — the tier
     between .h-sec and body copy. It was thirteen separate clamps that all
     meant the same thing: 17→24, 17→21, 17→22, 17→23, 18→22, 18→24, 18→25,
     19→24, 19→26, 16→20, 16→19 … eleven different rendered sizes at 1440
     for one role, and no way to tell which was a decision and which was a
     copy-paste that drifted.

     Written as a MULTIPLE of --text-body, not as its own clamp: the point
     of the ratio is the relationship to the paragraph the title sits above,
     so that relationship should be the thing in the file. It also means the
     title inherits the body scale's band for free — one crossover, not
     fourteen. 1.35 lands at 20.7 on a phone and 21.7 at 1920.

     A context that genuinely needs a different rank states its own multiple
     of THIS token (see .stagestack below), so there is still exactly one
     number to change. */
  --text-title: calc(var(--text-body) * 1.35);
  --lh-title: 1.28;
  --measure-body: 58ch;
  --measure-narrow: 52ch;
  --rail-x: clamp(20px, 3vw, 40px);
  --hdr-h: 85px;
  --ease: cubic-bezier(.22,.61,.36,1);

  /* The one arrow the whole site uses. Source: assets/images/brand-icon-arrow-01.svg.
     Carried as a token rather than a file so it can be a mask — see
     .ico-arrow below for why that matters. */
  --arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round'%3E%3Cpath d='M2 10H18'/%3E%3Cpath d='M11.7778 5C12.3309 6.30952 14.3497 9.14286 18.0001 10'/%3E%3Cpath d='M11.7778 15.0001C12.3309 13.6906 14.3497 10.8573 18.0001 10.0001'/%3E%3C/svg%3E");
  /* The SAME glyph, turned a quarter turn, for the one control that points
     down instead of onward. A second icon would have been the start of a
     second icon system; a <g transform> keeps it one drawing. */
  --arrow-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round'%3E%3Cg transform='rotate(90 10 10)'%3E%3Cpath d='M2 10H18'/%3E%3Cpath d='M11.7778 5C12.3309 6.30952 14.3497 9.14286 18.0001 10'/%3E%3Cpath d='M11.7778 15.0001C12.3309 13.6906 14.3497 10.8573 18.0001 10.0001'/%3E%3C/g%3E%3C/svg%3E");
}

/* --- arrow icon ----------------------------------------------------
   The glyph is stroked, not filled, so shipping it as an image element
   would weld it to one colour and force a second file per tint. As a mask
   over `currentColor` it is instead whatever the element already is —
   brass on a .cap-row, paper on a navy .btn-solid, ink on a .btn-ghost, and
   navy-ink on any button once the gold hover fill is under it — from a single
   definition. Sized in em so it tracks the label it sits next to; 1.25em lands
   on the icon's native 20px at body size. */
.ico-arrow{
  content:""; display:inline-block; flex:none;
  width:1.25em; height:1.25em; background:currentColor;
  -webkit-mask:var(--arrow) center/contain no-repeat;
          mask:var(--arrow) center/contain no-repeat;
}

*,*::before,*::after{ box-sizing:border-box; }
html{ scroll-behavior:smooth; -webkit-text-size-adjust:100%; }
body{
  margin:0; background:var(--field); color:var(--ink);
  /* 450, not the inherited 400. Jakarta's 400 is a genuinely light text cut,
     and on a warm paper base at 16px it was a main cause of the page reading
     thin. 450 is only available because the face is variable — a static
     instance would snap this to 400 or 500, and 500 across all body copy is
     too heavy to sit under Fraunces headings without competing with them. */
  /* var(--text-body), not 16px. The base used to sit OUTSIDE the scale:
     16px is larger than --text-body was at any width below 1568px, so an
     element that forgot to opt into a token rendered bigger than the site's
     own body copy. Nothing does today — measured across 22 pages, no run of
     text resolved to 16px — but a baseline that disagrees with the scale is
     a trap for the next component, not a neutral default. */
  font-family:var(--f-display); font-size:var(--text-body); font-weight:450; line-height:1.6;
  -webkit-font-smoothing:antialiased;
  overflow-x:hidden;
}
body.is-locked{ overflow:hidden; }
img{ max-width:100%; display:block; }
a{ color:inherit; text-decoration:none; }
button{ font:inherit; color:inherit; background:none; border:0; cursor:pointer; }
:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }

.skip-link{
  position:absolute; left:-9999px; top:0; z-index:9999;
  background:var(--navy-ink); color:var(--paper); padding:12px 20px;
  font-family:var(--f-mono); font-size:12px; letter-spacing:.12em; text-transform:uppercase;
}
.skip-link:focus{ left:0; }

/* --- grain overlay -------------------------------------------------
   Fine, even photographic dust — NOT a leather tooth. It was briefly the
   coarse split-frequency hide grain at .13 while the ground was tan, and
   that is the single change that made the whole site read brown: a coarse
   multiply at that strength over ivory does not add tactility, it adds
   dirt, and it removes the clean white the layout is spaced against.

   The tooth belongs to the leather BLOCKS, where it has a brown ground to
   sit on. Page-wide, the job is only to stop large flat areas looking like
   untouched screen — which .055 does and anything heavier oversteps.

   Fixed, over everything: it sits over the ivory ground, the navy bands and
   the photographs alike, or the page separates into a textured part and a
   smooth part.

   This WAS `opacity:.055; mix-blend-mode:multiply` on raw turbulence. A fixed
   full-viewport blend layer forces the whole page into one blend group that
   re-composites every scroll frame — the single biggest scroll cost on every
   page. The multiply is now pre-baked into the texture's own alpha instead:
   multiplying by noise `c` at opacity `o` equals normal-compositing BLACK at
   alpha `o·(1−c)`, so the filter flattens the turbulence over white, takes
   luminanceToAlpha, and maps A → .055·(1−lum). The turbulence primitive stays
   in linearRGB (its raster was linear→sRGB converted before the old blend
   read it); the flatten/lum steps run in sRGB to match. Verified against the
   blend version by pixel diff: mean |Δ| ≤ 0.5/255 at 1x and 2x. */
body::after{
  content:""; position:fixed; inset:0; z-index:9998; pointer-events:none;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' color-interpolation-filters='linearRGB' result='t'/%3E%3CfeFlood flood-color='%23fff' result='w'/%3E%3CfeComposite in='t' in2='w' operator='over' result='f'/%3E%3CfeColorMatrix in='f' type='luminanceToAlpha' result='l'/%3E%3CfeComponentTransfer in='l'%3E%3CfeFuncA type='linear' slope='-.055' intercept='.055'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* --- type scale --------------------------------------------------- */
/* Both display tiers are Fraunces — the serif exception, applied at the two
   heading scales only. The tier below (.h-sub) and everything smaller stays
   Archivo, which is where the tension comes from: a reader meets a serif at
   statement scale and a sans the moment the text turns into information.

   Two things change alongside the family, and neither is cosmetic:
   - "wdth" is gone. Fraunces has no width axis (opsz/wght/SOFT/WONK only), so
     those declarations styled nothing once the face changed.
   - tracking relaxes from -.035em/-.03em to -.015em/-.012em. The tight
     negative values were compensating for Archivo's wide display cut; applied
     to a serif they drive the serifs of adjacent letters into each other. */
.h-display{
  font-family:var(--f-serif); font-optical-sizing:auto;
  font-size:clamp(36px, 6.4vw, 72px); line-height:1.0;
  font-weight:500; letter-spacing:-.015em; margin:0;
  text-wrap:balance;
}
.h-sec{
  font-family:var(--f-serif); font-optical-sizing:auto;
  font-size:clamp(28px, 3.9vw, 52px); line-height:1.08;
  font-weight:500; letter-spacing:-.012em; margin:0;
  text-wrap:balance;
}
/* Fraunces at 52-72px resolves a high opsz, which is exactly where its
   hairlines get thinnest. On paper that reads as refinement; reversed out of
   navy the thin strokes halate and the line looks eroded. One notch of weight
   on dark surfaces only, which is a legibility floor rather than a style. */
.on-dark .h-sec, .on-dark .h-display, .banner .h-display{ font-weight:600; }
.h-sub{
  font-size:var(--text-title); line-height:var(--lh-title);
  font-weight:500; letter-spacing:-.02em; margin:0;
}
.lede{
  font-size:var(--text-lede); line-height:var(--lh-body);
  color:var(--ink-soft); max-width:var(--measure-body); margin:0;
  font-weight:450; text-wrap:pretty;
}
.mono{
  font-family:var(--f-mono); font-size:11px; font-weight:500;
  letter-spacing:.16em; text-transform:uppercase;
}
/* The folio marker changes metal with its ground, not with its meaning. */
.idx{ color:var(--accent); }
/* m-intro's visible kicker is "Leadership", not a "01 / About" folio — that
   line-and-navy treatment is its own deliberate design, not the numbered-folio
   pattern the other sections use. But .sec-rail still needs a real folio to
   read 01 from (see the JS: no folio found = lozenge, which since 2026-07-28
   no section on the page uses — it is the fallback, not a design). This gives
   it one without adding a second, visually-clashing kicker to the section. */
.idx.rail-only{
  position:absolute; width:1px; height:1px; padding:0; margin:-1px;
  overflow:hidden; white-space:nowrap; border:0;
}

/* The one region that is NOT brass. White and navy both carry brass —
   that is the whole site — so brass is the default and leather is the
   exception, rather than the other way round as it was during the tan
   afternoon.

   Declared on the container, not on each brass thing inside it, so
   anything dropped onto a leather block later gets the right tone for free. */
.tex-leather,
.making-step .slot{
  --accent:var(--bronze); --accent-2:#A8834A;
}

/* --- accent foil ------------------------------------------------------
   Folio numbers are filled with the --sheen gradient instead of flat
   --accent, and the gradient slides when its section head is hovered. Two
   decisions worth keeping:

   The @supports gate is not ceremony. `color:transparent` plus background-clip
   is one of the few effects on this page that fails to NOTHING rather than to
   a plain state — no clip, no glyph — so the transparent colour is only ever
   declared where the clip is known to work. Everything outside the block keeps
   flat --accent, which is what the page shipped with.

   The trigger is .sec-head, not the section. Sections here run 900px tall, so
   hovering "the section" means the sweep fires whenever the cursor is anywhere
   on screen while scrolling — an effect firing constantly stops reading as a
   response to anything. Limited to the head, it answers the one gesture that
   was actually aimed at it.

   background-position, not a keyframe sweep: it reverses when the pointer
   leaves, it holds its end state without fill-mode, and a frozen timeline in a
   background tab leaves the numeral flat accent rather than mid-wipe. */
@supports ((-webkit-background-clip:text) or (background-clip:text)){
  .sec-head .idx,
  .band__body .idx{
    background-image:var(--sheen);
    background-size:300% 100%; background-position:100% 0;
    background-repeat:no-repeat;
    -webkit-background-clip:text; background-clip:text;
    color:transparent;
    transition:background-position 1.1s var(--ease);
  }
  .sec-head:hover .idx,
  .band__body:hover .idx{ background-position:0% 0; }
}

/* --- layout ------------------------------------------------------- */
.container{ width:100%; padding-inline:var(--pad-l) var(--pad); margin-inline:auto; max-width:1680px; }
/* Tightened from clamp(72px,10vw,150px) on 2026-07-28: at 1440px the old rule
   put 150px above AND below every section, so ~300px of paper separated one
   band of content from the next and the page read as underfilled. The vw term
   dropped 10→6.5 as well as the cap, because the runaway was worst on wide
   screens — that is where 10vw was outrunning the content it framed. */
.sec{ padding-block:var(--sec-pad); position:relative; }
/* Anchor landings. The header is position:fixed at --hdr-h, so a hash jump that
   parks a section's top edge at y=0 hides the section pad and clips the top of
   the h2 behind the bar — 13px of it at 1680, closer to 30px at 1280, and the
   whole folio row on narrow screens. Scoped to .sec[id] because only sections
   that are anchor targets can be landed on. The +24 matches the news-article
   TOC rule further down, which solved the same problem locally first. */
.sec[id]{ scroll-margin-top:calc(var(--hdr-h) + 24px); }
.sec-head{
  display:grid; grid-template-columns:minmax(0,120px) minmax(0,1fr);
  gap:clamp(16px,3vw,56px); align-items:start;
  /* Scaled with .sec above, and it has to be: this gap sets how far a section
     heading floats above its own content. Left at 64px against a 104px section
     pad, the heading reads as belonging to the whitespace rather than to the
     block it introduces. */
  padding-bottom:var(--g-block);
}
.sec-head .mono{ padding-top:.7em; }
.idx + .h-sec{ margin-top:var(--g-pair); }
/* The gap scale as classes (added 2026-08-07). Six elements across contact /
   capabilities / oem-odm carried these same declarations as inline style="" —
   repeated markup is how they drift; a class keeps them on the one scale. */
.tag--block{ display:block; }
.mt-pair{ margin-top:var(--g-pair); }
.mt-group{ margin-top:var(--g-group); }
.mt-section{ margin-top:var(--g-section); }
.sec-head .h-sec + .lede,
.sec-head .h-display + .lede,
.fac-grid .h-sec + .lede,
.band__body .h-sec + p{ margin-top:var(--g-pair); }
.sec-head + .cap-explorer,
.sec-head + .prod-grid,
.sec-head + .cert-list,
.sec-head + .route-list,
.fac-grid + .fac-reach{ margin-top:0; }
/* The folio's tick into the gutter — a short line that stops well short of
   the rail, so the folio addresses it rather than being tied to it.

   A long connector reaching all the way across was tried here and taken out
   again. On its own it was defensible, but it put a horizontal line into a
   lane that also held a vertical hairline and a vertical label, and three
   directions meeting in 70px of gutter is the thing the client saw first:
   "nhìn rất lung tung". The rail is one direction now, top to bottom, and
   nothing crosses it. What replaced the connection is proximity — see
   --rail-x in the 1280 block, which moved the rail in to 72px from the text.

   1.05em, not 50%: .mono carries padding-top:.7em, so the box's centre is
   below the type's. This lands on the x-height of the numerals. */
@media (min-width:1280px){
  .sec-head .idx,
  .band__body .idx{ position:relative; }
  .sec-head .idx::before,
  .band__body .idx::before{
    content:none !important;
    display:none !important;
  }
  .rails-on .sec-head > .idx,
  .rails-on .band__body > .idx,
  .rails-on .fac-grid > div:first-child > .idx{ display:none; }
  .rails-on .sec-head,
  .rails-on .band__body{ grid-template-columns:minmax(0,1fr); }
  .rails-on .sec-head > div,
  .rails-on .band__body .h-sec,
  .rails-on .band__body p{ grid-column:1; }
}
.rule{ height:1px; background:var(--line); border:0; margin:0; }
.on-dark .rule{ background:var(--line-light); }

/* --- image frame ---------------------------------------------------
   The gradient is not decoration for its own sake — it is what the frame
   shows while the photograph is still in flight, so a slow connection sees
   brand navy rather than a white hole. Photographs sit inside the frame,
   which is also what carries the frame-level transforms (banner slow-zoom).
   The one transform that deliberately sits on the image instead is the
   product hover push-in — see .prod .slot > picture, where
   moving the photograph *inside* a clipping frame is the whole effect. */
.slot{
  position:relative; overflow:hidden; background:var(--navy-ink);
  background-image:
    radial-gradient(120% 90% at 12% 8%, rgba(1,76,134,.85) 0%, transparent 62%),
    radial-gradient(90% 80% at 88% 96%, rgba(221,199,157,.20) 0%, transparent 58%),
    linear-gradient(148deg, #011E36 0%, #01345C 46%, #0B1116 100%);
}
.slot::before{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none; opacity:.22;
  background-image:
    repeating-linear-gradient(112deg, rgba(255,255,255,.055) 0 1px, transparent 1px 7px);
}
/* Over a real photograph the scanline reads as dirt, not texture — it drops
   to a whisper and only survives so the empty and filled frames still feel
   like the same object. */
.slot:has(img)::before{ opacity:.08; }
.slot > picture,
.slot > video,
.slot > img{ position:absolute; inset:0; width:100%; height:100%; }
.slot > picture > img{ width:100%; height:100%; }
.slot img,
.slot > video{ object-fit:cover; }

/* --- surface textures -----------------------------------------------
   Two grounds, defined once here and called by class, so no section ever
   hand-rolls its own noise. Both are ONE radial ground plus ONE grain
   layer. That is a rule, not a style: the vertical seam in the review
   render came from two gradients on different axes overlapping, and the
   only reliable fix is to never have a second one to misalign.

   The grain is an ::after at z-index 1 — a top overlay — where the source
   sketch used z-index:-1 behind the content. -1 paints below the block
   backgrounds of descendants, which is invisible on a flat band but wrong
   anywhere a child carries its own opaque fill: the grain would survive
   only in the gaps between those children and the block would read as a
   flat patch stamped on a textured band. (.m-scale was the case this was
   written against, back when its stat cells were opaque navy. It is paper
   now and no longer textured at all — .m-compliance's cert badges are the
   live example.) As a top overlay in `overlay` blend it modulates band and
   children alike, so the texture stays continuous across the join. Low
   opacity + pointer-events:none is what
   makes it safe to sit over text — the same trade the page-wide
   body::after grain already makes. */
.tex-navy,
.tex-leather{ position:relative; }
/* .overlay IS NOT IN THIS LIST, and putting it back will reopen a bug that
   has now been reported by the client three times. See the note on .overlay
   itself: the noise is a feTurbulence filter tiled as a background, and at a
   2x device pixel ratio Chrome rasterises the 300-unit tile at a scale that
   stitchTiles cannot stitch — so the menu came back as a visible GRID of
   identical blurry blobs. It is invisible in a 1x headless screenshot, which
   is why three rounds of tuning the numbers never caught it. */
.tex-navy::after,
.tex-leather::after,
.making-step .slot::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  mix-blend-mode:overlay;
  opacity:var(--tex-grain, .42);
  background-size:var(--tex-scale, 520px);
}

/* The ground stays --navy-ink rather than the sketch's brighter #0b5a97
   radial. m-compliance sits directly beneath the sustainability photo
   band and the two are built to read as one long dark stretch; a lighter
   base under the join would split them back into two colliding sections.
   The lift is a whisper laid on top of the token instead, and because it
   is a real background-image it paints BEHIND any opaque child — so open
   areas get the depth and blocks sitting on their own fill stay flat. */
.tex-navy{
  background-color:var(--navy-ink);
  background-image:radial-gradient(120% 100% at 32% 22%,
                     rgba(11,90,151,.34) 0%, rgba(2,56,99,.11) 46%, transparent 100%);
}
/* Again: NOT .overlay. baseFrequency 0.012 is about 83 user units per
   wavelength in a 300-unit tile — only ~3.6 wavelengths across it. stitchTiles
   can only hide the join when the tile is a whole number of wavelengths, and
   the browser's correction for that is computed against the RASTER size, so it
   lands differently at 1x and 2x. On a section the tile is small against the
   content and the seams do not read; across an empty full-screen panel they
   are a grid. */
.tex-navy::after{
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300' viewBox='0 0 300 300'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.012 0.02' numOctaves='4' seed='7' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
}

/* Leather is the warm accent, never a field: it is allowed on the making
   frames and nowhere else on this page, because every other slot on the
   site holds a real photograph and a texture under a photo is a texture
   nobody sees. */
.tex-leather,
.making-step .slot{
  background-color:#2c1810;
  background-image:radial-gradient(130% 110% at 35% 20%, #7a4a28 0%, #4a2c17 55%, #2c1810 100%);
}
.tex-leather::after,
.making-step .slot::after{
  --tex-grain:.5; --tex-scale:360px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7 0.9' numOctaves='4' seed='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
}
/* Five identical browns in a row stop reading as five hides and start
   reading as one sheet cut into strips. Varying them per frame is the
   cheapest way to keep them siblings rather than copies — no extra
   markup, no fifth asset.

   Brightness and saturation only, never hue-rotate: brown sits close
   enough to the yellow-green boundary that even 4-6deg of rotation turns
   two of the five olive, which stops looking like a different hide and
   starts looking like a colour bug. Luminance and intensity vary the
   frames without ever letting them leave leather. */
.making-step:nth-child(2) .slot{ filter:brightness(1.07) saturate(1.05); }
.making-step:nth-child(3) .slot{ filter:brightness(.92)  saturate(.97); }
.making-step:nth-child(4) .slot{ filter:brightness(1.03) saturate(.94); }
.making-step:nth-child(5) .slot{ filter:brightness(.96)  saturate(1.08); }
.making-step:nth-child(even) .slot::after{ --tex-scale:300px; }

/* Both devices above exist to serve an EMPTY frame, and both turn harmful the
   moment a photograph lands in one — the same trade .slot:has(img)::before
   already makes for the scanline.

   The per-frame brightness/saturate was keeping five identical gradients from
   reading as one hide cut into strips. Five different photographs already
   differ; grading them unequally instead makes the one blue uniform every
   worker wears land as a different blue in frame 2 than in frame 3, which
   reads as a colour bug in the photography, not as texture.

   Specificity is the whole reason :nth-child(n) is here and not decoration:
   the rules being overridden score (0,3,0), while .making-step .slot:has(img)
   scores only (0,2,1) — :has() inherits the specificity of its argument and
   `img` is a type selector. Without :nth-child(n) lifting this to (0,3,1) the
   guard silently loses and the filters stay on the photographs. */
.making-step:nth-child(n) .slot:has(img){ filter:none; }
/* Through the token rather than a hardcoded opacity, so the grain still
   answers to the one place that defines it. */
.making-step .slot:has(img)::after{ --tex-grain:.14; }

/* --- tex-paper: the light-ground variant ----------------------------
   Two things invert compared with .tex-navy, and both are the difference
   between texture and grubbiness on an ivory section.

   BLEND. `overlay` is near-inert against a light ground: it only pushes a
   base away from mid-grey, and ivory is already at the top of that range,
   so the same noise that gives navy its tooth does almost nothing here.
   `multiply` is the only blend that reads on paper — which is exactly why
   the page-wide body::after grain uses it and the dark bands do not.

   LAYER ORDER. .tex-navy's grain is a TOP overlay because it has to modulate
   the opaque .stat cells as well as the band. This one sits BENEATH the
   content instead, because .m-intro holds the chairman photograph: a
   multiply veil over a portrait does not texture it, it dulls it. Under the
   content the noise is the paper the section is printed on, which is the
   thing being asked for. It also means the opacity can run higher than the
   page baseline without touching the legibility of a single line of type.

   No background of its own: the element keeps whatever ground it already has,
   so a run-up gradient underneath still shows through.

   THE MASK IS NOT OPTIONAL, and it is the one thing a dark band does not need.
   A section boundary on navy is already a colour edge, so the grain layer's
   own edge lands on a line that exists anyway. Between two ivory sections
   there is no line — so the grain's boundary becomes the only edge on screen,
   and an unmasked layer draws a hard horizontal rule across the page at
   exactly the join (verified: the first render did). Feathering the last ~14%
   at each end is what makes it read as a sheet rather than as a panel.
   Lowering the opacity does NOT fix this: it dims the texture and leaves the
   edge, because the edge is a step, not a level. */
.tex-paper::after{
  content:""; position:absolute; inset:0; z-index:0; pointer-events:none;
  mix-blend-mode:multiply;
  opacity:var(--tex-grain, .10);
  background-size:var(--tex-scale, 240px);
  -webkit-mask-image:linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
          mask-image:linear-gradient(180deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7 0.9' numOctaves='4' seed='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)'/%3E%3C/svg%3E");
}
/* Everything in the section has to clear the sheet it is printed on. One rule
   on the direct children rather than on each block, so a section can grow a
   new child without silently acquiring a veil the others do not have. */
.tex-paper > *{ position:relative; z-index:1; }

/* Grain sized for a phone: the same 520px tile on a 390px viewport is a
   pattern you can count, not a texture. */
@media (max-width:720px){
  .tex-navy::after{ --tex-scale:364px; }
  .tex-paper::after{ --tex-scale:168px; }
  .tex-leather::after,
  .making-step .slot::after{ --tex-scale:252px; }
}

/* --- ghost-mark: the oversize monogram as a ground device ------------
   Lifted wholesale from .overlay::before, which is where this started and
   which the client approved on sight. It is the same three-part trick and
   all three parts are load-bearing:

     · the MARK, not an invented pattern — the fill stays brand-owned;
     · a very low opacity, because the logo is a hard-edged chevron and at
       anything like .05+ its diagonals read as artwork left on the page
       rather than as a change in the navy;
     · the MASK, which is what makes the low opacity survive. Flat opacity
       leaves every diagonal terminating in a crisp line, so however far
       the opacity drops you still see a faint LOGO. Fading the mark out
       toward the page's content dissolves exactly the edges that point
       back into the type and leaves presence only in the corner nothing
       else occupies.

   Everything a site has to vary is a token, so a new placement is a
   two-line rule and never a second copy of the block. --ghost-fade takes
   the gradient DIRECTION: point it at the content, i.e. the opposite
   corner from wherever the mark is anchored.

   The one thing NOT tokenised is the anchor sign. Offsets are negative on
   the horizontal only. body carries overflow-x:hidden, so a right:-6%
   bleed is free — it just crops at the viewport edge. Vertically there is
   no such clip: a mark hanging past the bottom of its section adds real
   document height, which is the bug .overlay::before's comment records
   paying for once already. Anchor to 0 and size the mark to fit instead.

   z-index:0 rather than -1: .tex-navy establishes no stacking context, so
   a negative index would drop the mark clean through the section's own
   background and out of sight. 0 puts it in the positioned layer, which
   also puts it above inline text — hence the .container lift below. The
   rail is z-index:2 and clears both without being touched. */
.ghost-mark{
  position:relative;
  /* CLIP, not hidden. The monogram sits at right:-6% and nothing was catching
     it, so every ghost-marked section added ~6% of its own width to the
     document scroll width — measured 1526 against a 1440 viewport, on the
     homepage as well as the sub-pages.

     overflow:hidden would fix the number and break the page: it makes the
     element a scroll container, and a position:sticky descendant then sticks
     to THAT box instead of the viewport, which silently kills the stepline
     numerals. overflow-x:clip is the one value that pairs with a visible
     overflow-y and creates no scroll container. */
  overflow-x:clip;
}
.ghost-mark::before{
  content:""; position:absolute; z-index:0; pointer-events:none;
  right:var(--ghost-x, -6%);
  top:var(--ghost-top, auto); bottom:var(--ghost-bottom, auto);
  width:var(--ghost-w, min(46vw,560px)); aspect-ratio:450/414;
  background:url("../images/brand-logo-mark-01.svg") no-repeat center/contain;
  filter:brightness(0) invert(1);
  opacity:var(--ghost-o, .05);
  /* Both the prefixed and unprefixed properties, as the prototype is checked
     in Safari as well as Chrome. The fallback in the var() is not decoration:
     an unresolvable direction invalidates the whole gradient, which drops the
     mask entirely and leaves a hard-edged logo at full opacity. */
  -webkit-mask-image:linear-gradient(var(--ghost-fade, to top left), #000 0%, rgba(0,0,0,.35) 42%, transparent 76%);
          mask-image:linear-gradient(var(--ghost-fade, to top left), #000 0%, rgba(0,0,0,.35) 42%, transparent 76%);
}
/* The content wrapper only, NOT `> *`. The .tex-paper precedent lifts every
   direct child, which is right there and wrong here: .sec-rail is
   position:absolute, and handing it position:relative would drop the rail
   back into the flow. Every host of this class puts its content in a
   .container, so one selector covers all of them and leaves the rail alone. */
.ghost-mark > .container{ position:relative; z-index:1; }

/* Two placements, deliberately not three. The mark is a grace note: on every
   navy surface in a row it stops being one and becomes wallpaper. These two
   are the page's first dark stretch and its last, so the device opens and
   closes the navy rather than running through it.

   .m-capabilities — top right, beside a heading that occupies barely half the
   measure and leaves the rest of that block as bare navy. Narrower and fainter
   than the footer's: it shares its air with the .cap-row arrows in the same
   right margin, and those are live controls the eye is meant to find first.

   footer — bottom right, the same corner and the same reading as the menu
   curtain the client saw. Sized to 430px so its 396px of height clears the
   footer's 473 with room: at the default 560 it would hang past the top edge
   and print the monogram across the contact column above. */
.m-capabilities.ghost-mark{
  --ghost-top:0; --ghost-w:min(38vw,470px); --ghost-o:.04;
  --ghost-fade:to bottom left;
}
.m-compliance.ghost-mark{
  --ghost-bottom:0; --ghost-w:min(46vw,560px); --ghost-o:.068;
  --ghost-fade:to bottom left;
}
footer.ghost-mark{
  --ghost-bottom:0; --ghost-w:min(48vw,380px); --ghost-o:.032;
}
/* Under ~700px the footer columns stack and there is no bare corner left to
   fill — the mark would sit behind the contact list instead of beside it. It
   stays, at half strength and pinned to the very bottom band, where the only
   thing above it is the copyright rule. */
@media (max-width:720px){
  footer.ghost-mark{ --ghost-w:min(78vw,320px); --ghost-o:.045; }
  .m-capabilities.ghost-mark{ --ghost-w:min(64vw,300px); --ghost-o:.03; }
  .m-compliance.ghost-mark{ --ghost-w:min(86vw,390px); --ghost-o:.048; }
}

/* --- the two sections that meet the dark ----------------------------
   .m-scale hands off to .m-capabilities, .m-products to the sustainability
   band. Both used to carry a run-up: flat for the first 68%, then a fade to
   --greige across the last stretch so the ground never butt-joined navy.

   THE RUN-UP IS GONE as of 2026-07-28 and the butt joint is now the point.
   The reason it existed was that ivory meeting navy left a "two sheets taped
   together" edge — but that reads as an edge only when both sides are near
   each other in value. Pure white against --navy-ink is a deliberate cut, not
   an accident, the same way a printed page ends at its trim. Re-introducing a
   warm grey over the last third is what a white ground cannot carry: it lands
   as a dirty smudge behind the last stat row rather than as a transition.

   Kept as one rule with both selectors because they are one idea, not two —
   and kept as a rule at all, rather than deleted, because the sections do need
   to declare the ground explicitly: they sit over the body's own background
   and a future retint of one region must have a single place to land. */
.m-scale,
.m-products{
  background:var(--field);
}
/* Half a section pad, not a full one: the making-foot above already hands
   this section ~--sec-pad of paper, so a full pad here doubled the air at
   exactly the seam the client flagged (2026-08-06). Other sections keep
   the full pad — only this seam has a donor above it. */
/* 721, not 4: the deeper reclaim pulls this section's head inside the
   .m-capabilities box, and that section stacks at 720 (the turntable
   system — the fixed shoe flies at 740 above it), so the pin's opaque
   .making__ground was painting over a heading that was present, revealed
   and correctly positioned (elementFromPoint said so, 2026-08-06). One
   above the section, still under the shoe. The grounds are the same
   paper, so the only visible change is the heading staying visible. */
.m-products{ padding-top:calc(var(--sec-pad) * .25); z-index:721; }
.scale-object{
  position:fixed; left:0; top:0; z-index:740;
  /* 18vw, and it is a ceiling rather than a preference. Three moves in one day
     (2026-08-14): 29vw → 16vw when the client asked that the shoe stop covering
     text, then up again on "giày to hơn chút, khoảng 20vw". 20vw does not fit —
     here is the arithmetic, so nobody has to rediscover it.

     THE CHANNEL the shoe rides in runs from the end of the LONGEST stat label
     to the left edge of the note column. Probed in page after the column shift:
     222px at 1200, 260px at 1440, 337px at 1920 — a near-constant ~18vw.

     THE FRAMES are not solid to their edges. Measured over the alpha of all 81
     webp frames (the union across the whole turn, not one pose): the shoe's ink
     starts 3.5% in from the left and ends 4.5% short of the right. So a box of
     width W shows only ~92% of W, and the path pins the VISIBLE edge rather
     than the box — see home.js. That 8% is exactly what lets 18vw sit inside an
     18vw channel with real air on both sides.

     At 20vw the ink alone is ~266px against a 260px channel at 1440, and the
     heel clipped the last characters of "MACHINERY & EQUIPMENT" — the same
     complaint that started this, one notch smaller. 18vw is the largest size
     that keeps every label and every paragraph clear at 1200, 1440 and 1920.
     To go bigger, the channel has to grow first: that means more off --col-num
     and the same amount onto --col-object, which moves the labels further left
     than the 24px the client asked for. Re-probe both before touching the 18. */
  width:clamp(210px,19vw,360px); aspect-ratio:16/9;
  pointer-events:none; opacity:0;
  /* The scroll-linked half of the transform, and nothing transitioned.
     The shoe used to ride on left/top, which re-lays-out and re-rasters a
     505px layer at a new position on every scroll frame instead of letting the
     compositor move a layer it already has — that is what the judder through
     this section was. The centring translate stays SECOND: percentages resolve
     against the element's own box, so it shifts the shoe by its own size only
     after --shoe-x/--shoe-y have put the anchor where it belongs.

     The .2s transform transition that used to sit here was the other half of
     it. --shoe-r is rewritten on every scroll frame, so that transition
     restarted every frame and never once ran to completion: the rotation
     permanently trailed the scroll by 200ms and smeared every correction into
     the next. Entrance easing moved to __inner, which changes only on a class
     toggle and can therefore afford to be transitioned. */
  transform:translate3d(var(--shoe-x,0px),var(--shoe-y,0px),0)
            translate3d(-58%,-50%,0) rotate(var(--shoe-r,0deg));
  transition:opacity .2s var(--ease);
  will-change:transform, opacity;
}
.scale-object.is-on{ opacity:var(--shoe-o,1); }
.scale-object.is-under{ z-index:710; }
/* The state half of the transform: scale, and only ever two values of it, so
   this .2s ease runs exactly twice — once on arrival, once on leaving. */
.scale-object__inner{
  display:block; width:100%; height:100%;
  /* The stacking context for the two cross-dissolving frames. */
  position:relative;
  transform:scale(.92);
  transition:transform .2s var(--ease);
}
/* Both frames fill the box and sit on top of each other; --b is the NEXT frame
   in the sequence and JS rides its opacity with the fractional position between
   the two. No transition — the opacity IS the scroll position, and easing it
   would put the same 200ms lag on the turn that the rotation used to have. */
.scale-object__frame{ position:absolute; inset:0; }
.scale-object__frame--b{ opacity:0; }
.scale-object.is-on .scale-object__inner{ transform:scale(var(--shoe-s,1)); }
.scale-object video,
.scale-object img{
  width:100%; height:100%; object-fit:contain; display:block;
}
/* The width term is not a tidy-up — it is a visible bug below 821. The shoe is
   placed off the .stat note's left edge, and below the 820 flip that note sits
   far enough left that the figure lands at x=-164..-180. It is 200px wide, so
   22-38px of shoe is painted at opacity 1 against the left margin. Measured at
   500/640/760/820, scrollY 1600; correct again from 860 (x=356).

   Touch already escaped this because pointer:coarse hid it. What was left was
   the mouse user with a window under 821 — split screen, a resized window —
   who got a fragment of a shoe and no way to tell what it was.

   KEEP IN STEP WITH home.js's spinHidden, per the note there. */
@media (hover:none), (pointer:coarse), (max-width:820px){
  .scale-object{ display:none; }
}

/* --- header ------------------------------------------------------- */
.header{
  /* 960 puts the bar ABOVE .overlay (950), which is the reference's behaviour
     and not a stacking accident. With the old 900 the curtain buried the
     lockup, so an opened menu was an unbranded navy rectangle with a lone X in
     one corner. Lit, the bar becomes the menu's own header — and the burger
     can morph into the close control instead of a second X landing in the
     exact same 22px/--pad corner the first one already occupied. */
  position:fixed; inset:0 0 auto 0; z-index:960;
  display:flex; align-items:center; justify-content:space-between;
  gap:24px; padding:22px var(--pad) 22px var(--pad-l);
  /* Padding is deliberately NOT in this list any more, and the header no longer
     changes size on scroll. It used to shrink 22px → 14px, which meant .45s of
     the browser re-laying out a fixed three-column flex row on every frame,
     every time the 40px threshold was crossed — and crossed back.

     What it bought was the least nameable of the four things the scrolled state
     already does: a paper background arrives, a 14px blur turns on, the ink
     flips navy, a bottom hairline appears. Nobody who has seen this header
     could tell you it also got 16px shorter. The condensing FEELING is kept
     below by scaling the logo, which is a transform and costs no layout at all.

     `color` is new here. It was never transitioned, so the paper→navy flip
     used to snap one frame while the background behind it took .45s to
     arrive — briefly navy-on-navy. */
  transition:background .45s var(--ease), color .45s var(--ease), border-color .45s var(--ease);
  /* A floor in the TRANSPARENT state too, where it used to be `transparent`.
     Over a moving video the bar had no bottom edge at all, so the lockup and
     the utility cluster read as three things dropped onto the footage rather
     than as one bar. At .14 the line is under the video's own contrast most of
     the time and only asserts itself over the pale frames — which are exactly
     the frames where the white type needed the help. */
  border-bottom:1px solid rgba(247,245,242,.14); color:var(--paper);
}
.header.is-scrolled{
  /* Clean, cool header surface. It deliberately does not track --field: even
     a warm off-white bar reads muddy once it floats over this page. */
  background:rgba(252,253,255,.94); backdrop-filter:blur(14px) saturate(140%);
  color:var(--ink); border-bottom-color:var(--line);
}
/* Open-menu beats scrolled, and has to say so twice — the second selector
   carries the extra class so it outweighs .is-scrolled rather than merely
   following it. Open the menu from mid-page without this and a blurred paper
   bar with navy ink sits on the navy sheet: the one combination that reads as
   a leftover strip of the page underneath instead of the menu's own header.
   backdrop-filter has to be switched off explicitly as well; a blur over a
   flat navy field costs a compositor layer and buys nothing to look at. */
.header.is-menu-open,
.header.is-menu-open.is-scrolled{
  background:transparent; backdrop-filter:none;
  color:var(--paper); border-bottom-color:rgba(247,245,242,.14);
}
.header.is-menu-open .logo{ scale:1; }
.header.is-menu-open .logo__mark{ filter:brightness(0) invert(1); }
.header.is-menu-open .logo__sub{ color:var(--accent); }
/* No .btn-cta override here any more. The button's hover is one fill and one
   ink on every ground (see the unified block below), so the open-menu state
   has nothing left to correct. */
/* The shrink, re-spent somewhere it is free. Scaling from the left edge keeps
   the mark pinned where it was and draws the wordmark in toward it, which is
   the same "tightening" read the padding change was reaching for — except a
   transform never touches layout, so the nav and the CTA beside it are not
   re-measured 27 times per crossing. */
.logo{ display:flex; align-items:center; gap:11px;
  transform-origin:left center; transition:scale .45s var(--ease); }
.header.is-scrolled .logo{ scale:.94; }
.logo__mark{
  /* 29, not 26. The lockup is two lines tall now; at 26 the mark sat visibly
     short of the descriptor's baseline and the pair read as a logo with a
     caption stuck under it rather than as one lockup. */
  width:29px; height:auto; flex:none;
  filter:brightness(0) invert(1); transition:filter .45s var(--ease);
}
/* The wordmark and its descriptor stack, so they are one flex child of .logo
   rather than two — otherwise `gap:11px` puts the descriptor BESIDE the name. */
.logo__text{ display:block; }
.header.is-scrolled .logo__mark{ filter:none; }
/* The wordmark is the one place the page still speaks Archivo. The original
   reason was the Ậ — under Satoshi this read N-H-[Ậ from some other font]-T,
   a swap of face, width and stroke weight inside the company's own name at
   the top of every page. Jakarta has the Ậ, so that reason is gone and this
   rule could now be --f-display without breaking.

   It stays Archivo on two grounds that survive the font swap: a logo set in
   its own face is normal, and the `wdth` 118 below is real only here. Jakarta
   has no width axis, so moving the lockup onto it would silently drop the
   extension and the wordmark would reflow narrower. */
.logo__name{
  font-family:var(--f-mark);
  font-weight:600; letter-spacing:-.01em; font-size:19px;
  font-variation-settings:"wdth" 118; line-height:1; white-space:nowrap; display:block;
}
.logo__sub{
  display:block; font-family:var(--f-mono); font-weight:500; font-size:9px;
  letter-spacing:.26em; margin-top:4px; color:var(--accent);
}
.header.is-scrolled .logo__sub{ color:var(--navy); }
/* The utility cluster. There is no inline .nav in the bar any more at ANY
   width: the lockup takes the left edge, this cluster takes the right, and
   every section link lives behind .btn-menu. That is why the button is no
   longer declared display:none here and switched on at 820px — it is now the
   only way into the site's sections on a 1600px monitor as much as on a
   phone, so a width-conditional menu button would be a hole in the site. */
.header-actions{ display:flex; align-items:center; gap:clamp(12px,1.5vw,20px); }
/* Bare glyphs, not the reference's filled discs. Three bordered circles
   sitting 14px from .btn-cta's own 1px border read as four buttons competing
   for one corner; at .62 opacity they read as what they are — quiet handles
   beside a single call to action, with .header-rule doing the separating. */
/* The sprite host. Not display:none — that has historically stopped <use>
   resolving — so it is collapsed to nothing and clipped instead. */
.icon-defs{ position:absolute; width:0; height:0; overflow:hidden; }
.social-row{ display:flex; align-items:center; gap:14px; }
.social-row a{ display:block; opacity:.62; transition:opacity .3s var(--ease), color .3s var(--ease); position:relative; }
/* 17px glyphs in the bar, 19px in the overlay — both under the 24px floor,
   and in the overlay they are the only social links a phone ever sees, since
   the bar drops its .social-row at 820.

   The hit box is a pseudo rather than padding because padding would push the
   flex row apart and re-space a cluster the header comment above tunes by
   eye. Width is glyph + one gap, NOT a flat 44: at gap 14 a 44px-wide box
   would reach 12.5px past a 19px glyph and overlap its neighbour's, so the
   right edge of one icon would open the next one's link. Glyph + gap makes
   adjacent boxes meet exactly — no overlap, and no dead strip between them.
   Height is free to take the full 44. */
.social-row a::after{
  content:"";
  position:absolute; top:50%; left:50%;
  transform:translate(-50%,-50%);
  width:calc(100% + 14px); height:44px;
}
/* Hover goes to the accent as well as to full opacity. Opacity alone made the
   three glyphs the only hover on the page that answers in the same colour it
   started in, while every link, row and button around them moves to brass. */
.social-row a:hover{ opacity:1; color:var(--accent); }
.social-row svg{ display:block; width:17px; height:17px; }
.header-rule{ width:1px; height:20px; flex:none; background:currentColor; opacity:.26; }
/* The trigger carries a word now. Two bare rules beside three bare glyphs was
   the whole right-hand cluster speaking in shapes; the reference labels its
   equivalent ("Contact" beside its icons), and one mono word is what tells a
   visitor the bars are the way into the site rather than a decoration. It also
   gives the button a real hit target — 34×18 of hairline is a hard tap. */
/* The hit target the note above only half-fixed. Adding the word solved the
   WIDTH — 34px of hairline became 91px — and left the HEIGHT at 20px, which
   is what a thumb actually has to land on. Measured 91x20 on all 14 template
   pages at 320/360/390, under the 24px WCAG 2.5.8 floor, on the one control
   that is the site's only way into its own navigation at every width.

   Padding plus an equal negative margin: the hit box grows to 44px, the outer
   size the header's flex row measures stays 20px, so nothing moves. */
.btn-menu{
  display:flex; align-items:center; gap:11px; flex:none;
  padding-block:13px; margin-block:-13px;
}
.btn-menu__label{
  font-size:10.5px; letter-spacing:.2em; opacity:.72;
  transition:opacity .3s var(--ease), color .3s var(--ease);
}
.btn-menu:hover .btn-menu__label{ opacity:1; color:var(--accent); }
.btn-menu__bars{ display:block; width:34px; height:18px; position:relative; flex:none; }
.btn-menu__bars i{
  position:absolute; left:0; right:0; height:2px; background:currentColor;
  transition:transform .4s var(--ease), left .4s var(--ease), right .4s var(--ease);
}
.btn-menu__bars i:nth-child(1){ top:2px; } .btn-menu__bars i:nth-child(2){ bottom:2px; }
/* The two bars part rather than rotate: the button opens a curtain that wipes
   down from the top edge, and bars that spread say "this is about to open"
   in the same direction the curtain travels. Guarded on the closed state — an
   open menu's X must not drift apart under the cursor that has to click it. */
.btn-menu:not([aria-expanded="true"]):hover .btn-menu__bars i:nth-child(1){ transform:translateY(-2px); }
.btn-menu:not([aria-expanded="true"]):hover .btn-menu__bars i:nth-child(2){ transform:translateY(2px); }
/* Open: the same two bars cross. 6.25px is the travel from top:2 / bottom:2 to
   the centre of an 18px box (8.25 − 2), and the inset to left/right:8 squares
   the box first — a 34-wide X in an 18-tall box is a flattened cross, not an X.
   Reusing the burger is the point: there is one control in that corner, and it
   shows its own state, so nothing has to be spawned or torn down on open. */
.btn-menu[aria-expanded="true"] .btn-menu__bars i{ left:8px; right:8px; }
.btn-menu[aria-expanded="true"] .btn-menu__bars i:nth-child(1){ transform:translateY(6.25px) rotate(45deg); }
.btn-menu[aria-expanded="true"] .btn-menu__bars i:nth-child(2){ transform:translateY(-6.25px) rotate(-45deg); }
/* Pill, as of 2026-07-28 — the one rounded family on an otherwise square page.
   The radius is deliberately full (999px, not a 4-8px softening): a small
   radius on a page where every other edge is a true 90° reads as a mistake,
   a full pill reads as a decision. Buttons are the only controls that take it;
   cards, bands, images and the contact grid stay square, so roundness marks
   "this is pressable" rather than becoming a general softness.

   The side padding grows with the radius. A pill's curve eats horizontal room
   at the text's cap line, so 18/20 would leave "Enquire" visibly crowding the
   arc; the vertical padding is untouched, so the header bar's height is the
   same as before. */
/* ===== ONE BUTTON SYSTEM ===========================================
   Consolidated 2026-07-28 at the client's request: the page had grown four
   button families (.btn-cta, .btn-solid, .btn-ghost, .link-quiet) with three
   more context overrides on top (.on-dark, .making__head, .banner), each
   picking its own resting background, hover fill, shadow and arrow colour.
   That is what "too many styles" was describing. There are now exactly two
   states, and every button on the site is one of them:

     OUTLINED  transparent, 1px border      →  hover: gold fill, no border
     FILLED    navy fill, no border         →  hover: gold fill, no border

   Both land on the SAME hover: accent gold behind navy-ink text, wiped up
   from the bottom edge. One colour, one motion, on paper and on navy alike —
   gold is the only value in the palette legible against both grounds, which
   is why it can be the single answer and --navy or --paper could not.

   The border disappearing on hover is the point of the rule, not a detail:
   a filled pill still wearing its outline reads as two decisions stacked. */
.btn-cta,.btn-ghost,.link-quiet{
  /* Mixed down from currentColor rather than named as --line / --line-light.
     A full-strength ring made the outlined state compete with the filled one
     for the same attention; at 30% the pill still reads as a control but the
     label leads it. Deriving it from the label colour is what lets ONE
     declaration serve paper and navy — the two hard-coded line tokens this
     replaced were the reason every ground used to need its own rule.
     Tunable in one place: raise the 30% and every outlined button follows. */
  --btn-line:color-mix(in srgb, currentColor 30%, transparent);
  background:transparent; color:currentColor;
  border:1px solid var(--btn-line); border-radius:999px;
  position:relative; overflow:hidden; isolation:isolate;
  display:inline-flex; align-items:center;
  font-family:var(--f-mono); font-weight:500; line-height:1.2;
  text-transform:uppercase; box-shadow:none;
  transition:border-color .45s var(--ease), color .45s var(--ease);
}
/* Two sizes, not four. The header keeps the smaller one because the bar's
   height is built around it; everything in the body shares the larger. */
.btn-cta{ padding:8px 20px 8px 24px; font-size:12.5px; letter-spacing:.04em; gap:9px; text-transform:none; font-family:inherit; }
.btn-ghost,.link-quiet{ padding:12px 32px 12px 36px; font-size:13.5px; letter-spacing:.05em; gap:12px; }

/* The single hover, shared by the outlined family AND the filled one. The
   fill is a named colour, never currentColor: currentColor resolves at paint
   time, by which point :hover has already flipped `color`, so the wipe came
   up the same value as the label and the word vanished into its own
   background. That bug is why this is spelled out. */
.btn-cta::before,.btn-ghost::before,.link-quiet::before,.btn-solid::before{
  content:""; position:absolute; inset:0; z-index:-1; background:var(--accent);
  transform:translateY(101%); transition:transform .5s var(--ease);
}
.btn-cta:hover::before,.btn-ghost:hover::before,.link-quiet:hover::before,.btn-solid:hover::before{
  transform:translateY(0);
}
.btn-cta:hover,.btn-ghost:hover,.link-quiet:hover,.btn-solid:hover{
  color:var(--navy-ink); border-color:transparent;
}

/* Every call to action ends in the same arrow, so the affordance reads the
   same whether it is 12.5px in the header or 13.5px in the contact block.
   The nudge is 5px — enough to register as a response, short enough that it
   never reaches the button's own padding and clips against overflow:hidden. */
.btn-cta::after,.btn-solid::after,.btn-ghost::after{
  content:""; display:inline-block; flex:none;
  width:1.25em; height:1.25em; background:currentColor;
  -webkit-mask:var(--arrow) center/contain no-repeat;
          mask:var(--arrow) center/contain no-repeat;
  transition:transform .45s var(--ease);
}
.btn-cta:hover::after,.btn-solid:hover::after,.btn-ghost:hover::after{
  transform:translateX(5px);
}

/* --- overlay menu ------------------------------------------------- */
.overlay{
  position:fixed; inset:0; z-index:950;
  color:var(--paper); overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain;
  clip-path:inset(0 0 100% 0); transition:clip-path .8s var(--ease);
  pointer-events:none;
  /* Flex column so .overlay-foot can be pinned to the bottom edge without
     .overlay-inner's grid having to grow a full-width row it does not want.
     overflow-x:hidden is the price of the oversize monogram below. */
  display:flex; flex-direction:column;
  /* THE SAME GROUND AS THE NAVY SECTIONS, not a version of it (client,
     2026-07-30: the menu must match "Design, make and export" and "Certified
     and audited"). It used to carry its own weaker copy — a 90%/72% radial at
     .13 where the sections run 120%/100% at .34 — on the reasoning that a
     falloff tuned for a 400px band would wash a whole viewport to mid blue.
     That reasoning produced a menu that read as a different navy from every
     other dark surface on the site, which is the more visible problem.

     It is now the same DECLARATION, not a matching pair of values: the ::after
     above and the background-image below are shared selector lists with
     .tex-navy. Two copies of a ground is how the last one drifted. */
  background-color:var(--navy-ink);
  background-image:radial-gradient(120% 100% at 32% 22%,
                     rgba(11,90,151,.34) 0%, rgba(2,56,99,.11) 46%, transparent 100%);
  /* NO GRAIN LAYER ON THIS SURFACE. .overlay was removed from the two
     .tex-navy::after selector lists on 2026-07-31, and it stays removed.

     The client rejected this ground three times and the first two diagnoses
     were both wrong. It was not the missing viewBox (real, fixed, and not
     this), and it was not the menu carrying weaker values than the sections
     (also real, also fixed, also not this). The actual fault is that the
     texture is a feTurbulence FILTER tiled as a background image, so what you
     get depends on the scale it is rasterised at. baseFrequency 0.012 is only
     ~3.6 wavelengths across the 300-unit tile; stitchTiles corrects the
     frequency to make the tile join itself, and that correction is computed
     against the raster size — so at a 2x device pixel ratio the correction
     misses and the panel comes back as a visible GRID of identical blurry
     blobs. The client's screenshot shows the grid; every screenshot I took to
     check it was 1x headless, where it does not exist.

     A section survives this because the tile is small against a box full of
     type. A full-screen panel of almost-empty navy has nothing to hide it.

     So the menu keeps the colour, the radial and the monogram — which is
     exactly the ground the previous deploy shipped and the client approved —
     and does without a texture that cannot be verified on the machine it
     renders on. Re-tuning --tex-scale/--tex-grain does NOT fix this; it only
     changes the size of the squares. */
}
/* The menu's texture now comes from the shared .tex-navy rules above: same
   blend, same --tex-grain, same --tex-scale, same tile. It used to restate
   them here at opacity .34 against the sections' .42.

   Two things inside that shared SVG are load-bearing and were BOTH missing
   until 2026-07-30, when the menu came back from the client rendering as grey
   clouds with rectangular seams across it:

   · viewBox. Without one, an SVG with intrinsic width/height scaled by
     background-size has no pinned user space, and feTurbulence baseFrequency
     is in user units. Chrome rasterises the filter at 300x300 then scales the
     bitmap; other engines compute it in the scaled space — at 520px that is a
     1.73x frequency shift, so a fine mottle becomes cloud. Correct here,
     wrong there, which is the worst kind of bug to own.
   · stitchTiles. fractalNoise does not tile: every repeat meets its neighbour
     on a hard discontinuity, which at 520px on a 1440 viewport is two visible
     vertical seams and one horizontal, in a grid. */
/* The company's own monogram, oversize and nearly out. This is the slot the
   reference fills with its swooping line motif — the bottom-right third of the
   sheet, which even at seven rows is simply empty. Using the mark rather than
   inventing a pattern keeps the fill brand-owned.

   .028, down from a first pass at .05. The mark is a hard-edged chevron, not a
   soft swoop: at .05 its diagonals cut visibly across the sheet and read as a
   watermark someone forgot to remove. At .028 it is a change in the navy that
   resolves into a shape only once you look for it — which is the whole job.

   bottom:0, NOT a negative offset. .overlay is the scroll container, so an
   absolutely-positioned child hanging below its padding box adds real
   scrollable height: the first pass at bottom:-14% gave a menu that fits on
   one screen a scrollbar, and lit the overlay's own .swipe-bar--y track down
   the right edge. Horizontal bleed is free by comparison — overflow-x is
   hidden, so right:-6% just crops. */
.overlay::before{
  content:""; position:absolute; z-index:0; pointer-events:none;
  right:-6%; bottom:0; width:min(48vw,600px); aspect-ratio:450/414;
  background:url("../images/brand-logo-mark-01.svg") no-repeat center/contain;
  filter:brightness(0) invert(1); opacity:0;
  /* The mask is what makes this usable at all. Flat opacity on a hard-edged
     chevron gives you a faint LOGO — every diagonal terminates in a crisp
     line, so however far the opacity drops it still reads as artwork left on
     the page. Fading it out toward the top-left dissolves exactly the edges
     that point back into the type, and leaves presence only in the corner
     nothing else occupies. It also keeps the mark clear of the contact
     column's baseline, which is the one place a stray diagonal would land
     behind text a buyer is trying to copy.

     mask-composite is not needed here — one gradient, one channel. Both the
     prefixed and unprefixed properties are set because the prototype is
     checked in Safari as well as Chrome. */
  -webkit-mask-image:linear-gradient(to top left, #000 0%, rgba(0,0,0,.35) 42%, transparent 76%);
          mask-image:linear-gradient(to top left, #000 0%, rgba(0,0,0,.35) 42%, transparent 76%);
  transition:opacity 1.1s var(--ease) .3s;
}
.overlay.is-open::before{ opacity:.06; }
.overlay.is-open{ clip-path:inset(0 0 0 0); pointer-events:auto; }
/* Section list left, contact card right, after the reference layout.
   The left edge is --pad-l, not --pad, so the menu's first character sits on
   the SAME left edge as the header lockup and the banner headline it drops
   over — the curtain covers the page without shifting the page's spine.

   max-content on the nav column is load-bearing rather than tidiness: the
   nav block now holds TWO type columns (see .overlay-main nav below), and
   max-content is what keeps their combined width honest — give this track a
   fraction instead and the pair stretches into the contact card's gutter. */
.overlay-inner{
  /* flex:1 replaces min-height:100% now that .overlay is a flex column: the
     inner block takes whatever the foot leaves, and the foot sits on the
     bottom edge instead of trailing the content up in the middle of the sheet.
     position/z-index lift it clear of the grain ::after and the monogram. */
  flex:1 0 auto; position:relative; z-index:2;
  display:grid; align-content:start;
  grid-template-columns:minmax(0,max-content) minmax(240px,380px);
  justify-content:space-between; gap:clamp(40px,6vw,90px);
  padding:clamp(96px,13vh,150px) var(--pad) clamp(32px,5vh,56px) var(--pad-l);
}
/* The sheet's bottom edge. The menu is seven rows and a contact card on a
   full viewport, so roughly the lower third was blank navy — the single biggest
   reason the panel read as unfinished rather than as spare. This is the
   footer's own bottom line, verbatim, which is the right thing to put there:
   it is the registration detail a sourcing visitor checks, and it is already
   approved copy.

   It now carries .mona-sign itself rather than an imitation of it, so this
   rule is down to what the sheet owns: the bottom inset and the reveal. Type,
   gap, logo height, well and hairline all come from .mona-sign, which is the
   point — one signature, one definition, both sheets. */
.overlay-foot{
  position:relative; z-index:2; flex:none;
  /* Left rail, not centred (client, 2026-07-28). The footer centres its credit
     because a footer has no other vertical rail to answer to; this sheet does —
     the nav column starts at --pad-l and every row of it hangs off that edge.
     A centred block ignored the one alignment the panel is built on and floated
     free in the middle of the bottom band. --pad-l on the left is what puts it
     under "About", and it is the only reason the pads are asymmetric. */
  display:flex; justify-content:flex-start;
  padding:0 var(--pad) clamp(20px,3vh,30px) var(--pad-l);
  opacity:0; transition:opacity .7s var(--ease) .58s;
}
/* The hairline, as its own element rather than a border on anything — because
   its length is now tuned independently of both boxes it could have hung off.
   On .overlay-foot (border-top) it spanned the whole sheet and sliced the panel
   in two; on .mona-sign it was exactly the credit's 347px, which read clipped.
   700px is roughly double that, near the width of "Sustainability" above it, so
   it reads as the foot of the nav column rather than a rule under the logo.

   left/right both set with max-width: in LTR the box is over-constrained, so
   right is dropped and the rule stays anchored on the nav rail while --pad on
   the right becomes the floor for narrow screens — under ~760px the max-width
   stops binding and the line simply spans the padded box. No media query. */
.overlay-foot::before{
  content:''; position:absolute; top:0;
  left:var(--pad-l); right:var(--pad); max-width:700px; height:1px;
  background:rgba(247,245,242,.07);
}
/* The sign carries no rule of its own here — .overlay-foot::before draws it, so
   the line's length is not tied to the credit's width. The footer's own line
   stays full width; a footer has content under it across the whole measure,
   this band does not.

   margin-top:auto is the footer's floor-push and is dead weight inside a flex
   column that already pins the foot. padding-top is 16px, not the footer's 22 —
   the sheet's band is shallower than the footer's well — and it is what holds
   the credit clear of the rule above it.

   flex-direction is the same argument: the sign stacks under 560 to keep the
   footer's centred line clear of the fixed to-top button, and there is no
   to-top button on this sheet — only a fixed-height panel the second line was
   pushing the logo out the bottom of. Row here, wrapping if it must, and
   packed left so the wrap drops the logo under the label's own start. */
.overlay-foot .mona-sign{
  margin-top:0; padding:16px 0 0; border-top:0;
  flex-direction:row; justify-content:flex-start; gap:10px clamp(10px,3vw,13px);
}
.overlay.is-open .overlay-foot{ opacity:1; }
/* No rules between rows and no 01/02/03 folio numerals. The reference sets
   this list as bare type on empty ground, and the numerals the old menu
   carried were a duplicate index anyway — the rows were already in the order
   the page presents them. */
.overlay-nav{ list-style:none; margin:0; padding:0; }
.overlay-nav > li{ position:relative; }
.overlay-nav > li > a{
  display:inline-block; position:relative; padding:clamp(9px,1.1vw,15px) 0;
  /* The vw term dropped from 4.8 to 3.4 on 2026-08-06 with the second column:
     the WIDTH now carries two type columns plus their gap plus the contact
     card, so at 1280 wide the old 4.8vw (61px rows) overflowed the sheet
     sideways. 3.4vw holds ~43px there and still reaches the client's 62px
     ceiling on wide desktops. The vh term is a HEIGHT constraint wearing a
     type-size costume, added 2026-07-31 when a wide-short window (1680x762)
     put "Contact" below the fold. It spent the afternoon of 2026-08-06 at
     7vh because SEVEN rows measured 773px inside a 762px sheet at that
     corner; the columns are FIVE rows now (product tier removed), and five
     rows at the full 62px cap come to ~485px + ~18vh of sheet padding ≈
     622px against 762 — so 7.4vh is back and the ceiling is reachable on
     short windows again. Re-measure at 1680x762 if a SIXTH row is ever
     added — that is the corner, and it is not the one anyone checks. */
  font-size:clamp(34px,min(3.4vw,7.4vh),62px); font-weight:400; letter-spacing:-.025em;
  line-height:1.08; color:rgba(247,245,242,.72);
  transform:translateY(18px); opacity:0;
  transition:transform .7s var(--ease), opacity .7s var(--ease), color .3s;
}
/* The page's hover, finally spent here. Every other link on the site answers
   with a brass hairline sweeping scaleX from the left; these rows answered
   with a colour change alone, which is why the largest, most clickable type on
   the site felt the least clickable. The reference underlines its rows too.

   Sitting on the <a> and not the <li>: the row's box runs the full column
   width, so an <li> rule would extend past "News" by the width of
   "Sustainability" and read as a divider rather than an underline. */
.overlay-nav > li > a::after{
  content:""; position:absolute; left:0; right:0; bottom:clamp(6px,.8vw,11px);
  height:2px; background:var(--accent);
  transform:scaleX(0); transform-origin:left center;
  transition:transform .45s var(--ease);
}
.overlay-nav > li > a:hover::after{ transform:scaleX(1); }
.overlay.is-open .overlay-nav > li > a{ transform:none; opacity:1; }
.overlay.is-open .overlay-nav > li:nth-child(1) > a{ transition-delay:.18s }
.overlay.is-open .overlay-nav > li:nth-child(2) > a{ transition-delay:.24s }
.overlay.is-open .overlay-nav > li:nth-child(3) > a{ transition-delay:.30s }
.overlay.is-open .overlay-nav > li:nth-child(4) > a{ transition-delay:.36s }
.overlay.is-open .overlay-nav > li:nth-child(5) > a{ transition-delay:.42s }
/* Rows 6 and 7, added 2026-07-31 with the Contact row and the Capabilities
   disclosure. Until then the ladder stopped at 5 while the list had grown to
   9, so rows 6-9 inherited transition-delay:0 and arrived BEFORE rows 1-5 —
   the cascade ran backwards, and nothing about it looked broken enough to
   catch by eye. If a row is ever added, it needs a step here or it jumps the
   queue in exactly the same silent way. */
.overlay.is-open .overlay-nav > li:nth-child(6) > a{ transition-delay:.48s }
.overlay.is-open .overlay-nav > li:nth-child(7) > a{ transition-delay:.54s }
/* YOU ARE HERE (client, 2026-08-14). Three states, and they have to stay
   distinguishable from one another:

     rest     paper at 72%, no underline
     hover    gold, underline sweeps in from the left
     current  paper at FULL strength, underline already drawn

   Colour is what separates current from hover, the underline is what separates
   current from rest. Marking the current row with gold alone — which is what
   the footer does at 13px — would make an idle current row and a hovered row
   identical at 62px, and answering "where am I" without the visitor moving the
   mouse is the whole point of the mark.

   [aria-current], not [aria-current="page"]: a news article or a job posting is
   not the news index, but it IS inside it, so sync-shell marks the section with
   "true" (the note there explains why the value differs). Both read the same
   here, deliberately.

   ORDER IS LOAD-BEARING. This sits above the :hover rule because the two have
   identical specificity — one class plus one attribute or pseudo-class, two
   elements — so whichever comes last wins. Hover must win, otherwise the
   current row is the one link in the menu that does not answer the pointer. */
.overlay-nav > li > a[aria-current]{ color:var(--paper); }
.overlay-nav > li > a[aria-current]::after{ transform:scaleX(1); }
.overlay-nav > li > a:hover{ color:var(--accent); }

/* Two columns of five (max seven — the ladder above stops at nth-child(7)).
   Each <ul> is its own .overlay-nav, so the ladder runs per column and
   parallel rows rise together; the foot's .58s still lands after the last
   used step's .42s. max-content per track for the same reason the outer grid
   uses it: type-width columns, gutter guaranteed.

   The gap's calc slope is deliberate (2026-08-06, client asked for more air
   between the columns): 14vw−64px puts the growth on desktop only — ~43px at
   768 where the grid is already tight, ~115px at 1280, ~138px at 1440,
   capped 160px from ~1600. A flat vw term wide enough for desktop was
   adding ~35px of pressure at tablet widths. */
.overlay-main nav{
  display:grid; grid-template-columns:max-content max-content;
  gap:0 clamp(48px, calc(14vw - 64px), 160px); align-items:start;
}

/* The contact card. It replaces the photograph that used to fill this half:
   a visitor who opens a menu looking for the address cannot get it from a
   picture of the sample room, and the reference puts the address, the mailbox
   and the number exactly where that visitor looks. */
.overlay-contact{
  transform:translateY(18px); opacity:0;
  transition:transform .7s var(--ease) .42s, opacity .7s var(--ease) .42s;
}
.overlay.is-open .overlay-contact{ transform:none; opacity:1; }
.overlay-contact h2{
  margin:0 0 clamp(22px,2.6vw,36px); font-weight:400;
  font-size:clamp(30px,4vw,52px); letter-spacing:-.025em; line-height:1.08;
  color:rgba(247,245,242,.72);
}
.overlay-contact dl{ margin:0; }
.overlay-contact dt{
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.22em; text-transform:uppercase; color:var(--accent);
  margin:26px 0 8px;
}
.overlay-contact dt:first-of-type{ margin-top:0; }
.overlay-contact dd{ margin:0; font-size:var(--text-small); line-height:var(--lh-tight); opacity:.84; }
.overlay-contact dd a:hover{ color:var(--accent); }
.overlay-contact .social-row{ opacity:.82; }
.overlay-contact .social-row svg{ width:19px; height:19px; }
/* .btn-close is gone. The header bar now sits above the overlay and its burger
   shows the open state as an X, in the same corner and at the same 22px offset
   the close button used — so keeping both meant two close affordances stacked
   on each other. The X the visitor clicks is .btn-menu__bars.

   The old rule and its .x pseudo-elements were deleted rather than commented
   out; the geometry lives on in .btn-menu[aria-expanded="true"]. */

/* --- banner / hero ------------------------------------------------ */
.banner{
  position:relative; min-height:100svh; display:flex; align-items:flex-end;
  color:var(--paper); overflow:hidden;
}
.banner .slot{ position:absolute; inset:0; z-index:0; animation:slowzoom 26s var(--ease) forwards; }
@keyframes slowzoom{ from{ transform:scale(1.09) } to{ transform:scale(1) } }
.banner::after{
  content:""; position:absolute; inset:0; z-index:2; pointer-events:none;
  background:linear-gradient(to top, rgba(1,20,36,.86) 0%, rgba(1,20,36,.34) 42%, rgba(1,20,36,.42) 100%);
}
.banner-inner{
  position:relative; z-index:3; width:100%;
  padding:0 var(--pad) clamp(40px,7vh,88px) var(--pad-l);
  display:grid; grid-template-columns:minmax(0,1fr) auto; gap:40px; align-items:flex-end;
}
.banner h1{ max-width:21ch; }
.banner .lede{ color:rgba(247,245,242,.78); margin-top:var(--g-pair); max-width:44ch; }
.banner-line{ margin:26px 0 0; color:var(--accent); opacity:.85; }
/* Spacing only. This button used to restate its own colour, a transparent
   fill and its own hover here because the base .link-quiet shipped a 72%
   white backing that had to be cancelled over video. The base is transparent
   now and takes its colour from the ground, and .banner is already
   color:var(--paper) — so the pill comes out paper-on-video by inheritance,
   and hovers gold like every other button. */
.banner .link-quiet{ margin-top:clamp(16px,2vw,24px); }
.banner-meta{ text-align:right; display:flex; flex-direction:column; gap:var(--g-pair); align-items:flex-end; }
/* Against the video, not against a flat panel: .62 alpha measured over the
   poster's darkest region still clears 4.5:1, and the banner's gradient scrim
   is what makes that reliable rather than lucky. Do not lighten the scrim
   without re-checking this line. */
/* One fact per line, set by the markup — the middot-joined run wrapped
   wherever the measure happened to fall and broke "Binh Duong, Vietnam" across
   two lines, which reads as a typesetting accident rather than three facts.
   Three <li> at line-height 1.9 give the same block of text the same height,
   with the breaks landing where the meaning does.

   The old max-width:34ch went with it, and nowrap replaces it. 34ch was never
   34 characters here: `ch` measures the glyph, not the .14em of tracking after
   it, so the box came out ~15% narrower than the text it was sizing and the
   longest fact wrapped anyway. The strings are fixed and the longest runs
   ~205px, which clears the narrowest phone with room to spare — but that is a
   fact about THIS copy. Lengthen a line and it will ride out of the column
   rather than wrap, which is the trade nowrap makes.

   Two strings since 2026-08-14, not three: "Established 2024" came out on
   client instruction. The longest is unchanged, so the ~205px measurement
   above still holds. */
.banner-facts{
  margin:0; padding:0; list-style:none;
  color:rgba(247,245,242,.62);
  font-size:11px; letter-spacing:.14em; line-height:1.9;
}
.banner-facts li{ white-space:nowrap; }
.scroll-cue{ display:flex; align-items:center; gap:12px; opacity:.7; }
.scroll-cue .bar{ width:3px; height:52px; background:var(--accent); position:relative; overflow:hidden; }
.scroll-cue .bar::after{
  content:""; position:absolute; inset:0; background:var(--paper);
  animation:cue 2.4s var(--ease) infinite;
}
@keyframes cue{ 0%{ transform:translateY(-100%) } 60%,100%{ transform:translateY(100%) } }

.reveal-line{ display:block; overflow:hidden; }
.reveal-line > span{
  display:block; transform:translateY(105%);
  animation:riseIn .9s var(--ease) forwards;
}
/* Trimmed .22/.34/.46 → .14/.26/.38: the curtain's dwell may only begin once
   the second line has settled, so the lines' finish is on the intro's
   critical path — the .12s stagger between lines is the rhythm and stays.
   (These were briefly .10/.20/.30 with a .7s rise; reverted when the client
   clarified that "faster" meant a shorter PAUSE, not hastier motion — see
   the two-beat open comment.) Retime together with the curtain keyframes. */
.reveal-line:nth-child(1) > span{ animation-delay:.14s }
.reveal-line:nth-child(2) > span{ animation-delay:.26s }
.reveal-line:nth-child(3) > span{ animation-delay:.38s }
@keyframes riseIn{ to{ transform:translateY(0) } }

/* --- m-intro ------------------------------------------------------ */
.intro-grid{ display:grid; grid-template-columns:minmax(0,1fr) minmax(360px,.58fr); gap:clamp(42px,5.8vw,96px); align-items:start; }
.intro-copy{ max-width:820px; padding-top:clamp(4px,.8vw,12px); }
/* The folio, when this block carries a VISIBLE one — about.html's 01 / Story
   since 2026-08-14, where the homepage's copy of this section had it
   .rail-only. .intro-copy is plain flow, not .sec-head's grid, so the span
   needs to be told it is a line of its own; --g-pair is the same heading-to-
   lede gap every other section uses, which is what keeps this folio sitting
   at the same distance from its headline as the numbered blocks below it. */
.intro-copy > .idx:not(.rail-only){ display:block; margin-bottom:var(--g-pair); }
.h-sec .accent{ color:var(--navy); }
/* Navy-on-paper is the scheme; reversed on a navy ground it would vanish, so
   on-dark sections keep the same emphasis idea with the brass that already
   carries every other accent there (folio numbers, kickers). */
.on-dark .h-sec .accent{ color:var(--accent); }
.intro-title + .lede{ margin-top:var(--g-pair); }
.intro-grid .lede{ max-width:var(--measure-body); color:var(--ink-soft); }
.intro-proof{
  /* Three EQUAL measures, not three equal columns — they are not the same
     thing here. The divider padding is carried as a right padding on cells 1–2
     and a left padding on cells 2–3, which keeps the hairline centred between
     two runs of copy (that part was right). But it also charges the middle cell
     the padding twice: on repeat(3,1fr) cell 2 got 42px less text width than
     its neighbours, which is the whole reason its sentence broke to four lines
     against their three.
     So the track widths pay it back: the free width is split three ways AFTER
     one padding is set aside, and that one padding is handed to the middle
     track. Sum is still 100%, every cell still resolves to the same content
     box, and the rules stay where they were. */
  --proof-pad: clamp(20px,2.6vw,42px);
  --proof-cols:
    calc((100% - var(--proof-pad)) / 3)
    calc((100% - var(--proof-pad)) / 3 + var(--proof-pad))
    calc((100% - var(--proof-pad)) / 3);
  list-style:none; display:grid; grid-template-columns:var(--proof-cols);
  margin:var(--g-group) 0 0; padding:0;
  border-top:1px solid var(--line);
}
/* Ruled, not boxed (client, 2026-07-28). This was three tinted panels held
   apart by a 1px grid gap over a --line background — the old cheat that draws
   a full box for free. On a page where every other list is a hairline index
   (.cap-row, .cert, .route-list, .stat) a bordered card grid reads as a table
   dropped into an essay, which is exactly what the client saw.

   Same anatomy as .cert-list, three across instead of two: rule above the run,
   rule under each cell, and the column divider carried as a right border on
   every cell but the last so it stops at the row rule rather than running
   past it. Padding is asymmetric for the same reason it is there — it holds
   the copy off the divider it sits beside. No fill: the paper is the cell. */
.intro-proof li{
  padding:clamp(15px,1.6vw,20px) 0 clamp(17px,1.9vw,24px);
  border-bottom:1px solid var(--line);
}
.intro-proof li:not(:last-child){
  padding-right:var(--proof-pad); border-right:1px solid var(--line);
}
.intro-proof li + li{ padding-left:var(--proof-pad); }
.intro-proof b{
  display:block; margin-bottom:6px; color:var(--navy); font-size:var(--text-body);
  font-weight:650; line-height:1.25;
}
/* --text-body, not --text-small. These cells carry a 55-character sentence
   each, directly under a 17px lede — the drop to 14px was the largest
   single step in the intro and the one that made the section look like it
   changed its mind about type halfway down. The label above keeps its rank
   on weight and navy, which is what it was already doing: at 14px/650 it
   was the same size as the sentence it introduced. */
.intro-proof span{ display:block; color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body); text-wrap:pretty; }
/* No rule over the founder block (client, 2026-07-28). The three proof cells
   above already close on their own bottom hairline, so this one was the second
   horizontal line in ~40px of paper and the pair read as a boxed-in band.

   ONE --g-group, not two (client, 2026-07-28). The gap used to be split either
   side of that rule and both halves were kept when the rule went, which left
   the founder block sitting 68px clear — a bigger hole than the one between
   this section's own heading and its lede, and the largest thing on a stretch
   of paper that is meant to read as one continuous argument. It is a plain
   group boundary now, the same 34px that separates the lede from the proof
   cells: the change of subject is carried by the mono label, not by a void. */
.intro-founder{
  display:block; max-width:58ch;
  margin-top:var(--g-group);
}
.intro-figure{ margin:0; }
.intro-figure .slot{
  aspect-ratio:4/5; background:#EFE9DF;
  box-shadow:0 22px 60px rgba(1,34,61,.10);
}
.intro-figure .slot img{ object-position:center center; }
.intro-figure figcaption{
  margin-top:14px; font-family:var(--f-mono); font-weight:400; font-size:11px;
  letter-spacing:.1em; text-transform:uppercase; color:var(--ink-soft);
}
/* Six full-width ROWS, not a 3×2 of cells. The 3×2 gave every figure the same
   ~380px box, which meant 34,000 m² and 5 were typeset at the same size and
   the section read as a spec table — six equal facts, no argument. As rows,
   each figure gets the page's full width, a counter, its unit and a sentence
   saying why the number matters, which is the difference between listing scale
   and making a case for it.

   --col-n is a length rather than a percentage because the counter holds one
   or two characters at any width — a percentage would starve it on a phone
   and strand it on a 27-inch screen. */
.stats{
  --col-n:0px;                       /* counter removed  */
  /* ONE figure size for the whole block, declared here because two different
     things have to agree on it: the numeral's font-size and the label's
     padding-top, which is derived from it below. Overriding --fig on a single
     .stat re-sizes that row's figure AND re-aligns its label in one move. */
  --fig:clamp(38px,5.6vw,96px);
  /* Measured in the NUMERAL'S OWN UNITS, not as a % of the row, because the
     only thing this column has to clear is the longest figure — and a
     percentage of the row is not that. Every % value it held before (28, then
     36) was either too narrow somewhere, which put 3,000,000 through its
     label, or too wide everywhere else, which stranded the labels a third of a
     screen from the numbers they name. The client's word for the second was
     that the blue had to come back "gần gần số".

       5.23  the width of "3,000,000+" per px of font-size, probed in-page:
             5.072 at every viewport (glyph advances, the -.085em tracking
             and the .42em trailing + all scale linearly), plus ~3% for
             font-loading variance. Was 4.95 for the bare "3,000,000";
             re-measured 2026-07-28 when the client asked for the +.
       -4px  the trim on the gutter, flat rather than scaled — a proportional
             one opens to 60px at the 96px ceiling and strands the labels
             again. This term is the ONLY dial for "move the labels left/right"
             and it moves all four together by exactly its own delta; the
             client walked it 20 → 8 → -4.

             IT IS NOW AT THE FLOOR. The 5.23 factor leaves the numeral only
             ~0.158em of slack, so measured clearance after 3,000,000+ is that
             slack minus 4px: 11px at 1920, 9px at 1440, 5px at 1080, 3px at
             820, 2px at 600. Nothing overlaps, and below 820 the label's own
             padding-left adds 18px of visible air — but on desktop the label
             has no padding, so another step left puts blue type into the
             numeral. Do not take one without shrinking --fig or re-measuring
             4.95 against a narrower numeral first.

     Re-measure the 5.23 if the numerals' typeface or tracking changes; the
     probe is one measureText call on .stat--pairs .num. */
  /* -28px, not the -4px this carried until 2026-08-14: the client asked for the
     label column 24px further left. Taken off the NUMERAL column rather than
     added as a negative margin on .cap, because the labels start where this
     track ends — move the track's edge and the whole column follows, padding
     and cap-height alignment intact.

     It is free width, not borrowed: probed in page at 1440, the widest numeral
     ("150,000/mo") inks 327px inside a 418px track, and .num's own
     padding-right is 23px there. 24px comes out of 68px of slack. Do NOT keep
     shaving it — at about -60px the widest row starts to touch its label, and
     the failure looks like a kerning bug rather than a column that is too
     narrow. Re-probe if --fig or the tracking moves. */
  /* RE-PROBED 2026-08-14, which is what the paragraph above asks for, and the
     44px came off — not by shaving further but by making it proportional.

     The subtraction was a FIXED pixel amount on a track that scales with
     --fig, so it eats a different share at every size: 11% of the column at
     1440 (fig 81) and 24% at 768 (fig 43). The slack above was measured "at
     1440 and ~30 at 1200" and never below, and below is where it runs out.
     Measured with the counter SETTLED on "150,000/mo" — mid-count values are
     narrower and read as passing — the ink overlapped "Production capacity"
     by 15px at 560, 14 at 700, 11 at 768, 9 at 820 and 5 at 900, and first
     cleared at 1024. Exactly the failure the note below .stat .cap describes,
     with its instruction: the column is too narrow, widen it.

     4.687 is the same number, expressed as a ratio instead of a constant:
     44px IS 0.543 x fig at 1440, so 5.23 - 0.543 = 4.687 and the desktop
     track is unchanged to within a pixel (379.6 vs 380.6). Everything the
     paragraph above says about paying the difference into --col-object still
     holds at the width it was written for; smaller screens simply stop paying
     a desktop-sized constant out of a much smaller column.
     Ink cost of "150,000/mo" is ~4.50 x font-size, so 4.687 clears it. */
  --col-num:calc(var(--fig)*4.687);
  --col-cap:18%;
  /* +2% (~24px at 1440), exactly what --col-num gave up. The two move together
     ON PURPOSE: the tracks after --col-num all shift left by whatever it loses,
     and the last one is minmax(0,1fr), so without this the NOTE column would
     have slid 24px left too and the shoe's lane would be the same width in a
     new place. Paying the 24px into the object lane instead keeps the notes
     where they are and widens the empty channel the shoe rides in — which is
     the other half of the same request. See .scale-object's x in home.js.

     19.3%, raised again on the second "make the shoe bigger" pass. --col-num is
     now -44px: 24px of that is the label shift the client asked for and 16px is
     bought channel. There is still ~30px of slack in --col-num at 1440 and ~30
     at 1200 — but the ceiling is 1920, where the channel grows more slowly than
     the viewport does (18.5% of vw at 1200, 17.6% at 1920). Size the shoe
     against 1920, not against the width you happen to be testing. */
  --col-object:19.3%;
  position:relative;
  margin-top:var(--g-block);
  border-top:1px solid var(--line);
}
.stat{
  display:grid;
  grid-template-columns:var(--col-n) var(--col-num) var(--col-cap) var(--col-object) minmax(0,1fr);
  column-gap:0; align-items:start;
  padding:clamp(18px,2.1vw,30px) 0 clamp(20px,2.5vw,36px);
  border-bottom:1px solid var(--line);
}
/* Everything is top-aligned with the counter, and the figure simply hangs
   below the line the other three start on — that overhang IS the reference's
   layout. Do not switch this to align-items:center to "tidy" it up; centring
   six different figure heights against three short labels is what makes a row
   list read as a table again. */
.stat > *{ min-width:0; }
.st-n{ color:var(--navy); padding-top:.55em; }

/* The page used to narrow its big numbers on Archivo's width axis (96) so a
   figure like 34,000m² would not sprawl across its cell. Neither Satoshi nor
   Jakarta after it has a width axis, so the same optical job is done with
   tracking instead — the numbers still have to read as compressed against the
   body text around them.

   Weight drops to 420 from the 500 the small version carried. Jakarta's 500 is
   a text weight; held at 96px it turns into a headline shout, and the
   reference's figures are large-and-quiet, not large-and-heavy. Optical
   compensation, not a style change — if this ever goes back down in size, put
   the weight back up with it. */
.stat .num{
  grid-column:2; grid-row:1;
  font-size:var(--fig); font-weight:420; letter-spacing:-.05em; line-height:.96;
  display:flex; align-items:baseline; gap:.04em; padding-right:clamp(12px,1.6vw,28px);
}
/* No size of its own any more — 3,000,000 is set at --fig like the other three,
   which is what lets all four labels sit on one line with their own figure.
   Only the tracking is its own: nine glyphs need the tighter fit that four do
   not, and that -.085em is what the 36% column above was measured against. */
.stat--pairs .num{
  letter-spacing:-.085em;
  white-space:nowrap;
}
/* The unit rides at a fraction of the figure rather than at a fixed size, so
   34,000 and its m² stay in proportion at every width. */
.stat .num i{ font-style:normal; color:var(--navy); font-size:.42em; letter-spacing:-.01em; }
/* The label's CAP-HEIGHT is flush with the numeral's cap-height — not its line
   box, which is what a plain align:start would give and which reads as the
   label floating high. Both numbers come from the fonts' own metrics, measured
   in-page:

     .144  Jakarta at line-height .96: half-leading (-.152em) plus the gap from
           its ascender down to the top of a digit (+.297em).
     5.12  the same distance inside .mono, which is FIXED at 11px, so this term
           is a constant and not a ratio. If .mono's size ever moves, remeasure.

   Derived from --fig rather than from a literal, so a row that overrides the
   figure size (see the 820 breakpoint) re-aligns its own label for free. */
.stat .cap{
  grid-column:3; grid-row:1;
  color:var(--navy); padding:calc(var(--fig)*.144 - 5.12px) clamp(12px,1.6vw,28px) 0 0;
}
/* THERE IS NO .stat--pairs .cap RULE and there should not be one. It carried,
   in turn, a measured padding-left to dodge the numeral's tail sideways (broke
   the labels' shared left edge) and then a padding-top to clear it downward
   (broke their shared baseline). Both were treating the symptom: the figure
   was overflowing its column. Widening --col-num fixed the cause, so this row
   now takes the same .cap rule as the other three. If 3,000,000 ever overlaps
   its label again, the column is too narrow — do not re-add a nudge here. */
.st-note{
  grid-column:5; grid-row:1; margin:0; padding-top:.35em;
  color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body); max-width:var(--measure-narrow);
  text-wrap:pretty;
}

/* --- m-capabilities (capabilities + the making) -------------------- */
/* Single column since the proof panels were removed. Kept as a wrapper rather
   than deleted so the rows keep a named block to sit in and the five stages
   below have something to be "beneath". */
.cap-explorer{ display:block; }
.cap-list{ border-top:1px solid var(--line); list-style:none; margin:0; padding:0; }
.cap-row{
  /* Two rows, not one line of four columns: the panel beside it took roughly
     a third of the width away, and the old .9fr/1.1fr split left the
     description at about 22 characters a line. Title and description now
     stack in one column and the row is read top-to-bottom like the rest of
     the page's editorial rows. */
  display:grid; grid-template-columns:clamp(44px,4.4vw,68px) minmax(0,1fr) 28px;
  gap:var(--g-pair) clamp(12px,1.8vw,26px); align-items:start;
  padding:clamp(20px,2.5vw,34px) 0; border-bottom:1px solid var(--line);
  position:relative;
  /* ONE hover shift for the whole row — see .route-list a for the reasoning. */
  --row-shift:clamp(6px,1.4vw,20px);
}
.cap-row p{ grid-column:2; }
/* The hairline sweep and the row indent are both composited: scaleX on a
   full-width rule rather than an animated width, translateX on the row's
   children rather than an animated padding. Identical on screen, no layout
   pass per frame — and the ::before stays pinned to the true left edge
   because it is the one thing that does NOT move. */
.cap-row::before{
  content:""; position:absolute; left:0; bottom:-1px; height:2px; width:100%; background:var(--navy);
  transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
/* .is-on is gone with .cap-panels: it existed only to hold the sweep on the
   row whose proof panel was showing. :focus-visible replaces the focus half
   of that behaviour, which the JS used to supply — without it a keyboard
   user tabbing the list would get no sweep at all. The line also thickens on
   the way in (scaleY, not height — stays a transform so it's still
   composited, no layout thrash) so the sweep reads as gaining weight, not
   just length. */
/* a.cap-row, not .cap-row. Every row on the homepage is a link, so this
   changes nothing there — but capabilities.html reuses the row as a static
   list item, and a sweep plus a text shift under the cursor is a promise of
   somewhere to go. Narrowing the selector is what keeps one component usable
   in both places without a second class. */
a.cap-row:hover::before,
a.cap-row:focus-visible::before{ transform:scaleX(1) scaleY(2); }
.cap-row > *{ transition:transform .5s var(--ease), color .5s var(--ease), font-weight .5s var(--ease); }
/* The title takes the colour of the rule that just drew under it, and gains
   --w-hover of weight. That is the whole system for this hover across the page
   (.cert h3, .prod-cap .h-sub, .route-list b all follow it): the sweep is
   already the section's answer to "which accent survives on this ground", so the
   heading has one too and there is nothing left to pick. Here on paper that is
   --navy, matching .cap-row::before; .m-capabilities re-declares both below.

   The weight is the ONE hover on this page that is not composited — Jakarta is a
   variable font, so 500 -> 650 interpolates, but every frame of it is a layout
   pass. It is affordable because it runs on ONE text node at a time and the
   longest of these titles is three words; it would not be on a paragraph. If it
   ever needs to come off the main thread, drop the transition and let the weight
   snap while the colour keeps the fade — do NOT reach for a text-shadow fake.

   The transition rides on `> *` rather than on the title, because the `> *` rule
   above would otherwise win the shorthand and drop both. */
a.cap-row:hover .h-sub,
a.cap-row:focus-visible .h-sub{ color:var(--navy); font-weight:var(--w-hover); }
/* Every child, arrow included. The arrow used to be excluded here and given a
   flat 6px of its own, so the row came apart mid-hover: the text travelled up
   to 20px and the arrow 6px. Below 820px the arrow drops under the text and
   shares its left edge, which made the mismatch impossible to miss. */
a.cap-row:hover > *,
a.cap-row:focus-visible > *{ transform:translateX(var(--row-shift)); }
.cap-row .arrow{ justify-self:end; color:var(--accent); }
.cap-row p{ margin:0; color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body); max-width:var(--measure-body); text-wrap:pretty; }

/* .cap-panels / .cap-panel / .cap-proof / .cap-fig / .cap-aside were deleted
   in the v2 consolidation (2026-07-28). The panels were the page's third
   printing of the scale figures — see .m-scale, now their only home. */

/* The five stages carry a lead paragraph instead of the second <h2> they had
   as a standalone section. The label sits inline with the copy so it reads as
   an aside to the heading above, not as a heading of its own. */
/* margin-top is 0 and the separation it used to carry now lives on the two
   containers that actually need it: .making__head's row-gap for the wrapped
   case, and the ≤1080 block's own margin on .link-quiet. As a top margin it
   only ever did work when the lead and the link WRAPPED — side by side on
   .making__head's flex line, an equal margin on both items separates nothing
   and just drops the whole head 34px down a seam that already had three other
   sources of air above it. */
.making-lead{
  margin:0; max-width:var(--measure-body);
  color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body);
  text-wrap:pretty;
}
.making-lead .tag{ display:block; margin-bottom:8px; color:var(--accent); }

/* --- m-capabilities on navy -----------------------------------------
   The page's dark beat, moved here from .m-scale on 2026-07-28 when the
   numbers went to paper. It landed on this section and not on .m-products
   because the two navy stretches further down (sustainability, compliance)
   are 60% of the page apart from each other otherwise, and because "one
   continuous operation, nothing handed outside" is the claim that most wants
   the page's structural colour behind it.

   Everything below is a RECOLOUR, not a re-layout: every rule here restates a
   value set earlier in this stylesheet, and none of them changes a metric. If
   you find yourself adding a margin or a grid-template to this block, the
   change belongs upstairs with the light version, or the two will drift.

   Grouped in one place rather than sprinkled as .on-dark arms next to each
   original, because the day this section goes back to paper the whole beat
   should be one deletion. */
/* GROUND, not section. This colour was written three times — here, on
   .m-compliance and on .band — which is three chances to drift and three
   places to edit when a fourth navy section appears. .on-dark is already the
   marker every navy block carries, and sub-pages put ruled lists and stat
   quads on navy that no homepage section name covers. The z-index stays
   section-scoped: it is the shoe turntable's layering, not a ground fact. */
.on-dark{ color:var(--paper); }
.m-capabilities{ position:relative; z-index:720; }
/* Two alpha tiers on navy and no third: .82 for a lede, .78 for body copy.
   They were .72/.68/.66/.48 across the three navy regions, which was drift
   rather than hierarchy — the client read the low end as washed out
   (2026-07-28). On --navy-ink that ladder is 11.2:1 / 9.5:1, under solid
   paper's 14.8:1, so heading > lede > body still reads. */
.on-dark .lede{ color:rgba(247,245,242,.82); }
.on-dark .cap-list{ border-top-color:var(--line-light); }
.on-dark .cap-row{ border-bottom-color:var(--line-light); }
/* Gold, not navy. The hover sweep was a navy hairline drawing itself across
   paper; navy on navy is a rule that costs a repaint and shows nothing. Gold
   is the metal this ground already carries. */
.on-dark .cap-row::before{ background:var(--accent); }
/* ...and the title travels to the same gold, for the same reason — see the
   note on .cap-row:hover .h-sub. Gold on --navy-ink measures 4.7:1, so the
   hovered state is still AA at body size; the paper half of this pair cannot
   use it (brass is 3.4:1 on white, which is why .prod-cap .idx was left
   alone), and that asymmetry is exactly what makes this a ground question. */
.on-dark a.cap-row:hover .h-sub,
.on-dark a.cap-row:focus-visible .h-sub{ color:var(--accent); }
/* Weight is inherited from the paper rule above — only the colour is a ground
   question. Re-declaring it here is how the two halves drift apart. */
.on-dark .cap-row p,
.m-capabilities .making-lead{ color:rgba(247,245,242,.78); }

/* The stages stop here, and the reason is the reason this block exists at
   all: it recolours for a ground, and below .making__ground the ground is
   paper again. Four rules used to run past this line and each one was light
   ink laid on light paper.

     .making-step p          the stage description, invisible under the frame
     .swipe-bar / __thumb    the phone rail's readout, invisible under it
     .making-track::before   the old hairline, deleted with the pinned rebuild
     .making-step::before    the old beads, deleted with it

   The first two are live elements that simply changed sides. The last two
   name elements that no longer exist. Both kinds are dropped rather than
   inverted, because everything below the join is already correct at its
   light-version value — the recolour's job was only ever to undo paper, and
   there is paper down there to not undo. .making-step h3 is the one arm that
   does have to be stated, and it is stated next to the stages themselves: it
   carries no colour of its own and inherits straight off .m-capabilities. */

/* --- m-products --------------------------------------------------- */
.prod-grid{ display:grid; grid-template-columns:repeat(4,1fr); gap:clamp(14px,1.6vw,24px); }
.prod{ position:relative; }
.prod .slot{ aspect-ratio:3/4; }
.prod:nth-child(even){ margin-top:clamp(20px,4vw,64px); }
/* No lift on the frame, by client decision (2026-07-28). It used to rise 8px
   on hover, which opened a gap between the photograph and the caption rule
   drawn directly beneath it — the one hover mark this section has. The image
   stays welded to that line; the push-in below carries the whole gesture. */

/* Hover pushes the photograph INTO the frame rather than swapping in a second
   image. The brief's material-vs-finished flip needs macro stock that does not
   exist yet; pushing in is the honest version of the same idea, because every
   pixel it reveals is a pixel the photographer actually shot.

   1.07, not more. The widest this column ever gets is 403px — the container
   caps at 1680 and the row is four-up — so against a 1000px source the
   photograph is sampled down 2.5x at rest and still 2.3x pushed in. The push
   therefore reads from detail the file actually holds instead of from upscaled
   mush. Past ~1.15 the shoe starts losing its heel to the frame edge and the
   crop reads as a mistake rather than as an intent.

   Origin sits below centre because all four shots are floor-level product on a
   plain sweep — the sole and the vamp are the parts that carry visible
   material, and a centred origin would spend the movement on empty backdrop. */
.prod .slot > picture{ transition:transform .9s var(--ease); transform-origin:50% 62%; }
.prod:hover .slot > picture,
.prod:focus-visible .slot > picture{ transform:scale(1.07); }

/* Same hairline-sweep family as .cap-row / .cert / .route-list: a 2px rule
   that scales in on hover/focus and thickens with it (scaleY, kept on the
   transform so it stays composited). It sits on .prod-cap rather than .slot
   so it stays put under the frame while the picture lifts and zooms above
   it — a moving line chasing a moving image would read as jitter, not
   emphasis. var(--navy): this section is on paper, same as .cap-row's base
   and .route-list, not the on-dark var(--accent) .cert uses. */
.prod-cap{ position:relative; display:flex; justify-content:flex-start; align-items:baseline; padding-top:12px; gap:10px; }
.prod-cap::before{
  content:""; position:absolute; left:0; top:0; height:2px; width:100%; background:var(--navy);
  transform:scaleX(0); transform-origin:left top; transition:transform .7s var(--ease);
}
.prod:hover .prod-cap::before,
.prod:focus-visible .prod-cap::before{ transform:scaleX(1) scaleY(2); }
/* No font-size. This was 16→20px against .h-sub's 19→24 — a private scale
   for the product caption with nothing behind it but the moment it was
   typed. The card is already distinguished by the photograph above it. */
.prod-cap .h-sub{ transition:color .5s var(--ease), font-weight .5s var(--ease); }
/* --navy, matching .prod-cap::before above — see .cap-row:hover .h-sub for why
   the sweep gets to decide. It is the one tint this caption can take: the note
   below explains why the folio's brass has nowhere to travel on paper, and the
   title is on the same ground. */
.prod:hover .prod-cap .h-sub,
.prod:focus-visible .prod-cap .h-sub{ color:var(--navy); font-weight:var(--w-hover); }
.prod-cap .idx{ font-size:10px; letter-spacing:.12em; color:var(--accent); }
/* No hover tint on the folio, deliberately. Sending .idx to --brass was
   tried and measured too close to this section's ground — the number would
   have all but vanished under the pointer. There is no lighter brass to
   travel TO here, because at rest .idx is already the dark bronze that any
   accent has to become to stay legible on paper. The lift and the push-in
   carry the hover state instead. */
.prod-note{ margin:6px 0 0; color:var(--ink-soft); font-size:var(--text-small); line-height:var(--lh-tight); text-wrap:pretty; }
/* Product captions stay visible so the photograph, title and supporting line
   read as one product group. Hover only carries motion now; it no longer hides
   information needed to understand the set at a glance. */
@media (hover:hover) and (pointer:fine){
  .prod-note{
    transition:color .35s var(--ease);
  }
  .prod:hover .prod-note,
  .prod:focus-visible .prod-note{ color:var(--ink); }
}
/* One head layout, two sections. Products and Certifications both end their
   heading block with a quiet secondary action sitting out to the right, so
   this is named for the job rather than for Products — the compliance link
   moved up here on 2026-07-28 and a second copy under a second name is how
   the button families got to four in the first place.

   align-items:end, so the pill lands level with the LAST line of the copy
   block, not the first. Top-aligned it floats beside a display-size heading
   at a fifth of its weight and reads as debris; bottom-aligned it shares a
   line with the lede and the two read as one row. */
.head-copy{
  display:grid; grid-template-columns:minmax(0,1fr) auto;
  gap:clamp(20px,3vw,56px); align-items:end;
}
/* .2em, not flush: the pill carries more air under its label than the lede
   carries under its last line, so matching the boxes sits the type low.
   margin is set on all four sides because .link-quiet ships a margin-top
   (--g-group) for the stacked case it used to be in, and that would push it
   out of the row it is now sharing. */
.head-copy .btn-ghost,
.head-copy .link-quiet{ justify-self:end; margin:0 0 .2em; }

/* --- m-compliance (navy) ------------------------------------------ */
/* Ground comes from .tex-navy, not from here. The `background` shorthand
   used to live on this rule, and being later in the sheet at equal
   specificity it would reset the texture's background-image to none —
   the section would go flat navy and the class would look broken. */
/* colour + lede now come from .on-dark above */
/* A ruled 2x2, not four full-width rows. Four rows ran the block down most of
   a screen for what is four short paragraphs, and the badge sat a long way from
   the copy it belongs to; paired cells halve the height and let two programmes
   be compared in one glance.

   The cell's anatomy since 2026-08-07 — name and tag flush left, the
   programme's own mark (.cert__mark) top-right, verified copy underneath.
   The drawn badges and their 44px gutter went when the real marks arrived
   (client request), so this is still an editorial index ruled like
   .cap-row and .route-list, not a wall of logos. */
.cert-list{
  display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
  border-top:1px solid var(--line-light);
}
.cert{
  display:grid; grid-template-columns:minmax(0,1fr) auto;
  /* auto 1fr, not auto auto: the copy row absorbs whatever height the
     stretched cell has spare, so the two rows always fill the padding box.
     That is what lets the mark centre on the CELL rather than on its own
     content — with two auto rows the tracks stop at the copy's last line,
     and the shorter cell of a pair (SCAN, which has no disclosure under
     it) would centre its mark higher than its neighbour's. */
  grid-template-rows:auto 1fr;
  gap:var(--g-pair) clamp(14px,1.6vw,20px);
  align-items:start; align-content:start;
  padding:clamp(20px,2.5vw,34px) 0; border-bottom:1px solid var(--line-light);
  position:relative;
}
/* Explicit placement, because source order is mark → id → body and the mark
   would otherwise auto-place into the name's own cell. Name and copy share
   column 1; the mark owns column 2 outright, which is what guarantees no
   overlap now that it sits level with the copy. */
.cert__id{ grid-column:1; grid-row:1; }
.cert__body{ grid-column:1; grid-row:2; }
/* The column divider is a border on the left-hand cell, not a gap. Grid
   stretches the pair to equal height, so one hairline per row lands exactly
   between them and stops at the row rule instead of running past it — a
   free-standing column rule would have to guess where the last row ends.
   The asymmetric padding is what holds the copy off that hairline. */
.cert:nth-child(odd){ padding-right:clamp(22px,3.2vw,56px); border-right:1px solid var(--line-light); }
.cert:nth-child(even){ padding-left:clamp(22px,3.2vw,56px); }
/* Bottom edge, unified 2026-07-28: every sweep in the family now lands on the
   divider BELOW its cell — same gesture as .cap-row::before and the product
   caption's under-image rule. */
.cert::before{
  content:""; position:absolute; left:0; bottom:-1px; height:2px; width:100%; background:var(--accent);
  transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
.cert:hover::before{ transform:scaleX(1) scaleY(2); }
/* .cert__badge (the drawn 44px badges and their gutter column) deleted
   2026-08-07 with the badges themselves — the programmes' real marks
   (.cert__mark, top-right) replaced them at the client's request. */
.cert h3{
  margin:0; font-size:var(--text-title); font-weight:500;
  letter-spacing:-.025em; line-height:var(--lh-title);
  transition:color .5s var(--ease), font-weight .5s var(--ease);
}
/* Gold, matching this cell's own sweep — see .cap-row:hover .h-sub. No
   :focus-visible twin here and that is not an oversight: a .cert is a div, not
   a link, so there is nothing on it to focus. The day one of these becomes a
   link, this rule needs the twin the other three already have. */
.cert:hover h3{ color:var(--accent); font-weight:var(--w-hover); }
.cert__id .tag{ display:block; margin-top:6px; }
/* 40ch, not --measure-narrow (client, 2026-08-07: "cho cụm text này ngắn
   lại", then "ngắn toàn bộ 4 cụm"). The token was tried first and did
   nothing here: --measure-narrow is 52ch ≈ 588px against a 558px cell at
   1440, so it only engaged above ~1700 and the copy still ran the full
   width of the cell on every normal screen. 40ch ≈ 452px stops the text
   column at the widest mark's left edge (amfori, 158px), which is the
   line the eye already reads across the four cells. Now 48ch, and it is
   only a READING cap: since the mark took its own column the copy's real
   limit is that column, which binds first at every width up to ~1700px.
   The cap was still raised twice on the client's "tăng width" — it is
   what governs on a wide screen, where the column has slack the eye does
   not want spent on 80-character lines. Deliberately NOT a token:
   --measure-narrow is 52ch ≈ 588px against a 558px cell, so it would cap
   nothing here below ~1700px. */
.cert p{ margin:0; font-size:var(--text-body); line-height:var(--lh-body); color:rgba(247,245,242,.78); text-wrap:pretty; max-width:48ch; }
.cert .tag{ color:var(--accent); }
/* The programme's own mark, top-right of the cell (client request,
   2026-08-07). Source PNGs keep the programmes' original colourways —
   dark blues and blacks that would vanish on this navy — so the filter
   flattens them to paper-white and the cell keeps one ink like the rest
   of the band. Sizes are optical, per-image, in the HTML width/height
   attrs: wordmarks ~34px, the GRS lockup 56px, the SCAN emblem 44px.
   On certifications.html the same marks sit in a flex head instead —
   see .step__head below. */
/* In flow and centred on the name block (client, 2026-08-07: "canh giữa
   theo cả ô"). It spans BOTH rows and centres in that span, so its
   reference is the whole cell, not the name it started beside. The column
   is auto rather than a fixed lane keyed to the widest mark: sized per
   cell, each text column keeps every pixel its own mark does not need
   (SCAN's 70px emblem leaves ~90px more than amfori's 158px wordmark).
   The trade this makes is unavoidable — a mark level with the copy has to
   own a lane the copy cannot enter, so the text is narrower here than it
   was when the mark sat up in the name row and the copy ran underneath
   it. `right` went with the absolute positioning; the odd cell's own
   padding-right holds the inset. */
.cert__mark{
  grid-column:2; grid-row:1 / span 2; align-self:center;
  filter:brightness(0) invert(1); opacity:.7;
  transition:opacity .5s var(--ease);
}
.cert:hover .cert__mark{ opacity:1; }
/* The mark shares its top line with the name and the tag. Measured: the
   widest pairing — the GRS tag (~272px) against the GRS mark (123px) —
   clears the half-width cell down to the page's own 1080 step now that
   the badge gutter is gone (it stopped clearing at ~1100 while the 44px
   column still stood; re-measure if a gutter ever returns).

   Below 1081 the mark leaves this beside-the-copy lane entirely. It used
   to be hidden outright here, and the client filed that as "thiếu hình
   chứng chỉ" on 2026-08-17: the measurement was right, the conclusion was
   not — the mark and the tag were competing for one ROW, not for the
   page. It is restacked above the name in LAYER Y; only the column
   collapse is left here. */
@media (max-width:1080px){
  /* The track has to go with the mark, not just its contents: a grid gutter
     sits between TRACKS, so an empty auto column still charged the cell
     ~17px of column-gap it had nothing to put there. Measured at 1000:
     401px + 0px inside 418px of content. */
  .cert{ grid-template-columns:minmax(0,1fr); }
}
/* certifications.html: the same marks in the stepline's programme heads.
   A flex row — name left, mark right — so on a narrow phone the mark
   wraps under the name (margin-left:auto keeps it on the right edge)
   instead of colliding, and no width ever has to hide it. The grid
   placement .cert__mark carries is inert in a flex parent; its
   align-self:center is not, and is wanted here for the same reason. */
.step__head{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px 24px; flex-wrap:wrap; }
/* Basis 0, not auto: flex line-breaking is decided on the items'
   HYPOTHETICAL size, before any shrinking, so with basis:auto the widest
   pairing (GRS) sent its mark down a row while the other three stayed up —
   one dropped mark in a run of four read as an accident. At basis 0 the
   name always takes exactly the space the mark leaves and wraps its own
   text instead; min-width:0 is what allows that wrap. */
.step__head .h-sub{ flex:1 1 0%; min-width:0; }
.step__head .cert__mark{ margin-left:auto; }
.step:hover .cert__mark{ opacity:1; }
/* The brief's category line under each programme head (added 2026-08-07 —
   the four approved strings the homepage .cert__id tags already carry).
   Scoped to .stepline so capabilities' stagestack steps, which have no
   category line, never inherit a stray gap. */
.stepline .step__body > .tag{ display:block; margin:4px 0 12px; color:var(--accent); }

/* --- SMETA disclosure ---------------------------------------------- */
.cert-more{ margin-top:12px; }
.cert-more summary{
  cursor:pointer; list-style:none; display:inline-flex; align-items:center; gap:10px;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.16em; text-transform:uppercase; color:var(--accent);
  position:relative;
}
/* 155x18 measured — wide enough, 26px short vertically. .faq summary already
   pays for its own thumb room with padding-block:clamp(18px,2vw,26px); this
   one is an atomic inline-level box sitting in flow, where vertical padding
   would push the disclosure off the 12px rhythm .cert-more sets above it.
   The pseudo buys the height without entering layout at all. */
.cert-more summary::after{
  content:"";
  position:absolute; top:50%; left:0;
  transform:translateY(-50%);
  width:100%; height:44px;
}
.cert-more summary::-webkit-details-marker{ display:none; }
/* The page's one arrow again, turned to point down and then up — a disclosure
   triangle drawn in a second hand would be the only chevron on the site. */
.cert-more summary .ico-arrow{ width:1em; height:1em; transform:rotate(90deg); transition:transform .4s var(--ease); }
.cert-more[open] summary .ico-arrow{ transform:rotate(-90deg); }
.cert-pillars{
  list-style:none; margin:12px 0 0; padding:0;
  display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px 20px;
  font-size:var(--text-small); line-height:var(--lh-tight); color:rgba(247,245,242,.78);
}
.cert-pillars li{ padding-left:14px; border-left:1px solid var(--line-light); }
/* --- m-facility (facility + markets) ------------------------------- */
.fac-grid{
  display:block;
  max-width:min(100%, 920px);
  margin-inline:auto;
  text-align:center;
}
/* .fac-list and .markets-grid were deleted in the v2 consolidation: the first
   held scale figures that now live only in .m-scale, the second was the old
   standalone Markets section's two-column shell. */
.fac-reach{ margin-top:var(--g-block); }
.fac-grid .idx{ justify-content:center; }
.fac-grid .h-sec{ max-width:15ch; margin-inline:auto; }
.fac-grid .facility-title{
  max-width:100%;
}
.facility-title .line{
  white-space:nowrap;
}
/* THE SECOND DOCUMENT OVERFLOW, and the one with no clip to hide it. The
   nowrap is deliberate — it holds the designed two-line reading against
   .h-sec's text-wrap:balance, so "A factory you can walk —" cannot break
   mid-phrase. But .h-sec floors at 28px and that line measures 319px, so
   below ~356 it no longer fits the 280px box, escapes an overflow:visible
   heading, and drags the whole document out to 339 at 320.

   Releasing the nowrap costs nothing above the failure point: an unwrapped
   line that already fits renders identically either way, so 356-430 is
   unchanged and only the widths that genuinely cannot hold the phrase wrap.
   The <br> still guarantees "in five markets." starts its own line, so the
   worst case is three lines rather than two — which is the correct
   degradation for a 320px phone, and better than shrinking a section
   heading below the scale's 28px floor. */
@media (max-width:430px){
  .facility-title .line{ white-space:normal; }
}
.fac-grid .lede{ max-width:62ch; margin-inline:auto; }
.fac-member{
  margin:0;
  padding-left:clamp(18px,2vw,28px);
  border-left:1px solid rgba(221,199,157,.55);
  color:var(--ink-soft);
  font-family:var(--f-mono);
  font-size:10px;
  font-weight:500;
  letter-spacing:.22em;
  line-height:1.5;
  text-transform:uppercase;
  text-align:left;
  white-space:nowrap;
}
.fac-member::before,
.fac-member::after{ content:none; }
.fac-member b{ font-weight:500; color:var(--ink); }

/* --- reach map (lives inside .m-facility) -------------------------- */
.reach-map{
  position:relative; min-height:clamp(380px,42vw,640px);
  --map-land:#013A66;
  --map-route:#6E5228;
  --map-pin:#D9A43A;
  --map-pin-core:#012E52;
  --map-origin:#F0C866;
  --map-pin-icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 42'%3E%3Cpath d='M16 41C12.7 35.6 3 27.1 3 16.3 3 8.4 8.8 2 16 2s13 6.4 13 14.3C29 27.1 19.3 35.6 16 41Z' fill='%23000'/%3E%3C/svg%3E");
  --map-dash:8;
  --map-gap:7;
  overflow:hidden; isolation:isolate;
}
/* The land layer must occupy the SAME box as .reach-map__routes/.markets:
   width-fitted, aspect 680/216, vertically centred. It used to contain-fit an
   inset box, which silently switched to height-fitting on wide viewports and
   slid every pin off its landmass — never reintroduce `contain` here. */
.reach-map::before{
  content:""; position:absolute; top:50%; left:clamp(4px,2vw,30px);
  width:calc(100% - clamp(4px,2vw,30px) * 2);
  aspect-ratio:680 / 216;
  background:var(--map-land); opacity:.44;
  transform:translateY(-50%) scale(1.035);
  transform-origin:72% 56%;
  transition:opacity 1s var(--ease), transform 1.2s cubic-bezier(.22,1,.36,1);
  -webkit-mask:url("../images/m-facility-map-land-01.svg") center/100% 100% no-repeat;
          mask:url("../images/m-facility-map-land-01.svg") center/100% 100% no-repeat;
}
.reach-map.is-in::before{ opacity:.54; transform:translateY(-50%) scale(1); }
.reach-map::after{
  content:""; position:absolute; inset:0; z-index:-1; pointer-events:none;
  background:transparent;
}
.reach-map__routes{
  position:absolute; top:50%; left:clamp(4px,2vw,30px);
  width:calc(100% - clamp(4px,2vw,30px) * 2);
  height:auto; aspect-ratio:680 / 216;
  transform:translateY(-50%);
  z-index:1; overflow:visible; pointer-events:none;
}
.reach-map__routes path{
  fill:none; stroke:var(--map-route); stroke-width:1.85;
  stroke-linecap:butt; stroke-linejoin:round; opacity:0;
  stroke-dasharray:var(--map-dash) var(--map-gap);
  vector-effect:non-scaling-stroke;
  filter:drop-shadow(0 0 1px rgba(247,245,242,.58));
}
.reach-map.is-in .reach-map__routes path{
  animation:
    reach-route-draw 1.1s .18s var(--ease) both,
    reach-route-flow 2.8s 1.36s linear infinite;
}
.reach-map.is-in .reach-map__routes path:nth-child(2){
  animation-delay:.24s, 1.42s;
}
.reach-map.is-in .reach-map__routes path:nth-child(3){
  animation-delay:.32s, 1.50s;
}
.reach-map.is-in .reach-map__routes path:nth-child(4){
  animation-delay:.40s, 1.58s;
}
.reach-map.is-in .reach-map__routes path:nth-child(5){
  animation-delay:.48s, 1.66s;
}
.markets{
  list-style:none; margin:0; padding:0;
  position:absolute; z-index:2; top:50%; left:clamp(4px,2vw,30px);
  width:calc(100% - clamp(4px,2vw,30px) * 2);
  height:auto; aspect-ratio:680 / 216;
  transform:translateY(-50%);
}
.markets li{
  position:absolute; left:var(--x); top:var(--y);
  /* 34px pins match the mobile scale on purpose (2026-07-28): at 44px the
     head dwarfed Korea/Japan/NZ and the whole cluster read as floating at sea. */
  --pin-w:34px;
  --pin-h:45px;
  /* Head circle centre of the pin glyph: 15.06/42 of the height (the SVG head
     is a circle at y=15.06), NOT height/2 — eyeballed values sat the dot low. */
  --pin-dot-y:16.1px;
  /* WHERE THE PIN POINTS. The tip of the glyph, not the dot in its head:
     41/42 down the SVG's own viewBox. Client, 2026-07-29, after four rounds of
     "chấm vẫn sai" — "tôi cần cái điểm chấm ở dưới cùng của pin". They were
     right and the old note here was wrong: a teardrop marks with its point, so
     anchoring the head put every tip ~7.5% of the map's height out to sea, and
     no amount of moving --x/--y could fix a thing the glyph was doing.

     --pin-dot-y stays, and still positions the ::after dot inside the head —
     it is decoration now, not a claim about location. Do not re-point the
     transforms at it. */
  --pin-anchor-y:calc(var(--pin-h) * 41 / 42);
  opacity:0; transform:translate(calc(var(--pin-w) * -.5), calc(var(--pin-anchor-y) * -1 + 2px)) scale(.96);
  transform-origin:calc(var(--pin-w) * .5) var(--pin-anchor-y);
  transition:opacity .55s var(--ease), transform .7s cubic-bezier(.22,1,.36,1);
  /* The pin column must be exactly --pin-w: a wider track leaves slack that
     shifts the reversed pins' dot off the head centre. */
  display:grid; grid-template-columns:var(--pin-w) max-content; gap:12px; align-items:start;
}
.reach-map.is-in .markets li{ opacity:1; transform:translate(calc(var(--pin-w) * -.5), calc(var(--pin-anchor-y) * -1)) scale(1); }
.reach-map.is-in .markets li:nth-child(2){ transition-delay:.32s; }
.reach-map.is-in .markets li:nth-child(3){ transition-delay:.54s; }
.reach-map.is-in .markets li[data-origin],
.reach-map.is-in .markets li:nth-child(4),
.reach-map.is-in .markets li:nth-child(6){ transform:translate(calc(-100% + var(--pin-w) * .5), calc(var(--pin-anchor-y) * -1)) scale(1); }
.reach-map.is-in .markets li:nth-child(5){ transition-delay:.46s; }
.reach-map.is-in .markets li:nth-child(6){ transition-delay:.72s; }
.markets li::before{
  content:""; width:var(--pin-w); height:var(--pin-h);
  background:var(--map-pin);
  -webkit-mask:var(--map-pin-icon) center/100% 100% no-repeat;
          mask:var(--map-pin-icon) center/100% 100% no-repeat;
  filter:drop-shadow(0 1px 0 rgba(247,245,242,.88)) drop-shadow(0 9px 16px rgba(1,34,61,.24));
}
.markets li::after{
  content:""; position:absolute; left:calc(var(--pin-w) * .5); top:var(--pin-dot-y);
  width:13px; height:13px; border-radius:50%;
  transform:translate(-50%,-50%);
  background:var(--map-pin-core);
  box-shadow:0 0 0 3px rgba(247,245,242,.90);
}
.markets li[data-origin]::before{
  background:var(--map-origin);
}
.markets li[data-origin]{ --pin-w:42px; --pin-h:55px; --pin-dot-y:19.7px; }
/* The origin pin is the tallest; centring its label beside the pin body keeps
   neighbouring pins' tails clear of the pill's top edge. */
.markets li[data-origin] b{ align-self:center; }
.markets li[data-origin]::after{
  width:16px; height:16px;
  background:var(--map-pin-core);
  box-shadow:0 0 0 3px rgba(247,245,242,.94);
}
/* Sonar pulse off the origin dot — exports radiate from Binh Duong, in step
   with the flowing routes. Box-shadow only: it must never gate visibility.
   --pin-ring keeps the constant white ring identical to the static state. */
.markets li{ --pin-ring:0 0 0 3px rgba(247,245,242,.94); }
.reach-map.is-in .markets li[data-origin]::after{
  animation:reach-origin-pulse 2.8s 1.5s ease-out infinite;
}
/* The flow/pulse loops above run forever once .is-in lands, and they animate
   paint properties (dashoffset under a drop-shadow, box-shadow spread) below
   backdrop-filter pills. .is-paused freezes them while the map is fully
   off-screen — play-state, never removal: reach-route-draw fills `both` on an
   opacity:0 base, so removing the animation blanks the routes where pausing
   cannot. Toggled by the 'reach map pause' block in site.js; must stay AFTER
   the two animation shorthands above, which reset play-state on a tie. */
.reach-map.is-paused .reach-map__routes path,
.reach-map.is-paused .markets li[data-origin]::after{ animation-play-state:paused; }
@keyframes reach-origin-pulse{
  0%  { box-shadow:var(--pin-ring), 0 0 0 4px rgba(217,164,58,.50); }
  72% { box-shadow:var(--pin-ring), 0 0 0 22px rgba(217,164,58,0); }
  100%{ box-shadow:var(--pin-ring), 0 0 0 22px rgba(217,164,58,0); }
}
/* Hover response, pointer devices only: the pin lifts a little harder and the
   pill sharpens. Shadows/filters only — transforms here would fight the
   entrance translates and the per-label nudges. */
@media (hover:hover) and (pointer:fine){
  .markets li::before{ transition:filter .4s var(--ease); }
  .markets b{ transition:box-shadow .4s var(--ease), border-color .4s var(--ease); }
  .markets li:hover::before{
    filter:drop-shadow(0 1px 0 rgba(247,245,242,.88)) drop-shadow(0 13px 22px rgba(1,34,61,.34));
  }
  .markets li:hover b{
    border-color:rgba(110,82,40,.40);
    box-shadow:0 1px 0 rgba(255,255,255,.75), 0 12px 26px rgba(1,34,61,.16);
  }
}
.markets b{
  display:inline-flex; align-items:center;
  padding:5px 8px 5px;
  border:1px solid rgba(110,82,40,.22);
  border-radius:999px;
  /* Safari applies backdrop-filter to the element's rectangular box and
     ignores border-radius on it, so the blur bleeds past the pill's rounded
     corners into a square halo. overflow:hidden forces Safari to clip the
     backdrop to the rounded shape like every other browser already does. */
  overflow:hidden;
  background:rgba(247,245,242,.82);
  box-shadow:0 1px 0 rgba(255,255,255,.75), 0 8px 18px rgba(1,34,61,.08);
  backdrop-filter:blur(6px);
  font-weight:540; font-size:clamp(14px,1.2vw,17px);
  letter-spacing:-.02em; line-height:1; white-space:nowrap;
}
/* The origin joins the reversed set on desktop too: its pin now sits in the
   Tonkin stretch, and a right-hand label there collides with the South Korea
   pin's tail — the pill reads cleanly over the open sea to the west. */
.markets li[data-origin],
.markets li:nth-child(4),
.markets li:nth-child(6){
  grid-template-columns:max-content var(--pin-w);
  transform:translate(calc(-100% + var(--pin-w) * .5), calc(var(--pin-anchor-y) * -1 + 2px)) scale(.96);
  transform-origin:calc(100% - var(--pin-w) * .5) var(--pin-anchor-y);
}
.markets li[data-origin]::before,
.markets li:nth-child(4)::before,
.markets li:nth-child(6)::before{ grid-column:2; grid-row:1 / span 2; }
.markets li[data-origin]::after,
.markets li:nth-child(4)::after,
.markets li:nth-child(6)::after{ left:auto; right:calc(var(--pin-w) * .5); transform:translate(50%,-50%); }
.markets li[data-origin] b,
.markets li:nth-child(4) b,
.markets li:nth-child(6) b{ grid-column:1; text-align:right; }
/* South Korea's pill otherwise kisses the Vietnam pill's top-right corner. */
.markets li:nth-child(4) b{ transform:translate(0,-10px); }

@keyframes reach-route-draw{
  0%{ opacity:0; stroke-dasharray:var(--map-dash) var(--map-gap); stroke-dashoffset:15; }
  100%{ opacity:.82; stroke-dasharray:var(--map-dash) var(--map-gap); stroke-dashoffset:0; }
}
@keyframes reach-route-flow{
  from{ stroke-dashoffset:0; }
  to{ stroke-dashoffset:-15; }
}

/* --- fac-partners (buyer logo strip, below the reach map) ---------- */
.fac-partners{
  display:flex; flex-direction:column; align-items:center;
  margin-top:clamp(36px,5vw,56px);
  padding-top:clamp(24px,3vw,36px);
  border-top:1px solid rgba(0,0,0,.1);
  gap:clamp(20px,2.5vw,28px);
}
.fac-partners__label{
  margin:0; padding:0;
  font-size:clamp(24px,3vw,38px); font-weight:800; letter-spacing:.12em; text-transform:uppercase;
  color:#000;
}
.fac-partners__logos{
  display:flex; align-items:center; justify-content:center;
  gap:clamp(40px,6vw,80px);
  list-style:none; margin:0; padding:0;
}
.fac-partners__logos li{ margin:0; }
.fac-partners__logos img{
  display:block; height:clamp(24px,3vw,40px); width:auto;
  filter:brightness(0); opacity:.6;
}
@media (max-width:480px){
  .fac-partners__logos{ gap:32px; }
  .fac-partners__logos img{ height:22px; }
}

/* --- m-sustainability (full-bleed band) --------------------------- */
.band{ position:relative; min-height:clamp(420px,62vh,660px); display:flex; align-items:flex-end; overflow:hidden; }
.band .slot{ position:absolute; inset:0; z-index:0; }
.band::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(to top, rgba(1,34,61,.90) 0%, rgba(1,34,61,.38) 55%, rgba(1,34,61,.30) 100%);
}
.band__body{
  position:relative; z-index:2; width:100%; max-width:1680px; margin-inline:auto;
  padding:clamp(44px,6vw,82px) var(--pad) clamp(46px,6.5vw,88px) var(--pad-l);
}
.band__body .h-sec{ max-width:32ch; }
#sustainability-heading{
  max-width:min(100%, 34ch);
  text-wrap:normal;
}
.band__body p{ margin:var(--g-pair) 0 0; max-width:var(--measure-body); color:rgba(247,245,242,.78); font-size:var(--text-lede); line-height:var(--lh-body); text-wrap:pretty; }
@media (min-width:721px){
  .band__body{
    display:grid;
    grid-template-columns:minmax(0,120px) minmax(0,1fr);
    gap:0 clamp(16px,3vw,56px);
    align-items:start;
  }
  .band__body .idx{ grid-column:2; padding-top:0; }
  .band__body .h-sec,
  .band__body p{ grid-column:2; }
}
@media (min-width:721px) and (max-width:1279.98px){
  #sustainability .band__body{
    padding-left:var(--pad-l);
    display:grid;
    grid-template-columns:minmax(0,120px) minmax(0,1fr);
    gap:0 clamp(16px,3vw,56px);
  }
  #sustainability .band__body .h-sec,
  #sustainability .band__body p{ grid-column:2; }
}
@media (min-width:1280px){
  .rails-on #sustainability .band__body{
    padding-left:var(--pad-l);
    grid-template-columns:minmax(0,1fr);
  }
  .rails-on #sustainability .band__body .h-sec,
  .rails-on #sustainability .band__body p{ grid-column:1; }
}

/* --- group-contact ------------------------------------------------ */
/* Compacted 2026-07-28. The section ran 1213px on a 1000px viewport for one
   heading, three hairline rows, one button and three facts — the blocks were
   never the problem, the air between them was. Everything below is a rhythm
   value: the centred stack, the row order and the single filled button are
   untouched.

   padding-block is ASYMMETRIC on purpose. <footer> directly below opens with
   its own clamp(50px,6vw,80px) of top padding, so a symmetric bottom pad here
   is counted twice and the contact grid ends up marooned a third of a screen
   above the navy. Top keeps more than the bottom because the eyebrow needs air
   off the band above it; the bottom only has to clear the footer's own. */
.group-contact{
  background:var(--field-2);
  /* The accent pays for the tinted ground. --brass is ~3.4:1 on pure white and
     only ~2.96:1 on --field-2, which puts the rail numeral and the three →
     arrows under the 3:1 floor for non-text graphics — the arrows are the only
     affordance on those rows, so they are not decorative. #96700A is the same
     gold with the value dropped: ~3.9:1 here, ~4.5:1 if this band is ever
     retinted lighter, so it has room in both directions.

     SCOPED, NOT GLOBAL. --brass itself is a client sign-off (see the token
     block) and stays the page default; this is the region override pattern
     .tex-leather already uses for bronze, and it works because custom
     properties resolve at the point of USE — --sheen is built from --accent, so
     the folio numeral's sweep re-tones itself here with no second gradient.
     --accent-2 has to come along: leave the #E8B92B highlight against a darker
     base and the sweep stops reading as one metal catching light.

     If #96700A reads too olive next to the header's gold, #A67C08 is the
     lightest value that still clears the floor (~3.28:1). Below that the fix
     is the ground, not the gold. */
  --accent:     #96700A;
  --accent-2:   #C69D26;
  text-align:left;
}
.group-contact .sec-head{ padding-bottom:var(--g-group); }
.group-contact .close-lede{
  margin-inline:0;
  max-width:58ch;
}
.contact-grid{
  position:relative;
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(260px,340px);
  gap:clamp(28px,4vw,64px);
  align-items:start;
}
.contact-primary{ position:relative; z-index:1; display:block; }
.contact-mark{
  --contact-bleed-r:calc(max(0px, (100vw - 1680px) / 2) + var(--pad));
  position:absolute;
  top:clamp(-78px, -5vw, -38px);
  right:calc(var(--contact-bleed-r) * -1);
  z-index:0;
  width:calc(clamp(520px, 40vw, 660px) + var(--contact-bleed-r));
  height:clamp(360px, 31vw, 510px);
  overflow:hidden;
  isolation:isolate;
  -webkit-mask-image:linear-gradient(90deg, rgba(0,0,0,.02) 0%, rgba(0,0,0,.10) 18%, rgba(0,0,0,.42) 46%, rgba(0,0,0,.82) 76%, #000 100%);
          mask-image:linear-gradient(90deg, rgba(0,0,0,.02) 0%, rgba(0,0,0,.10) 18%, rgba(0,0,0,.42) 46%, rgba(0,0,0,.82) 76%, #000 100%);
}
/* PAINTED, NOT PLACED (client, 2026-07-28: "phần hình đổi thành trắng hoặc
   xanh"). This was the logo-mark.svg dropped in as an <img> at opacity .16,
   which meant the file's own brass gradient was the watermark's colour — a
   warm tan sheet lying on a cool blue ground, the same "second material"
   objection that took --field-2 off beige in the first place.

   The mark is a MASK now, so its colour is a value this sheet owns rather
   than one baked into the asset. Recolouring is `--mark-ink:` and nothing
   else; the artwork file is untouched and still the brand's own.

   WHITE. Both of the client's two options were built and seen on the page the
   same day; navy at .09 was tried second and the client went back to white.
   It is also the reading the rest of the site already takes: on navy the same
   device (.ghost-mark) is white, so the mark is a LIGHTER fold of whatever it
   sits on rather than a shape placed over it, and white keeps that true on
   paper. It stays out of the way of the section's real navy — a heading, a
   filled button and three hairline rows all within 400px of it.

   --mark-o IS PART OF THE COLOUR, NOT A SEPARATE DIAL. The horizontal mask
   below fades the mark leftward but reaches FULL black at the right edge, so
   whatever sits in --mark-ink lands there undiluted: white at 1 is a soft
   emboss, --navy-ink at 1 is a near-black logo printed across the corner —
   navy needed .09 to sit where the old brass sat by weight. Change one and you
   owe the other a value; do not dial opacity down on white to "soften" it, the
   gradient is already the softening and all that is left below 1 is paper.

   White is a ~12-value step on #F3F5F8, so it is the one thing here that is
   tied to --field-2: lighten the band again and this thins with it. */
.contact-mark::before{
  content:"";
  position:absolute;
  right:0;
  bottom:0;
  z-index:0;
  width:clamp(460px, 36vw, 620px);
  aspect-ratio:450/414;
  background:var(--mark-ink, #FFFFFF);
  opacity:var(--mark-o, 1);
  -webkit-mask:url("../images/brand-logo-mark-01.svg") no-repeat right bottom/contain;
          mask:url("../images/brand-logo-mark-01.svg") no-repeat right bottom/contain;
}
@media (max-width:720px){
  /* THE ONLY DOCUMENT OVERFLOW ON THE SITE, and it was here. The box set
     left AND right AND width — over-constrained, so in LTR `right` is
     dropped and `width` wins — and then paid for the right-hand bleed
     twice: once in the discarded `right`, once again inside `width`, which
     added --contact-bleed-r and --pad-l on top of 100%. At 390 that is
     370 + 20 + 20 = 410 against a 390 viewport, and at 320 and 360 the same
     +20, because --pad is already on its 20px floor below 400.

     It does not show as a scrollbar on a phone: Chrome honours the meta
     viewport by WIDENING the layout viewport to 410 and quietly zooming the
     whole page out instead. That is why it survived every mobile
     spot-check — and why the audit measures against clientWidth, not
     innerWidth.

     The mark is a right-corner device (the mask reaches full black at the
     right edge and fades leftward), so it bleeds RIGHT only: left flush
     with the container, width = the container plus one right gutter.
     `right` goes rather than `width`, since the height/bottom pair below
     already anchors the box and a lone `width` is the honest expression. */
  .contact-mark{
    top:auto;
    bottom:clamp(-176px, -44vw, -128px);
    left:0;
    right:auto;
    width:calc(100% + var(--contact-bleed-r));
    height:clamp(300px, 82vw, 340px);
    -webkit-mask-image:linear-gradient(90deg, rgba(0,0,0,.02) 0%, rgba(0,0,0,.10) 20%, rgba(0,0,0,.42) 48%, rgba(0,0,0,.82) 78%, #000 100%);
            mask-image:linear-gradient(90deg, rgba(0,0,0,.02) 0%, rgba(0,0,0,.10) 20%, rgba(0,0,0,.42) 48%, rgba(0,0,0,.82) 78%, #000 100%);
  }
  .contact-mark::before{
    width:clamp(390px, 108vw, 430px);
  }
}
.contact-actions{ display:flex; gap:14px; justify-content:flex-start; flex-wrap:wrap; align-items:center; margin-top:var(--g-group); }
/* The direct line beside the closing button. It is deliberately NOT a second
   button: the brief allows this band one filled CTA, and a phone number that
   looks like a button turns "call us" into a competing offer. Set as quiet
   text with the accent only under the live number, so the eye still finds it
   without the band growing a second voice.
   The email sat here beside the number until 2026-08-07, separated by a middot;
   it was cut on the client's instruction. What went with it: the middot span,
   and the 66ch cap that existed only to keep that two-value line from breaking
   between the number and the address. One ~40-character value needs neither. */
.contact-direct{
  margin:0;
  color:var(--ink-soft); font-size:var(--text-small); line-height:var(--lh-tight);
  text-wrap:pretty;
}
.contact-direct a{
  white-space:nowrap;
  color:var(--navy); text-decoration:underline;
  text-decoration-color:var(--accent); text-decoration-thickness:1px; text-underline-offset:.22em;
  transition:color .35s var(--ease);
}
.contact-direct a:hover,
.contact-direct a:focus-visible{ color:var(--ink); }
/* The one FILLED button. Everything it shares with the outlined family —
   the pill, the mono type, the gold hover, the vanishing border — is declared
   once in the button system block near the header; all that is left here is
   what makes it filled rather than outlined.

   No border, deliberately. The fill is the shape. The accent that used to ring
   this button (and before that, a 1px hairline the pill kept clipping into a
   floating dash under the word) is now the hover fill itself.

   Deleting that hairline also fixed a real bug rather than only moving a
   decoration. Its `::after` and the shared arrow rule both target
   .btn-solid::after, and an element has exactly one. The later block won on
   every property it declared, so the ENQUIRE button rendered its arrow as a
   16.9x1px accent-coloured sliver — the arrow mask still applied, squashed into
   the hairline's height. With it gone the shared rule is unopposed. */
.btn-solid{
  background:var(--navy); color:var(--paper); border:0; border-radius:999px;
  padding:12px 32px 12px 36px; font-size:13.5px;
  letter-spacing:.05em; text-transform:uppercase; font-family:var(--f-mono); font-weight:500;
  position:relative; overflow:hidden; isolation:isolate;
  display:inline-flex; align-items:center; gap:12px;
  transition:color .45s var(--ease);
}
/* .contact-grid / .contact-cell were deleted with the grid itself on
   2026-07-28 — see the note in the #contact markup. Left as a comment rather
   than silently dropped because the 1px-gap-over-a-line-background trick they
   used is the page's only hairline-grid recipe, and it is worth knowing it
   existed before someone re-invents it. Recover from git history if a real
   contact page ever needs it. */

/* --- footer ------------------------------------------------------- */
/* Deep navy, not charcoal (2026-07-28). It carries .tex-navy in the markup, so
   the ground, the radial lift and the grain all arrive from the one place that
   defines them — there is no second copy of the navy treatment to drift.

   NO `background` SHORTHAND HERE, and that is the whole reason this rule looks
   incomplete. .tex-navy is declared ~950 lines earlier at the same specificity
   (0,1,0), so a shorthand on this selector wins the cascade and resets
   background-image to none: the radial lift disappears and the class looks
   broken while devtools still shows the class applied. Set colour only. */
footer{
  color:rgba(247,245,242,.78);
  min-height:clamp(340px,21vw,420px);
  padding-block:clamp(52px,5vw,76px) 26px;
  display:flex;
}
footer > .container{ display:flex; flex-direction:column; flex:1; }
/* Column layout per the Serge Ferrari reference (2026-07-28). Three things
   carry it and none of them are "four columns": the mark holds a wide track
   ALONE on the left, the lists snap into an even run pushed hard right, and
   the last track carries links with no heading over them at all. */
.foot-top{
  display:grid;
  /* Track 2 and 3 swapped with the columns (2026-08-06): Contact moved in
     front of the Company/Products pair, and each keeps its old width. */
  /* FLOORS OF ZERO, ratios unchanged. The four floors used to read 260 / 330 /
     330 / 150, and a minmax() floor is a HARD minimum: 1070px of track plus
     three gaps is 1190px of demand against a 1062px container at 1180, so the
     last column ran to x=1249 and the Compliance links were sliced down their
     middle — "Sustainabilit", "Privacy polic".

     It never showed as document overflow because <footer> carries
     overflow-x:clip for .ghost-mark, so the excess was clipped rather than
     scrolled. Measured broken from 1081 (where this rule takes over from the
     two-column one) all the way to ~1400; only 1440 and up ever had the room
     the floors were asking for.

     The fr ratios are what the reference layout is actually made of, and they
     are untouched — at 1440 the tracks land within a few px of the old ones.
     The floors were only ever a minimum-legibility guard, and they were
     guarding a width the grid could not honour. */
  grid-template-columns:minmax(0,.86fr) minmax(0,.95fr) minmax(0,1fr) minmax(0,.5fr);
  gap:clamp(30px,3.4vw,62px); align-items:start;
  --foot-h-lh:1.25; --foot-h-gap:16px;
}
.foot-brand{ max-width:330px; }
.foot-lockup{ display:flex; align-items:center; gap:14px; min-height:58px; }
.foot-logo{ display:block; width:48px; height:auto; flex:0 0 auto; opacity:.92; }
.foot-name{ display:block; color:rgba(221,199,157,.9); font-family:var(--f-mark); font-stretch:112%; font-weight:650; font-size:20px; letter-spacing:.08em; line-height:1; }
/* .66, one step under the links rather than two. At .48 the company blurb was
   the faintest type on the page and lost the value contest with the grain
   itself; it is still the quiet paragraph in the column, just legible now. */
.foot-intro{
  color:rgba(247,245,242,.66);
  margin:var(--g-pair) 0 0;
  max-width:31ch;
  font-size:var(--text-body);
  line-height:var(--lh-body);
}
.foot-link-pair{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:clamp(26px,3vw,52px); }
/* Accent, not paper. The reference sets its group headings in the brand red
   against black links; on navy the structural equivalent is brass against
   paper — same job (the heading is the colour, the links are the ink), and
   the same token the folio numerals already use, so nothing new is minted. */
.foot-col h4{ margin:0 0 var(--foot-h-gap); color:rgba(221,199,157,.86); font-size:9.5px; font-weight:500; line-height:var(--foot-h-lh); letter-spacing:.22em; }
.foot-col ul{ list-style:none; margin:0; padding:0; display:grid; gap:8px; font-size:var(--text-small); }
.foot-col li{ line-height:1.34; }
.foot-col a{ color:rgba(247,245,242,.78); }
.foot-col a:hover{ color:var(--accent); }
/* The headingless column. Its first link is aligned by arithmetic rather than
   by an empty <h4>, so there is no blank heading for a screen reader to walk
   into. 11px is .mono's font-size and the one value borrowed from another
   rule — if .mono ever changes size the symptom is this column's first link
   sitting a few px high, nothing worse. */
.foot-col--plain{ padding-top:0; }
/* Every sub-page already owns its URL, so shipping real content later never
   moves an href. The "Đang cập nhật" state now lives only on the sub-pages
   themselves, where it is the page's actual message rather than a caveat
   attached to a link. */
/* MONA.Media credit, from the master mona-signature kit — and, since the ©
   line was cut (client, 2026-07-28), the footer's entire bottom block. It
   inherits .foot-bot's two structural duties with it: margin-top:auto is the
   flex push that pins the line to the footer's floor, and the free space above
   is the reference's deep well of empty ground. The label keeps the retired
   line's type — same mono, 9px, .19em, .36 ink. The SVG's white "MONA" is
   drawn for a dark ground, which this navy footer is. */
.mona-sign{
  display:flex; align-items:center; justify-content:center; gap:13px; flex-wrap:wrap;
  padding:22px 0 2px; margin-top:auto;
  border-top:1px solid rgba(247,245,242,.07);
}
.mona-sign span{
  font-family:var(--f-mono); font-size:9px; font-weight:500;
  letter-spacing:.19em; text-transform:uppercase; color:rgba(247,245,242,.36);
}
.mona-sign a{ display:inline-flex; opacity:.9; transition:opacity .2s, transform .2s; }
.mona-sign a:hover{ opacity:1; transform:translateY(-1px); }
.mona-sign img{ height:19px; width:auto; display:block; }
/* Stacked, not padded aside: the retired © line dodged the fixed to-top button
   with padding-right:58px, but it was left-aligned down here — this line is
   centered, so the same padding would just knock it off axis. A column keeps
   label and logo on the centre line, well clear of the button's corner. The
   explicit margin-top stands in for the auto push: mobile footers have
   min-height:0, so auto resolves to nothing and the well must be paid for. */
@media (max-width:560px){ .mona-sign{ flex-direction:column; gap:9px; padding-top:18px; margin-top:48px; } }

/* --- back to top --------------------------------------------------- */
.to-top{
  position:fixed; right:16px; bottom:16px; z-index:9997;
  width:44px; height:44px; display:grid; place-items:center;
  border:1px solid var(--line); background:rgba(247,245,242,.9); backdrop-filter:blur(8px);
  opacity:0; transform:translateY(10px); pointer-events:none;
  transition:opacity .4s var(--ease), transform .4s var(--ease), border-color .3s, color .3s;
}
.to-top.is-in{ opacity:1; transform:none; pointer-events:auto; }
/* ...but never while the menu is up. At z-index 9997 it clears the overlay's
   950, so a menu opened from mid-page had a paper disc floating in the bottom
   corner of the navy sheet, offering to scroll a page that is not on screen.
   Selected off body.is-locked because that is the class the open menu already
   sets — no second flag to keep in sync. */
body.is-locked .to-top{ opacity:0; pointer-events:none; }
.to-top:hover{ border-color:var(--navy); color:var(--navy); }
/* Same arrow as everywhere else, turned to point up, rather than a second
   arrow drawn in a different hand for the sake of one button. */
.to-top .ico-arrow{ width:20px; height:20px; transform:rotate(-90deg); }

/* --- section rail ----------------------------------------------------
   This replaces a fixed rail that floated in the left margin and read the
   whole document's scroll. Two things were wrong with that and only one of
   them was visual. It named the five making stages — which are already five
   cards inside .m-making — so it duplicated a section's content; and because
   it mapped document progress onto five markers spread across twelve
   sections, it read "03 / 05 Lasting" while the visitor was looking at the
   card that says Stitching. A rail that contradicts the section beside it is
   worse than no rail at all.

   So the rail now belongs to the section instead of to the viewport: one per
   section, positioned inside it, naming THAT section. There is no longer any
   state to keep in sync, because each rail's only subject is its own parent.

   Three things fall out of that move, and together they are why it is worth
   ten elements instead of one:

   1. Consecutive rails meet. inset top:0/bottom:0 with no section margins
      means each segment's foot is the next one's head, so the page still
      reads as one continuous hairline down the left edge — one line, ten
      owners, and only the segment you are standing in carries the accent.
   2. The dark-surface case stops being code. The old rail slid across light
      and navy sections and needed a per-frame probe to decide what colour
      its label should be; `.on-dark .sec-rail` is now the whole of it.
   3. With JS off the hairline still draws and the tag still names its
      section, because both are markup. Only the accent fill needs script, and
      an unfilled hairline is an honest resting state rather than a broken
      one — where the old rail had to hide itself entirely or lie about
      where the page was.

   aria-hidden: every tag repeats the folio already set in .sec-head, so a
   screen reader that read these would hear the page outline twice. */
/* --rail-p is written from script as scroll progresses. --mark-y and --mark-h
   stay declarative so the label and the break in the hairline cannot drift
   apart by a frame during fast scroll. */
@property --rail-p  { syntax:'<number>'; inherits:true; initial-value:0; }
@property --rail-on { syntax:'<number>'; inherits:true; initial-value:1; }
@property --mark-y  { syntax:'<length>'; inherits:true; initial-value:0px; }
@property --mark-h  { syntax:'<length>'; inherits:true; initial-value:0px; }
@property --name-len{ syntax:'<length>'; inherits:true; initial-value:0px; }

/* 1280 and not 1024. The rail used to come in at 1024 with its marker hidden
   until 1280 — below that width --rail-x is clamp(20px,3vw,40px) against a
   --pad-l of clamp(20px,5vw,80px), leaving ~24px of gutter, which is the
   vertical tag's own width with nothing to spare. What that actually shipped
   was a hairline with no head: a bare gold stripe down the left edge naming
   nothing, which is what the client photographed on 2026-07-28. A rail is the
   marker plus the line; without the marker the line is not a quieter rail, it
   is decoration. So the whole thing now waits for the width that can hold it,
   which is the same 1280 where --rail-x widens to ~104px. */
.sec-rail{ display:none; }
@media (min-width:1280px){
  .sec-rail{
    display:block;
    position:absolute; z-index:2; pointer-events:none;
    /* Offset from the CONTENT's left edge, not the browser's. Past 1680px the
       container stops growing and centres, so a rail pinned to the viewport
       drifts away from the page it is indexing — 480px of empty paper between
       the two at 2560, which reads as two unrelated objects. The max(0px, …)
       term is the container's own left margin, and it is zero until 1680, so
       below that this is exactly --rail-x on its own. */
    left:calc(max(0px, (100vw - 1680px) / 2) + var(--rail-x));
    top:0; bottom:0; width:2px;
    /* Where the marker rests with no script running: level with the folio,
       which is where the marker's own margin-top puts it. */
    --mark-y:calc(var(--sec-pad) + 6px);
    --mark-h:120px;
    --rail-plate:var(--field);
  }
}

/* The hairline is its own element now, and it has to be. The break that lets
   the marker sit INSIDE the rule is a mask, and a mask on .sec-rail would
   apply to the rail's children too — erasing the very numeral the break was
   opened for. Splitting the line out leaves the marker a sibling, outside the
   mask's reach.

   The break is cut rather than covered. A chip of background behind the
   numeral was the obvious alternative and it does not survive this page: the
   ground is a gradient on light sections and a textured navy on dark ones, so
   any solid plate shows its own seam. Removing the line where the marker
   stands has no colour to get wrong, and it is also what the reference does —
   the rule stops for the label and picks up again below it.

   The cutout belongs to the LINE only, and the fill must never carry a copy of
   it. The fill is a child of the line, so the line's mask already clips it —
   text sits in a real gap either way. Giving the fill its own mask cut a
   SECOND hole that drifted: a mask is resolved in the element's local box and
   `transform:scaleY(--rail-p)` is applied after it, so the fill's copy of the
   hole was scaled by p along with everything else. At p=0.48 on 01/About the
   gap meant for y 205–331 landed at 176–237 instead, and the accent read
   gold / grey / nothing / gold — a stripe with a bite out of its middle. The
   two holes only ever agreed at p=1, which is why this survived the sections
   that fill completely. */
.sec-rail__line{
  --gap-a:calc(var(--mark-y) - 14px);
  --gap-b:calc(var(--mark-y) + var(--mark-h) + 14px);
  -webkit-mask-image:linear-gradient(#000 0, #000 var(--gap-a), transparent var(--gap-a), transparent var(--gap-b), #000 var(--gap-b));
          mask-image:linear-gradient(#000 0, #000 var(--gap-a), transparent var(--gap-a), transparent var(--gap-b), #000 var(--gap-b));
}
.sec-rail__line{
  position:absolute; inset:0; width:2px; background:rgba(20,22,26,.18);
}
.on-dark .sec-rail__line{ background:var(--line-light); }
.group-contact .sec-rail__line{ background:rgba(20,22,26,.16); }
.sec-rail__fill{
  position:absolute; inset:0 auto 0 0; width:2px; background:var(--accent);
  transform:scaleY(var(--rail-p,0)); transform-origin:top;
  will-change:transform;
}

/* --- --bare: the spine crossing a band that has no heading to mark ----
   Client, 2026-08-07: "section này bị thiếu mất line dọc tiến độ bên trái như
   các section khác". The full-bleed .plate bands carried no rail at all, so on
   capabilities the spine died for 617px of photograph and picked up again
   underneath — read as two unrelated pages rather than one. The earlier note
   further down ("`.statement` and `.plate` carry no rail either") was written
   when the rail was a page-length progress bar and a break in it meant
   something; each rail measures only its own block now, so a plate can carry
   one honestly.

   NO MARK, and that is the whole variant. A plate has no folio and no heading
   — a numeral here would be invented, and memory of this project is explicit
   that rail numerals are read from the folio and never typed. What the band
   needs is the LINE, which is what the client asked for.

   The MASK GOES, and zeroing the two properties is not enough — that was the
   first attempt and it shipped a notch. The hole that lets the marker sit
   inside the rule is declared on .sec-rail with a 120px default, and site.js
   only writes --mark-y/--mark-h when it finds a .sec-rail__mark to measure, so
   with no mark the default survives and the hairline takes a 120px bite for a
   marker that does not exist. But the two stops are `--mark-y - 14px` and
   `--mark-y + --mark-h + 14px`: at 0/0 they resolve to -14px and +14px, which
   is still a 14px cutout, now at the very top of the band where it reads as the
   line failing to reach the edge (measured). The stops cannot be collapsed
   while the 14px breathing room exists, so the mask itself is what has to go.
   The properties are zeroed anyway, for the same reason the fill never carries
   its own copy of the mask: so nothing downstream inherits a live gap. */
.sec-rail--bare{ --mark-y:0px; --mark-h:0px; }
.sec-rail--bare .sec-rail__line{
  -webkit-mask-image:none;
          mask-image:none;
}
/* Every plate is a photograph, so the hairline is on the light side of the
   ledger whatever the sections above and below it are doing. .28 rather than
   --line-light's own value: a hairline on a busy photograph needs more than a
   hairline on flat navy, and both plates here carry a bottom scrim that the
   lower half of the line crosses. */
.sec-rail--bare .sec-rail__line{ background:rgba(247,245,242,.28); }

/* The marker starts on the section folio's line, then sticks under the fixed
   header while that section scrolls. */
.sec-rail__mark{
  position:sticky; top:calc(var(--hdr-h) + clamp(14px,2.4vh,30px));
  margin-top:calc(var(--sec-pad) + 6px);
  display:flex; flex-direction:column; align-items:center; width:2px;
  z-index:2;
}
.sec-rail__num{
  display:flex; align-items:center; margin-right:0; padding:7px 0 6px;
  writing-mode:vertical-rl;
  font-family:var(--f-mono); font-weight:500; font-size:11px; line-height:1;
  letter-spacing:.16em; color:var(--accent);
  background:transparent;
  box-shadow:none;
}
/* Fallback head for a section whose folio carries no number. Certifications
   used to be the one such case — a sub-block of 05 with a name and no numeral
   — and the client read the lozenge as an inconsistency next to seven numbered
   rails (2026-07-28), so it now carries 06 of its own and the sections below
   it moved up to 07 and 08. Nothing on the page reaches this rule today; it is
   kept so a future numberless section degrades to a mark rather than to a
   headless rule. Given the numeral's own width so the run below it stays
   centred on the rule whichever head it has. */
.sec-rail__num--mark{
  margin-right:0;
  width:2px;
}
.sec-rail__num--mark::before{
  content:''; width:5px; height:5px; background:var(--accent); transform:rotate(45deg);
}
/* Reading top-to-bottom rather than bottom-to-top. The old tag was a spine, and
   a spine is read upward, so rotate(180deg) put its first letter nearest the
   section's last line. This one is not a spine: it falls from the number above
   it the way the rest of the page falls. Reversed, you would read the name and
   arrive at its number last. */
.sec-rail__tag{
  margin-top:10px; padding:7px 0;
  writing-mode:vertical-rl;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.2em; text-transform:uppercase; white-space:nowrap;
  color:var(--ink-soft);
  opacity:1;
  background:transparent;
  box-shadow:none;
}
.on-dark .sec-rail__tag{ color:rgba(247,245,242,.66); }
.on-dark .sec-rail__num,
.on-dark .sec-rail__tag,
.band .sec-rail__num,
.band .sec-rail__tag{
  background:transparent;
  box-shadow:none;
}
.on-dark .sec-rail{ --rail-plate:var(--navy-ink); }
.band .sec-rail{ --rail-plate:var(--navy-ink); }
.group-contact .sec-rail{ --rail-plate:var(--field-2); }
/* .band clips its own parallax image, and an overflow:hidden ancestor takes
   over as the sticky scrollport — a scrollport that never scrolls, so the
   marker would freeze wherever it was laid out. Static is the honest version
   of that: inside a band the marker simply sits level with the heading and
   leaves with it. */
.band .sec-rail__mark{ position:static; }

/* --- intro curtain -------------------------------------------------
   Hidden unless the inline head script armed it, so a visitor with JS off
   never meets a navy rectangle it has no way to lift. */
.intro-curtain{ display:none; }
/* The `.is-lifting` half of this selector is load-bearing, not defensive.
   lift() adds .is-lifting and removes .intro-armed in the same synchronous
   block — it drops .intro-armed there so the hero's paused reveal restarts in
   step with the curtain rather than after it. Without the second selector the
   curtain loses `display:grid` on that very frame, so the lift is never
   rendered at all and the navy just blinks out. Measured when this was first
   hit: the state log went straight from (display:grid, closed) to
   (display:none, open) with no frame in between. Keying display off the
   curtain's own state instead of the root class lets the lift play while the
   hero is already moving. */
.intro-armed .intro-curtain,
.intro-curtain.is-lifting{
  display:grid; place-items:center;
  position:fixed; inset:0; z-index:9990;
  background-color:var(--navy-ink);
  background-image:
    linear-gradient(180deg, rgba(1,34,61,.90), rgba(1,34,61,.95)),
    url("../images/m-capabilities-making-03.webp");
  background-size:cover;
  background-position:center;
  /* Deliberately NOT pointer-events:none. While the panels are closed the nav
     and the Enquire button sit under an opaque navy field, and a click that
     passed through would fire a link the visitor cannot see. Swallowing it
     costs nothing: the skip listeners are bound to window, so the click still
     bubbles up and still lifts the intro early. */
}
.intro-curtain.is-lifting{
  background-color:transparent;
  background-image:none;
}
.intro-armed body{ overflow:hidden; }

/* Two navy panels riding inside a frame that pushes toward the camera as they
   part. The reference this was rebuilt from opens with the same two-half split,
   but its force comes from what the halves are travelling INSIDE: the whole
   assembly scales 1 → 4 across the same window, so the panels leave the
   viewport edge four times faster than they are actually moving. That is the
   difference between a wipe and a cut — the frame is not sliding away from the
   visitor, it is rushing past them.

   The frame owns the navy, not .intro-curtain, and that is what makes the
   handover a dissolve rather than a reveal: once the halves have cleared, the
   only thing still covering the page is the frame's own background, and it is
   already on its way to transparent. Paint the navy on the curtain itself
   instead and an unfading sheet sits behind the entire move.

   The top panel still travels upward — the same gesture as every
   [data-aos="mask"] heading — so the vocabulary the rest of the page speaks is
   still taught here, just in stereo.

   The extra 1px on each panel is not cosmetic — they meet with a 2px overlap.
   Two panels at exactly 50% can land a sub-pixel apart on a fractional viewport
   height and flash a hairline of the page through the seam before the intro has
   even begun. */
.intro-curtain__frame{
  position:absolute; inset:0; z-index:0;
  background:transparent;
  transform-origin:50% 50%;
  will-change:transform;
}
.intro-curtain__bar{
  position:absolute; left:0; right:0; height:calc(50% + 1px);
  background:var(--navy-ink);
  opacity:0;
  overflow:hidden;
  will-change:transform;
}
.intro-curtain__bar--t{ top:0; }
.intro-curtain__bar--b{ bottom:0; }
.intro-curtain.is-lifting .intro-curtain__bar{ opacity:1; }

/* The gold hairline that used to ride each panel's inner edge is gone with the
   dwell it was timed against. It was an aperture marker: it read as an aperture
   only because the frame stopped at 2.39:1 long enough for an edge to be looked
   at. Against a sub-second push it would be a gold flicker crossing the screen — the
   one thing on this page that would look like an accident rather than an edit. */

/* One stroke, no dwell. The letterbox hold that used to sit at 33–73% was the
   old opening's entire idea, and it goes with it: a frame that pauses is a
   frame the visitor is meant to look INTO, and this one is meant to be looked
   THROUGH. Panels and frame now run on one clock, one duration, one easing,
   because they are one gesture rather than three segments.

   cubic-bezier(.85,0,.15,1) is measured off the reference, not chosen. Sampled
   at 50ms across its open, the reference's progress sits under 5% for the first
   third of the move and is past 75% by the two-thirds mark — a near-still
   opening, then everything at once. That stillness is what sells the scale: the
   frame appears to hang for a beat before it leaves, so the push reads as
   acceleration rather than as a constant zoom. Swap in the house --ease (an
   ease-out) and the identical scale reads as a lens racking instead.

   .84s, against the 1.75s it replaces. Two thirds of the old number was dwell
   and the slow clear after it; the parting stroke itself stays weighty, but the
   full intro now lands under two seconds on a normal parse instead of feeling
   like a separate title sequence. */
.intro-curtain.is-lifting .intro-curtain__frame{
  animation:curtainPush .84s cubic-bezier(.85,0,.15,1) forwards;
}
.intro-curtain.is-lifting .intro-curtain__bar--t{
  animation:barPartUp .84s cubic-bezier(.85,0,.15,1) forwards;
}
.intro-curtain.is-lifting .intro-curtain__bar--b{
  animation:barPartDown .84s cubic-bezier(.85,0,.15,1) forwards;
}
/* --lb, the letterbox offset the dwell used to stop at, is gone: with no hold
   there is no ratio to derive. The panels now travel 100% of their own height,
   which is exactly half the viewport, and stop there.

   That figure is only half the exit — on its own it would park each panel
   flush against the viewport edge, still touching it, which the eye reads as
   stopping rather than leaving. scale(4) on the frame is what turns it into an
   exit: the panels are still crossing the enlarged frame when they cross the
   viewport's edge, so they leave the screen mid-travel. 4 is the reference's
   own figure and behaves as a floor rather than a taste — below about 2.5 the
   halt at the edge starts to show again.

   `transparent` rather than a written-out rgba(1,34,61,0): colour interpolation
   is premultiplied, so both fade at an unchanging hue and neither goes grey on
   the way out, but only `transparent` cannot drift out of sync with --navy-ink
   the way a duplicated literal can. */
@keyframes curtainPush{
  from{ transform:scale(1); background-color:var(--navy-ink) }
  to  { transform:scale(4); background-color:transparent }
}
@keyframes barPartUp{   to{ transform:translateY(-100%) } }
@keyframes barPartDown{ to{ transform:translateY(100%) } }
/* A slow push-in across the whole intro. This is the one touch that reads as a
   camera rather than a slideshow, and it is safe to freeze: it starts at
   scale(1), the element's own resting value, so a frozen timeline holds it
   visible and merely still — the rule [[frozen-timeline-blank-page]] exists to
   enforce. Never give this one a `from{opacity:0}`. */
.intro-curtain__lockup{
  display:grid; justify-items:center; gap:14px; padding:24px;
  position:relative; z-index:2;
  animation:curtainDrift 2s linear both;
  /* The .42s opacity/filter transition that used to sit here is gone with the
     dissolve it drove — see the is-lifting rule below, which now animates the
     lockup out instead of fading it. Nothing on this element transitions any
     more; both of its states are keyframed. */
  transform-origin:50% 50%;
  backface-visibility:hidden;
}
/* 2s / 1.03. The drift is the resting state's own slow push-in and it is
   overwritten wholesale the moment .is-lifting lands — lockupClear replaces the
   `animation` shorthand, which stops curtainDrift rather than compositing with
   it. That is the intended handover and the reason the exit had to become an
   animation: as a transition it would have fought curtainDrift for `transform`
   and lost. 2s is still longer than the ~1120ms the drift can actually reach,
   but the overrun is now deliberate — it means the push-in is still visibly
   accelerating at the cut rather than having quietly arrived. */
@keyframes curtainDrift{ from{ transform:scale(1) } to{ transform:scale(1.03) } }

/* --- intro navy texture ---------------------------------------------
   Real project asset, not procedural grain: the title card borrows the
   stitching/leather photograph already used in the page and buries it under a
   dense navy wash. It reads as material, while the lift itself stays a clean
   navy split so no texture sheet lingers over the hero. */

/* The lockup is only the title card. Once the panels start opening, it clears
   quickly so the hero does not inherit a second, oversized image on top of the
   video. The split/push still carries the transition; the mark no longer flies
   into the opened page. */
.intro-curtain.is-lifting .intro-curtain__lockup{
  animation:lockupClear .18s var(--ease) forwards;
}
@keyframes lockupClear{
  from{ transform:scale(1.02); opacity:1; filter:blur(0) }
  to  { transform:scale(.98); opacity:0; filter:blur(10px) }
}

/* Delays are tuned to land each mark before the lift, not to a rhythm of their
   own: the last glyph settles at ~.82s, panels part at 900ms. That small
   stillness is deliberate. A title card cut while something on it is
   still SETTLING reads as an accident; one whose marks have come to rest, and is
   then cut, reads as an edit.

   The progress bar is the single deliberate exception, and it is exempt because
   it is not a mark — it is the clock. It is still travelling through that 170ms
   and lands full on the exact frame the panels part, so the cut is the thing it
   was counting toward rather than something that interrupts it. The stillness
   above is unaffected: nothing is settling in it, one thing is elapsing. That
   reading survives only while the numbers agree. Move HOLD alone and the bar
   becomes a countdown to the wrong moment,
   which is a worse thing to have built than the plain rule it replaced.

   The lift was 1500ms while a third mark, a mono location line, settled last.
   That line was cut; HOLD follows the final wordmark beat now, because the
   number is derived from the last mark rather than from a fixed title-card tax.

   Every entrance below declares its RESTING value on the element and keeps only
   the start state in the keyframe — resting tracking, resting wdth, resting
   filter. Kill these animations and each element lands on its finished
   appearance rather than a start frame. See [[frozen-timeline-blank-page]].

   Opacity is the one property that does NOT follow that rule here: each mark
   declares opacity:0 and animates to 1, so a frozen timeline would hold it
   invisible. That is survivable only because the curtain is the one component
   whose frozen-tab case is answered by removing it wholesale — `.no-entrance
   .intro-curtain{display:none}` further down. Nothing rescues these elements
   individually, so do not copy this opacity pattern into anything that stays
   on the page. */
.intro-curtain__lockup img{
  width:clamp(52px,7vw,78px); height:auto; filter:brightness(0) invert(1);
  opacity:0; animation:curtainMark .6s var(--ease) .06s forwards;
}
/* The wordmark ARRIVES OVERSIZED AND CONTRACTS while its glyphs drop in one at
   a time. Both halves are measured off the reference's rendered geometry rather
   than read out of its transforms, which is the only way to get this right —
   Framer runs a layout-projection trick whose matrices decompose into a
   rotation that is not really there.

   Reference, wordmark row, rendered width: 590px → 169px over ~950ms. That is
   scale(3.49) settling to scale(1), and 3.5 is what makes the gesture read as
   GATHERING rather than as a zoom: the word starts wider than its own lockup —
   wider than a phone screen — so the eye meets letters before it meets a word,
   and the word assembles as it shrinks into place.

   Replaces curtainFocus, which faded the wordmark up while closing its tracking
   from .34em and travelling the Archivo wdth axis 92 → 112. That was the better
   piece of type design of the two and it is a real loss: it moved the house type
   system, where this moves generic geometry. It cannot survive alongside the
   contraction, though — an animated letter-spacing inside a scaling box makes
   the glyphs swim against their own advance widths, and per-glyph staggering
   needs the letters to be separate boxes, which is exactly what a tracking
   animation on the parent cannot address. */
.intro-curtain__name{
  font-family:var(--f-mark);
  font-size:clamp(22px,3.4vw,38px); font-weight:600; letter-spacing:.14em;
  color:var(--paper); font-variation-settings:"wdth" 112; line-height:1;
  /* flex, so the nine boxes sit in a row with no whitespace text nodes between
     them — the markup is deliberately newline-broken inside the tags for that
     reason, and flex makes the guarantee rather than relying on it. The negative
     margin cancels the trailing letter-space after the final H, which would
     otherwise push the whole row ~.07em left of the mark above it. */
  display:flex; margin-right:-.14em;
}
/* The contraction sits on the GROUP, not on .intro-curtain__name, and that is
   the fix for a collision rather than a preference. Scaled on the wordmark
   alone, the 3.5× word overlapped the mark laid out above it — `transform`
   changes no layout, so the mark had no idea the word had grown into it.
   Scaling both together holds their spacing at every frame of the move. */
.intro-curtain__logo{
  display:grid; justify-items:center; gap:14px;
  transform-origin:50% 50%;
  animation:nameContract .66s cubic-bezier(.85,0,.15,1) .16s both;
}
/* .3s each, 45ms apart, so glyph 9 starts at .16 + 8×.045 = .52s and settles at
   .82s — just as the .16 + .66 contraction is finishing. (Once compressed all
   the way to 25ms/.50s and reverted: the client clarified that "faster" meant
   a shorter PAUSE downstream, not a hastier card. Whatever the pace, tighten
   the pair together to keep the invariant below.) The two were tuned to land
   together on purpose: if the letters finish first the word visibly shrinks
   while already complete, which is a zoom; if the contraction finishes first the
   letters pop in at final size, which is a fade. They have to be simultaneous or
   the gesture is neither.

   The rise is .38em, not a pixel value — it has to scale with the type, and the
   type is a clamp() across a 22–38px range. */
.intro-curtain__ch{
  display:inline-block; white-space:pre;
  opacity:0; animation:chRise .3s var(--ease) calc(.16s + var(--i) * .045s) forwards;
}
@keyframes chRise{
  from{ opacity:0; transform:translateY(.38em) }
  to  { opacity:1; transform:none }
}
/* `both`, so the 3.5 start frame applies through the .18s delay rather than
   flashing the word at final size for three frames first. Its frozen-timeline
   case is the same one the rest of this card relies on and no better: the whole
   curtain is display:none under .no-entrance. Do not copy the pattern outward. */
@keyframes nameContract{ from{ transform:scale(var(--contract,3.5)) } to{ transform:scale(1) } }
/* 3.5 is the reference's figure and it only survives on a wide screen. Their
   wordmark is "Benjamin"; ours is "NHẬT MINH" — nine glyphs, uppercase, tracked
   .14em — so it rests about 1.8× wider relative to its container. Measured at
   390px: the 3.5× word spans x = -96 to 497 on a 390px viewport, so the first N
   is still half off the left edge at the moment it is fading in. A glyph the
   visitor never sees whole cannot be one of the letters "appearing one by one".

   The step-down is by breakpoint rather than a clamp() because scale() takes a
   number and CSS cannot divide a viewport width by a length to produce one.

   Both steps moved onto the sheet's own ladder (1024 → 1080, 700 → 720) in the
   2026-08-14 breakpoint consolidation. Safe to move because this is a ramp, not
   a flip: --contract is a scale number that steps DOWN as the screen narrows,
   so shifting a step by 56px or 20px changes only where an imperceptible
   gradation lands. The measured 390px constraint above is untouched — 390 is
   below both the old and the new small step. The 1024 here was also the one
   number in the sheet citing "the rails' own 1024 breakpoint", which the rails
   never had; they gate at 1280. */
@media (max-width:1080px){ .intro-curtain{ --contract:2.8 } }
@media (max-width:720px) { .intro-curtain{ --contract:2 } }

/* Benjamin-style open: the title card becomes two travelling panels, revealing
   the hero through a horizontal aperture before the overlay is removed. */
.intro-curtain__frame{ display:block; }
/* A TWO-BEAT open, by the client's direction (2026-07-28): part to an
   aperture, hold there, then finish. One clock of 1.8s, four segments —

     0–8%     still: the lockup is blurring out over the closed card
     8–66%    part to the aperture, decelerating INTO the stop. The travel
              (~1.04s) is keyed to the headline BY THE CLIENT'S SECOND NOTE:
              the hero's two lines rise on riseIn (.9s, delays .14s/.26s)
              from the same lift, so the panels land at ~1.19s — right on the
              beat the second line finishes (~1.16s). The pause begins only
              once the full title is standing in the band.
     66–70%   the dwell, and what the client actually measures here is the
              FULL MOTIONLESS WINDOW — from the frame where the panels look
              stopped to the frame where they visibly move again — which is
              what "giảm thời gian dừng" meant on their fifth note (a pass
              that shortened only the nominal segment left ~600ms of stillness
              and was flagged from a screenshot of this very moment). Every
              ease-out ends at velocity zero, so ANY landing curve parks the
              panels perceptibly early — measured ~300ms of invisible tail
              even on a crisp one. Hence the 50% keyframe: the big decel hands
              over to a short LINEAR glide (the last 5% of travel, ~290ms at
              ~90px/s — plainly visible motion) that arrives at the stop still
              moving, so stillness begins on the stop frame itself and reads
              as an arrival, not a fade into parking. The exit is easeInCubic
              (.32,0,.67,0), visibly moving within ~100ms, where easeInQuint
              sat dead for ~150ms. Net motionless window: ~250ms.
     70–100%  the exit: panels run to ±100% while the frame pushes to
              scale(4), both accelerating, so the halves cross the viewport
              edge mid-travel — a cut, not a wipe. Cubic, not quint: the
              dead-zone was doing hidden dwell-padding, see above.

   The aperture is ASYMMETRIC: top −40% (edge at 30vh), bottom +70% via
   --ap-b (edge at 85vh) — measured so both headline lines AND the lede sit
   whole inside the band; at ±40% the second line was cut by the bottom
   panel, which is exactly what the client flagged. --ap-b widens further on
   phones, where the headline sits lower in the viewport.

   The shorthand easing is linear because it is a carrier: every segment
   declares its own curve inside the keyframes (a timing function written in a
   keyframe governs the segment that STARTS there). Decel into the dwell,
   accel out of it — the two beats must not share a curve or the stop mushes.
   scale(4) is the reference's own figure; below ~2.5 the halt at the edge
   shows again. All three keyframe sets, riseIn's delays and the removal
   timer in the body script are one mechanism — retime them together or not
   at all. */
.intro-curtain{ --ap-b:70%; }
@media (max-width:720px){ .intro-curtain{ --ap-b:76%; } }
.intro-curtain.is-lifting .intro-curtain__frame{
  animation:refFramePush 1.8s linear forwards;
}
@keyframes refFramePush{
  0%,66%{ transform:scale(1); animation-timing-function:linear }
  70%{ transform:scale(1.02); animation-timing-function:cubic-bezier(.32,0,.67,0) }
  100%{ transform:scale(4) }
}
.intro-curtain__bar{
  z-index:1;
  opacity:0;
  background-color:var(--navy-ink);
  background-image:
    linear-gradient(180deg, rgba(1,34,61,.90), rgba(1,34,61,.95)),
    url("../images/m-capabilities-making-03.webp");
  background-size:100vw 100vh;
}
.intro-curtain__bar--t{ background-position:center top; }
.intro-curtain__bar--b{ background-position:center bottom; }
.intro-curtain__panel-lockup{
  position:absolute; left:0; width:100vw; height:100vh;
  display:none; place-items:center; pointer-events:none;
}
.intro-curtain__panel-lockup--t{ top:0; }
.intro-curtain__panel-lockup--b{ bottom:0; }
.intro-curtain__panel-lockup .intro-curtain__logo{
  animation:none; transform:none;
}
.intro-curtain__panel-lockup img{
  width:clamp(52px,7vw,78px); height:auto; filter:brightness(0) invert(1);
}
.intro-curtain__panel-lockup .intro-curtain__ch{
  opacity:1; animation:none; transform:none;
}
.intro-curtain.is-lifting .intro-curtain__bar{
  opacity:1;
}
.intro-curtain.is-lifting .intro-curtain__bar--t{
  animation:refPanelTop 1.8s linear forwards;
}
.intro-curtain.is-lifting .intro-curtain__bar--b{
  animation:refPanelBottom 1.8s linear forwards;
}
.intro-curtain.is-lifting .intro-curtain__lockup{
  animation:logoClearBeforeSplit .24s var(--ease) forwards;
}
/* Each bar is 50vh tall, so translateY(-40%) puts the top edge at 30vh and
   translateY(+70%) puts the bottom edge at 85vh — the asymmetric band the
   comment above derives. var() is legal inside keyframes and resolves against
   the animated element, which inherits --ap-b from .intro-curtain; that is
   what lets one keyframe set serve both breakpoints. The exit segment's
   cubic-bezier(.64,0,.78,0) ends at full velocity on purpose — the panels
   are LEAVING, and a curve that decelerated at 100% would read as the halves
   parking just off-screen rather than rushing past. */
/* The 8–50% easings end at NONZERO slope, velocity-matched to the linear
   glide that follows (top: 87/231 px/s ⇒ y2=.81; bottom travels farther in
   the same time, 87/430 ⇒ y2=.9). An ease-out that dies at zero here parks
   the panel at the 50% keyframe ~200ms before the glide segment begins — a
   visible mid-air stall at −35%, measured, which is why these curves may not
   be swapped for a stock ease-out. */
@keyframes refPanelTop{
  0%,8%{ transform:translateY(0); animation-timing-function:cubic-bezier(.2,.7,.5,.81) }
  50%{ transform:translateY(-35%); animation-timing-function:linear }
  66%{ transform:translateY(-40%); animation-timing-function:linear }
  70%{ transform:translateY(-40.5%); animation-timing-function:cubic-bezier(.32,0,.67,0) }
  100%{ transform:translateY(-100%) }
}
@keyframes refPanelBottom{
  0%,8%{ transform:translateY(0); animation-timing-function:cubic-bezier(.2,.7,.5,.9) }
  50%{ transform:translateY(calc(var(--ap-b,70%) - 5%)); animation-timing-function:linear }
  66%{ transform:translateY(var(--ap-b,70%)); animation-timing-function:linear }
  70%{ transform:translateY(calc(var(--ap-b,70%) + .5%)); animation-timing-function:cubic-bezier(.32,0,.67,0) }
  100%{ transform:translateY(100%) }
}
@keyframes logoClearBeforeSplit{
  from{ opacity:1; transform:scale(1.02); filter:blur(0) }
  to{ opacity:0; transform:scale(.985); filter:blur(8px) }
}
/* The gold progress bar that used to close the lockup left a slot, and the slot
   stays EMPTY. A caption filled it for one revision — "Vietnamese footwear
   manufacturer", lifted from the approved <title> — and that was a mistake with
   a name: the lockup is mark + wordmark and nothing else, which the client has
   asked for twice, and the first re-add was justified exactly the way this one
   was, by the line being approved copy elsewhere. Copy being approved as CONTENT
   does not make it part of the identity. See [[logo-lockup-is-fixed]].

   Losing the bar itself is losing a real thing — it was the only element that
   told the visitor how much longer, and its fill was timed to land full on the
   exact frame the panels parted. That was affordable against a 3.05s intro and
   is not against a sub-two-second one: a countdown is worth drawing when the wait is long
   enough to be felt, and past about two seconds the honest answer to "how much
   longer" is "you have already stopped asking".

   So the card carries two marks, not three. The reference carries three, and
   this is the single place the intro knowingly departs from it. */
@keyframes curtainMark{
  from{ opacity:0; transform:scale(1.14); filter:brightness(0) invert(1) blur(5px) }
  to{ opacity:1; transform:none; filter:brightness(0) invert(1) blur(0) }
}
/* The hero's own entrance is held at frame zero while the curtain is up,
   then released — otherwise it plays out unseen behind the navy. */
.intro-armed .reveal-line > span,
.intro-armed .banner .slot{ animation-play-state:paused; }

/* --- parallax ------------------------------------------------------
   Applied to the PICTURE inside the frame, never the frame itself: the
   banner frame is already running slowzoom's scale(), and two transforms
   on one element overwrite each other. The picture is oversized and offset
   so translating it can never expose an edge. */
.slot--px > picture,
.slot--px > video{ height:126%; top:-13%; will-change:transform; }

/* --- page transition -----------------------------------------------
   Both halves, but built out of two different primitives, and the reason
   matters more than the effect.

   A `from{opacity:0}` fade-in written as @keyframes was tried here and
   removed: a background tab freezes the document timeline, so the animation
   sits at currentTime 0 forever, and a running animation outranks the base
   value in the cascade even while frozen. The result is an entirely blank
   page for anyone who opens the site in a background tab or renders it
   headlessly — measured here, not theorised. Dropping fill-mode does not
   help, because the animation is still running.

   .pt-fade below is that fade-in reinstated as a TRANSITION with
   @starting-style, which has no such hold: `opacity:1` is the element's real
   computed value and @starting-style supplies only the one-frame start
   state. Frozen tab, no @starting-style support, transition.js missing —
   every one of those lands on a visible page. Visibility is never gated on
   the entrance running. Do not rewrite this as @keyframes.

   Leaving stays an animation, and that is safe for the opposite reason: it
   is JS-triggered, a frozen tab is one nobody is navigating away from, and a
   finished animation outranks a normal declaration. */
.is-leaving body{ animation:ptOut .26s var(--ease) both; }
@keyframes ptOut{ from{ opacity:1 } to{ opacity:0 } }

/* .26s mirrors ptOut, so leaving and arriving are one symmetrical gesture.
   Shared verbatim with assets/css/common.css so the homepage and the
   sub-pages arrive identically. */
.pt-fade{ opacity:1; transition:opacity .26s var(--ease); }
@starting-style{ .pt-fade{ opacity:0; } }

/* Set by transition.js when the page loaded in a background tab, where the
   timeline is frozen and every staged entrance would otherwise hold its
   invisible first frame forever. The hero headline is the one that matters:
   its lines start at translateY(105%) inside an overflow:hidden mask, so a
   frozen riseIn leaves the H1 clipped completely out of view.

   `.intro-curtain__lockup > *` used to be in this selector and was removed: the
   rule below hides the whole curtain under .no-entrance, so its lockup children
   are inside a display:none ancestor and could never have rendered either way.
   A guard that cannot fire is worse than no guard, because it reads as
   protection. The curtain's real protection is the rule below, nothing here. */
.no-entrance .reveal-line > span{
  animation:none !important; opacity:1 !important; transform:none !important;
}
/* The panels are a harder case than the single sheet they replaced: two opaque
   rectangles meeting at the centre cover the viewport completely, and their
   only way back out is an animation. A frozen timeline therefore hides the
   whole site rather than merely holding an entrance. lift()'s setTimeout does
   still fire in a hidden tab and would eventually rip them out — but relying
   on that is exactly the "truth lives only inside the animation" trap this
   project has been bitten by three times. If the tab was hidden at load there
   was no intro to watch anyway, so skip it outright and show the site. */
.no-entrance .intro-curtain{ display:none !important; }
/* Belt and braces for .pt-fade. It is already safe on its own — a transition
   holds no from-state — so this only skips an entrance nobody could watch
   rather than rescuing one. */
.no-entrance .pt-fade{ transition:none; }

/* --- reveal on scroll ----------------------------------------------
   Four entrances, not one. A single fade-up applied to every element is the
   reflex that makes a page feel machine-assembled; each variant here is
   chosen to match the physical behaviour of the thing it reveals.

   fade-up  prose — the default, and still the right answer for a paragraph
   mask     headings — a curtain lifting, echoing the hero's line reveal
   slide    list rows — entering along the reading direction, staggered
   frame    photographs — the FRAME opens; the image itself never moves,
            so it is never seen mid-slide out of position
   cells    grids — the container holds still and its cells arrive in turn  */

/* --- secondary CTA -------------------------------------------------
   A standalone buyer action under a block. It is NOT its own button family
   any more: shape, type, border and the gold hover all come from the button
   system block near the header, identical to .btn-ghost.

   What was removed here on 2026-07-28, and why — this rule was the single
   biggest source of the "too many styles" the client saw. It carried a 72%
   white backing, a 30px drop shadow, a 1px hover lift and a gold arrow that
   no other button had, then restated all four differently for .on-dark. A
   quiet secondary action does not need its own surface treatment; it needs
   the same pill, one step down in emphasis, which the outlined state already
   is. Only spacing survives. */
.link-quiet{ max-width:100%; margin-top:var(--g-group); }
/* Inherits the button colour of whatever ground it is on, so one rule serves
   paper and navy sections alike — the .on-dark override it used to need is
   gone with it. */
.link-quiet .arrow{ color:currentColor; transition:transform .45s var(--ease); }
.link-quiet:hover .arrow{ transform:translateX(5px); }

/* --- placeholder convention -----------------------------------------
   THERE ARE NO PLACEHOLDERS LEFT ON THIS PAGE. The last one, the Chairman's
   message, was replaced with a real biography in the v2 consolidation, and
   .chair-msg .is-demo went with it.

   .demo-note styles nothing and is kept only as the convention the next
   unsourced block should use: an unmarked placeholder is how a demo turns
   into a shipped claim, so anything without a real source announces itself
   in mono and in square brackets until it is replaced. If an asset is not
   ready, leave the styled frame EMPTY — never print the word placeholder. */
.demo-note{
  margin:clamp(26px,3vw,40px) 0 0; color:var(--ink-soft);
  padding-left:14px; border-left:1px solid var(--accent);
}
.close-lede{ margin:var(--g-pair) auto 0; max-width:var(--measure-body); color:var(--ink-soft); }
.chair-msg{
  margin:0;
}
.chair-msg .tag{ color:var(--navy); }
.chair-msg p{
  margin:6px 0 0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); max-width:var(--measure-narrow); text-wrap:pretty;
}
/* The company's own line, off the logo. It gets no attribution and no
   speaker: it is a brand line, not something anyone said.

   IT IS .pull NOW — the whole treatment lives in that rule and this one
   carries nothing but where it sits in the intro stack. The two were the
   same device, a quoted line ruled off in gold, drawn twice: 22px sans/600
   navy on a 18px-gold rule here, 25px serif/400 ink on a 22px one there. One
   device with two voices is the thing we are removing everywhere else, so it
   is not defensible here either.

   The earlier note in this place argued the opposite — that serif here would
   widen the no-serif exception by stealth. That reasoning does not survive
   reading the decision it cites: TRACKER.md scopes the exception to
   `.h-display`, `.h-sec`, `.statement` AND quote. This IS the quote case, and
   folding it into .pull takes the serif selector count DOWN, not up. */
.brand-line{ margin:var(--g-group) 0 0; }

/* --- 02 scale -------------------------------------------------------
   Paper, and continuous with .m-intro above it: no rule, no colour change,
   nothing marking where the founder story ends and the proof begins. That is
   a client decision (2026-07-28) reversing the navy band this used to be —
   the two sections are one argument, and giving the numbers their own colour
   made them read as a receipt stapled to the story rather than as its
   evidence. The page's dark beat moved down to .m-capabilities.

   The run-up gradient that used to live on .m-intro now lives here, because
   this is the section that hands off to navy — see the .m-products rule. */
/* The scale heading was its own .statement class, a tier lighter than the
   other section headings — client decision (2026-07-28) to bring it in line
   with .h-sec instead, same size and colour treatment as .intro-title, so the
   page's headings read as one consistent voice rather than a smaller receipt
   line. See #scale-heading in the markup. */
/* .stats-note is gone: 20% growth was exiled to a footnote only because the
   grid was a 4-up. It is a row like the other five now, with its own note
   saying in words that it is a target. */
/* No .m-scale .stat overrides left. The rows were carrying a full second
   palette for navy — opaque cells, light hairlines, reversed labels — and all
   of it is deleted rather than commented out, because .stat now has exactly
   one ground and a stale dark override is how a section ends up half-dressed
   the next time someone recolours it. */

/* --- 04 the making -------------------------------------------------
   Five stages on a single horizontal track, because the section's whole
   argument is that the stages are CONTIGUOUS — one roof, no hand-offs.
   A wrapping grid would break the chain into rows and quietly say the
   opposite. Below 1080px it becomes a scroll-snap rail rather than a
   stack, which keeps the left-to-right reading of a production line. */
/* The five stages, connected — and this is where the deleted fixed spine's job
   actually belonged. That rail named Cutting/Stitching/Lasting from the far
   margin while these five cards named them here, so the page said the same
   thing twice and the copy that mattered was the one nobody was pointing at.
   The sequence is tracked here now, against the stages themselves.

   The track's top edge is one continuous rule rather than five hover-only
   segments. It used to draw the deleted "One roof. Every stage." heading; it
   now draws the surviving claim in .m-capabilities' heading and lede — one
   continuous operation, nothing handed off. It fills with the accent as
   the track crosses the screen and each stage's numeral comes up as the fill
   reaches it, so the run of the process is legible while standing still. */
/* --- the band and the pin ------------------------------------------
   The stages straddle the join between a navy band and the paper below
   it, so the run belongs to NEITHER surface — it reads as one object
   laid across the section rather than as content sitting inside one.
   That is the whole reason the band is a partial height and not a dark
   section: a dark section would swallow the cards.

   Three elements, three jobs, and the split is load-bearing:
     .making         the tall scroll well. Its height is one screen plus
                     the track's own overflow, so a pixel of page scroll
                     buys exactly a pixel of sideways travel.
     .making__pin    the one screen that holds still while that happens.
     .making__ground the paper half. It lives INSIDE the pin. Put in the
                     well instead it would scroll out from under the
                     cards the moment the pin caught, which is the one
                     failure that makes a pinned section look broken.

   The PAPER is the inserted block, not the colour. This section runs
   navy end to end, so the second surface has to be cut out of it rather
   than laid on top of it. The join reads the same either way and the
   cards straddle it either way, but it decides who has to keep going
   afterwards: paper that stopped at the pin's foot would put a navy
   strip back under the outbound link. .making-foot carries it down.

   .is-pinned is written by script and never by this sheet. Absent it —
   no JS, reduced motion, or a viewport too narrow to be worth a screen
   of pinning — every rule below stays inert and the track is the same
   swipe rail it already is on a phone. The fallback is the baseline, not
   a second set of rules to keep in step. */
/* z-index 3 clears .sec-rail's 2, and it is the paper that needs it. The
   rail is drawn in .on-dark's light ink for a section that is navy from
   its heading down — right for the two thirds of it above the join, and
   invisible below, where a light hairline and a light tag sit on paper.
   Rather than give the rail a second ink it cannot switch between at a
   boundary that moves under it, the paper is simply raised over the top:
   the rail runs down the colour and stops where the colour stops. The
   navy stretch of .making is transparent, so the rail still shows
   through everything above the join. */
.making,
.making-foot{ position:relative; z-index:3; }
/* Was clamp(34px,4.4vw,64px). The stages are the EVIDENCE for the three rows
   above them, not the next subject, so the gap wants to read as a group break
   inside one section — and this margin is only one of four stacked sources of
   air at that seam (the pin's own top pad and the slack from centring a 781px
   composition in a 100svh pin are the other two that survive). At the old
   value the four totalled 217px on a 1000px viewport, which is more than the
   page puts between whole sections at --g-block. */
.making{ margin-top:clamp(28px,3.2vw,54px); }
.making.is-pinned{ height:calc(100svh + var(--travel,0px)); }

.making__pin{
  position:relative; display:flex; flex-direction:column;
  /* --stage-lift raises the frames inside the pin, and it does it by padding
     the pin's FOOT rather than by pulling on the track. Under centring the
     free space is split, so two pixels of extra bottom padding is one pixel
     of lift and one pixel off the gap above the frames — which is the gap
     this exists to close. Doubling it here is what makes the lift read as its
     own number rather than as half of one. Zero by default; the only place it
     is set is the min-height guard below. */
  --stage-lift:0px;
  padding-block:clamp(34px,5.4vh,66px) calc(clamp(24px,3.6vh,46px) + var(--stage-lift) * 2);
}
.making.is-pinned .making__pin{
  position:sticky; top:0; height:100svh; overflow:hidden; justify-content:center;
}

/* WHO GETS THE SLACK. A 100svh pin holding a ~750px composition has a couple
   of hundred pixels of free space in it, and centring hands half of that to
   the gap ABOVE the head — which is the same gap the capability rows just
   closed, so the two stack and the seam opens to 166px on a 1000px viewport.

   Two auto margins split the free space between them instead: the head goes
   to the pin's top edge, the rest of the slack sits between the head and the
   frames. The cards do not move — measured at 1680×1000 the track lands on
   the same pixel it did under centring — so the navy/paper join still crosses
   them where --band-h was composed to put it. Only the head rises.

   Above 900px of viewport height and no lower, because that is where the
   slack being redistributed actually exists: at 900 the pin has ~60px of it,
   and below ~840 the composition is TALLER than the pin. An auto margin
   resolves to 0 there, and a flex box with any auto margin ignores
   justify-content — so applying this unconditionally would swap the
   symmetrical overflow that centring gives for a one-sided one, and drop the
   whole ~90px of it onto the bottom of the frames, where the stage captions
   are. Common laptop heights sit right in that band. */
@media (min-height:900px){
  .making.is-pinned .making__head,
  .making.is-pinned .making-track{ margin-bottom:auto; }
  /* Closes the gap between the lede and the frames, which is where the slack
     lands once the head is off the top of it. Held here rather than on
     .making__pin because it inherits the guard's reason as well as its
     bound: below 900px the composition already overflows the pin, and
     padding its foot to lift the frames would only push the stage captions
     further out of the bottom of a screen they are already leaving. */
  .making.is-pinned .making__pin{ --stage-lift:40px; }
}

/* --band-h is where the ground changes, and the join lands about a fifth
   of the way down the frames. Authored as one number rather than derived
   from the cards, because it is a composition call: any shallower and
   the cards look dropped onto the colour, any deeper and the paper stops
   reading as a second surface and starts reading as the cards' own
   backing. */
/* The lift is subtracted because --band-h answers "where does the ground
   change" with a share of the PIN, while the number the composition actually
   cares about is where the join crosses the FRAMES. Those are the same
   question only while the frames sit still. --stage-lift moves them, so it
   moves the join by the same pixel count and the fifth-of-the-way-down reading
   survives the change — measured at 1680×1000 the join lands 25.4% into the
   frames both with the lift and without it. Zero on every unpinned layout, where this
   reads as the plain percentage it always was. */
.making__ground{
  position:absolute; inset:calc(var(--band-h,41%) - var(--stage-lift,0px)) 0 0; z-index:0;
  background:var(--field);
}
.making__head,
.making-track,
.making .swipe-bar{ position:relative; z-index:1; }
.making__head{
  display:flex; flex-wrap:wrap; align-items:flex-end; justify-content:space-between;
  /* row-gap, not a margin on the items. A gap only applies BETWEEN lines, so
     it separates the lead and the link exactly when they wrap and costs
     nothing when they share a line — which is what the two equal margin-tops
     they used to carry could never do. */
  column-gap:clamp(24px,4vw,72px); row-gap:var(--g-group);
}
.making__head .making-lead{ max-width:58ch; }
/* No colour override here, and that is the fix. This button used to carry
   `color:var(--ink)` as a ground correction, written when the head sat on the
   paper half of the navy/paper join. The join has since moved down: the head
   is now on navy at EVERY scroll position through the pin — measured, the
   link's centre stays 160px above the paper's top edge the whole way — so the
   correction was painting ink on navy at about 1.2:1 and taking the button's
   ring with it, since --btn-line is mixed from currentColor.

   Inheriting .on-dark's paper is not a fallback, it is the design: .link-quiet
   is built on currentColor precisely so one declaration serves both grounds.
   .making__head was one of the three context overrides the button
   consolidation set out to delete; this was the last piece of it.

   If the join ever moves back up under this head, fix the GROUND, not the
   colour — a per-section ink override is what put us here. */
.making__head .link-quiet{ flex:0 0 auto; }

/* Back to ink. The section is .on-dark and that is right for its heading
   and its three capability rows, but the stage captions now sit on the
   paper half, where .on-dark's paper ink is invisible text. The flip is
   scoped to the captions rather than lifted off the section, because
   every other line in here is still on navy. h3 is the one that has to
   be named: it carries no colour of its own and inherits straight from
   the section. */
.making-step h3{ color:var(--ink); }

/* The paper has to outlive the pin. It starts partway down a screen that
   holds still, so its edge is fixed while the cards travel; then the pin
   lets go and this picks the surface up at exactly the pin's foot and
   carries it to the section's own bottom edge. The negative margin is
   what reaches past .sec's padding — without it the last band of paper
   would stop short and leave a navy sliver above .m-products. */
.making-foot{
  position:relative; background:var(--field);
  padding-block:0 var(--sec-pad);
  /* The first -1 * --sec-pad is the mechanism (see note above) and must
     stay equal to the padding or the navy sliver returns. The extra clamp
     is a separate decision (client, 2026-08-06): the pin centres a 781px
     composition in 100svh, and the leftover slack below it plus two full
     section pads put ~360px of blank paper between the stages and the
     Products head. This reclaims up to 96px of that slack — safely inside
     the blank band, so the rising Products head never touches the track. */
  margin-bottom:calc(var(--sec-pad) * -1 - clamp(48px, 12vw, 172px));
}

/* The meter, relocated. It used to be drawn on .making-track's own top
   edge, which was right while the track held still. The track is now the
   thing that MOVES, and a rule glued to it would slide off the screen
   while claiming to measure the slide. It sits on the pin instead —
   static, reading the same --track-p the transform reads. */
.making__rule{
  display:none;
  flex:0 0 100%;
  position:relative; height:2px; margin-top:var(--g-group);
  background:var(--line-light);
}
.making__rule i{
  position:absolute; inset:0; background:var(--accent);
  transform:scaleX(var(--track-p,0)); transform-origin:left;
}

/* The stages. At rest this is a row of fixed-width columns wider than
   the screen: the overflow IS the travel, so nothing here has to know
   how many stages there are — add a sixth and the well grows itself.

   The left pad tracks .container's inner edge rather than the viewport's
   so the first frame starts on the same margin as the heading above it,
   including past 1680px where .container stops growing and centres. --vw
   is the well's clientWidth, written by script; the 100vw fallback is a
   half-scrollbar out and only above 1680px, which is invisible. */
.making-track{
  display:grid; grid-auto-flow:column;
  grid-auto-columns:var(--card-w,clamp(250px,24vw,400px));
  gap:clamp(14px,1.7vw,28px);
  margin-top:var(--g-group);
  padding-inline:calc(max(0px, (var(--vw,100vw) - 1680px) / 2) + var(--pad-l)) var(--pad);
  padding-bottom:18px;
  overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x mandatory;
  scroll-padding-left:var(--pad-l);
}
.making-step{ position:relative; scroll-snap-align:start; }
.making.is-pinned .making-track{
  overflow:visible; scroll-snap-type:none; padding-bottom:0;
  transform:translate3d(calc(var(--travel,0px) * var(--track-p,0) * -1), 0, 0);
  will-change:transform;
}
/* The bar hides itself when a scroller has no overflow, which was expected
   to cover this for free. It does not: Chrome keeps reporting the visible
   overflow of a grid it is no longer scrolling, so the bar draws a thumb
   for travel the reader cannot drag. Stated outright instead — a pinned
   track's readout is the meter in the band. */
.making.is-pinned .swipe-bar{ display:none; }
.making-step .slot{ aspect-ratio:3/4; }
.making-step .idx{ display:block; margin-top:16px; }
.making-step h3{
  margin:6px 0 0; font-size:var(--text-title); font-weight:500;
  letter-spacing:-.025em; line-height:var(--lh-title);
}
.making-step p{ margin:6px 0 0; color:var(--ink-soft); font-size:var(--text-small); line-height:var(--lh-tight); text-wrap:pretty; }

/* Label always, description on hover — five permanent sub-captions under five
   frames read as noise rather than as a sequence.

   Gated on (hover:hover) and (pointer:fine), which together mean a mouse is
   actually present. Every device without one — phones, tablets, anything
   touch-primary — falls through and keeps the captions on permanently, so the
   copy is never stranded behind an interaction the hardware cannot perform.

   opacity, not display or visibility: the text stays in the accessibility
   tree and in find-in-page at all times, and its box is reserved whether or
   not it is showing, so nothing reflows on hover. */
@media (hover:hover) and (pointer:fine){
  .making-step p{
    opacity:0; transform:translateY(-4px);
    transition:opacity .45s var(--ease), transform .45s var(--ease);
  }
  .making-step:hover p{ opacity:1; transform:none; }
}
/* The per-stage beads are gone with the hairline they were seated in. They
   were the rule's punctuation: one hole in the line per stage, lighting up
   as the fill reached it. That reading only worked while the row held still
   and the meter sat directly above the stage it was naming. Once the row
   travels sideways under a static rule, the bead and its stage are no longer
   the same distance apart from one frame to the next, so a bead is pointing
   at whichever card happens to be beneath it. The meter keeps the job; the
   stages keep their numerals at full strength, as they already did on the
   phone rail. */

/* --- swipe indicator ------------------------------------------------
   The bar under a sideways rail. It pairs with any [data-swipe] scroller
   and is a real element rather than a styled ::-webkit-scrollbar, because
   the rails on this page only exist below 1080px and iOS Safari draws
   NOTHING for a touch scroller — a native bar would appear on desktop
   Chrome, where there is no rail, and nowhere a finger can reach one.
   Its metrics are read off the scroller in JS instead.

   The native bar is suppressed on the scroller itself so the two can
   never draw at once on the platforms that do render one. */
[data-swipe]{ scrollbar-width:none; -ms-overflow-style:none; }
[data-swipe]::-webkit-scrollbar{ width:0; height:0; }

.swipe-bar{
  display:none; position:relative; height:3px; background:var(--line);
  margin-top:clamp(4px,1vw,10px); touch-action:none; cursor:grab;
}
/* Drawn only once the script has measured a real overflow. At >=1080px
   .making-track is a five-column grid with nothing to scroll, and a full
   track under a full thumb reads as a stuck progress bar, not as nothing. */
.swipe-bar.is-on{ display:block; }
.swipe-bar.is-grabbed{ cursor:grabbing; }
/* The 3px line is the drawing, not the target: this lifts the hit area to
   ~22px for a finger without moving a pixel of the layout. */
.swipe-bar::after{ content:""; position:absolute; inset:-10px 0; }
.swipe-bar__thumb{
  position:absolute; inset:0; background:var(--navy-ink);
  transform-origin:left center; transform:scaleX(0);
  transition:transform .16s linear;
  will-change:transform;
}
.swipe-bar.is-grabbed .swipe-bar__thumb{ transition:none; }

/* The vertical variant, for a scroller that IS the viewport rather than a
   strip inside one — the overlay menu. It cannot sit after its scroller the
   way the rails' bars do, because there is no "after" inside a fixed panel,
   so it is a fixed child of the scroller and the panel's own clip-path takes
   it away with the rest of the menu when that closes. Right gutter rather
   than the screen edge, on the same --pad the contact card is set to, and
   starting below the header bar — which now sits ABOVE this panel and holds
   the close control, so the top offset is what keeps the two off each other.
   (It used to clear .btn-close, which no longer exists.) */
.swipe-bar--y{
  position:fixed; width:3px; height:auto; margin:0;
  right:clamp(8px,2vw,24px); top:clamp(70px,10vh,110px); bottom:clamp(18px,3vh,36px);
  background:var(--line-light);
}
.swipe-bar--y::after{ inset:0 -10px; }
.swipe-bar--y .swipe-bar__thumb{
  background:var(--paper); transform-origin:center top; transform:scaleY(0);
}

/* .mile-list is gone with the Milestones section (v2 consolidation). If a
   News page is ever built, the rules are recoverable from git history and the
   copy is parked in 00_brief/news-entries_parked.md — but they belong in that
   page's stylesheet, not this one. */

/* --- 10 partnership routes -----------------------------------------
   The three buyer routes are the commercial heart of the page, so the
   restraint here is doing the most work on the site: hairlines, no fills,
   no cards. They read as an index of ways in rather than a row of competing
   offers, which is what keeps a manufacturer site off the storefront spine
   the IA spec rules out.

   THREE COLUMNS, not three rows, since 2026-07-28 — the user asked for it
   directly while shortening #contact, and it takes the block from ~245px to
   ~130px. The earlier note here argued for rows on the grounds that anything
   side-by-side becomes "three cards competing"; that turned out to be about
   the FILL, not the axis. With no background, no radius and no shadow, three
   columns divided by the same hairline still read as an index. What actually
   keeps them off the storefront spine is that nothing here is a button.

   The hairlines are drawn as border-block on the <ul> plus a divider on
   `li + li`, NOT as a border-bottom on every li. The old way doubles up at
   the foot of the mobile stack, where the ul's own bottom edge and the last
   li's border land on the same pixel. This way one formula serves both axes
   and the ≤820 block only swaps border-left for border-top. */
.route-list{
  list-style:none; margin:0; padding:0;
  display:grid; grid-template-columns:repeat(3,1fr);
  border-block:1px solid var(--line); text-align:left;
  max-width:min(1040px,100%);
}
.route-list li + li{ border-left:1px solid var(--line); }
.route-list a{
  position:relative; display:flex; flex-direction:column; align-items:flex-start;
  gap:9px; height:100%;
  padding:clamp(20px,2.2vw,30px) clamp(16px,2vw,28px);
  /* ONE hover shift for the whole cell. It lives here as a custom property so
     the title, the line under it and the arrow can never drift apart again:
     they all read the same number. */
  --route-shift:clamp(5px,1.2vw,16px);
}
/* Same hairline-sweep gesture as .cap-row::before and .cert::before: a 2px
   rule pinned to the shared bottom edge (the list's own border-block), scaling
   in on hover/focus rather than filling the cell — kept per-column here since
   these three sit side by side instead of stacked full-width. */
.route-list a::before{
  content:""; position:absolute; left:0; bottom:-1px; height:2px; width:100%; background:var(--navy);
  transform:scaleX(0); transform-origin:left bottom; transition:transform .7s var(--ease); z-index:0;
}
.route-list a:hover::before,
.route-list a:focus-visible::before{ transform:scaleX(1) scaleY(2); }
.route-list a > *{ position:relative; z-index:1; transition:transform .5s var(--ease), color .5s var(--ease), font-weight .5s var(--ease); }
/* --navy, matching this cell's sweep — see .cap-row:hover .h-sub. The colour
   fade is declared on `> *` with the shift for the same reason it is there:
   this rule beats `.route-list b` on specificity, so a transition written on
   the title alone would be dropped and the tint would snap. */
.route-list a:hover b,
.route-list a:focus-visible b{ color:var(--navy); font-weight:var(--w-hover); }
/* Every child, arrow included. The arrow used to be excluded here and given its
   own flat 6px lead, which meant that at wide viewports the title travelled 16px
   and the arrow 6px — the left edge of the cell visibly came apart mid-hover.
   The whole cell moves as one block now; if the arrow ever wants a lead again it
   has to be written as var(--route-shift) plus the lead, never as a bare px. */
.route-list a:hover > *,
.route-list a:focus-visible > *{ transform:translateX(var(--route-shift)); }
.route-list b{ font-weight:500; font-size:var(--text-title); line-height:var(--lh-title); letter-spacing:-.025em; }
.route-list p{ margin:0; color:var(--ink-soft); font-size:var(--text-small); line-height:var(--lh-tight); text-wrap:pretty; }
.route-list .arrow{ color:var(--accent); }

[data-aos]{ transition:opacity .9s var(--ease), transform .9s var(--ease), clip-path .9s var(--ease); }
[data-aos].is-in{ opacity:1; transform:none; }

[data-aos="fade-up"]{ opacity:0; transform:translateY(26px); }
[data-aos="slide"]  { opacity:0; transform:translateX(-30px); }

/* The end state is a NEGATIVE inset, not inset(0). At exactly zero the clip
   rectangle sits on the border box and shaves the descenders off g, y and ạ —
   the Vietnamese diacritics below the baseline are the first to go. */
[data-aos="mask"]{ opacity:0; transform:translateY(16px); clip-path:inset(0 0 100% 0); }
[data-aos="frame"]{ opacity:0; clip-path:inset(0 0 100% 0); }
[data-aos="mask"].is-in,
[data-aos="frame"].is-in{ clip-path:inset(-.35em -.35em -.35em -.35em); }

[data-aos="cells"] > *{
  opacity:0; transform:translateY(22px);
  transition:opacity .8s var(--ease), transform .8s var(--ease);
}
[data-aos="cells"].is-in > *{ opacity:1; transform:none; }
[data-aos="cells"].is-in > :nth-child(2){ transition-delay:.08s }
[data-aos="cells"].is-in > :nth-child(3){ transition-delay:.16s }
[data-aos="cells"].is-in > :nth-child(4){ transition-delay:.24s }
[data-aos="cells"].is-in > :nth-child(5){ transition-delay:.32s }
/* Six to eight, added 2026-07-31 for the sub-pages. The homepage never had a
   run longer than five; oem-odm's workflow rail is seven and 404's index is
   six, and without these the tail of a run all landed at once while the head
   was still staggering — which reads as the animation giving up rather than as
   a rhythm. The ladder stops at eight on purpose: past ~.56s the last cell is
   arriving long after the reader has got there. */
[data-aos="cells"].is-in > :nth-child(6){ transition-delay:.40s }
[data-aos="cells"].is-in > :nth-child(7){ transition-delay:.48s }
[data-aos="cells"].is-in > :nth-child(8){ transition-delay:.56s }

/* Rows and product tiles are siblings rather than children of one revealed
   container, so their stagger is indexed off the parent instead. */
.cap-list  > [data-aos]:nth-child(2){ transition-delay:.09s }
.cap-list  > [data-aos]:nth-child(3){ transition-delay:.18s }
.cap-list  > [data-aos]:nth-child(4){ transition-delay:.27s }
.prod-grid > [data-aos]:nth-child(2){ transition-delay:.09s }
.prod-grid > [data-aos]:nth-child(3){ transition-delay:.18s }
.prod-grid > [data-aos]:nth-child(4){ transition-delay:.27s }

/* --- responsive --------------------------------------------------- */

/* The rails' lane. Deliberately 1280 and not the rails' own 1024 breakpoint:
   between the two the hairline and its fill still draw — an accent line tracking
   the section reads fine on its own — but the TAG is suppressed, because at
   1024 the page has only 51px of gutter and the rail alone already stands at
   31px of it.

   The result is an asymmetric page, left margin roughly twice the right. That
   is the point rather than a side effect: a rail floating in a symmetric margin
   looks like something that landed on the page, whereas a margin visibly wider
   on the side the rail lives on reads as a page that was set around it. Every
   left-hand edge moves together — .container, the header lockup, the banner
   headline, the band body — so there is one left edge on the page, not two.

   The gutter is wider than the rail strictly needs now that the tag is set
   vertically and costs ~24px instead of the old horizontal label's 136. It
   stays at 156 anyway: the asymmetry above is the reason for the number, and
   the tag having room to spare is what keeps it from reading as a squeeze. */
@media (min-width:1280px){
  /* --rail-x is widened here and only here. At 40px the rail stood hard against
     the browser edge with ~136px of blank paper between it and the first line
     of content, and at that distance a label reads as page furniture rather
     than as part of the block it names — the client's word for it was that it
     sat "ngoài lề". Moving it out to ~104px leaves 72px, which is close enough
     that the marker and the heading are taken in as one group.

     It cannot be widened globally: below 1280 --pad-l falls back to
     clamp(20px,5vw,80px), so a rail at 6.5vw would cross to the RIGHT of the
     content's own left edge somewhere around 1100px and stand in the text. */
  :root{
    --pad-l:clamp(156px, 11vw, 176px);
    --rail-x:clamp(56px, 6.5vw, 104px);
  }
}
/* Centring the row, which is the other half of closing the empty lower half.
   The block used to sit hard against its top padding, so every pixel the
   rows did not use pooled in one place at the bottom. Centred, the same slack
   is split above and below and reads as air around the menu rather than as a
   sheet that ran out of content.

   Both guards are load-bearing, and neither is a tidiness breakpoint:

   - min-width:1180 once matched the point where the (now removed) sub-list
     opened sideways; it survives as the width below which the two type
     columns plus the contact card get tight enough that centring buys
     nothing and the sheet may already be scrolling.
   - min-height:760 keeps centring off short viewports. .overlay is the scroll
     container; content taller than the sheet and centred inside it puts the
     first row ABOVE the scroll origin, where no amount of scrolling reaches
     it. 760 is the height at which centring is still only distributing slack.
     Re-measured 2026-08-06 with the two-column nav, now FIVE rows per
     column: what keeps the column inside the sheet is the type clamp's own
     vw and vh terms plus the row count, and at five rows the full 62px cap
     leaves ~140px of slack at 1680x762. If the ceiling is ever raised, or a
     sixth row added to either column, re-measure .overlay-inner.scrollHeight
     against .overlay.clientHeight at 1680x762 — that is the corner, and it
     is not the one anyone checks. */
@media (min-width:1280px) and (min-height:760px){
  .overlay-inner{ align-content:center; }
  /* The other half of the same move, under the same guards for the same
     reason: only a sheet with slack in it has anything to lift the rule off.
     The line is what the eye takes as the panel's floor, so raising it raises
     the floor and takes another slice out of the navy under "News" — while
     staying short of the distance at which it would read as a bar floating
     with nothing beneath it. Below these guards the panel is already scrolling
     and the extra padding would only add to what has to be scrolled past. */
  .overlay-foot{ padding-bottom:clamp(28px,5.4vh,60px); }
}

@media (max-width:1080px){
  /* .cap-explorer / .cap-panels rules removed with the panels themselves —
     the explorer is a single column at every width now. */
  .prod-grid{ grid-template-columns:repeat(2,1fr); }
  .fac-grid,.intro-grid{ grid-template-columns:1fr; }
  .fac-member{
    min-height:clamp(190px,38vw,260px);
    align-items:flex-end;
  }
  .fac-member::before{ width:min(72%,460px); right:-8%; }
  .intro-grid{ align-items:start; }
  .intro-copy{ max-width:none; }
  .intro-figure{ max-width:min(560px,100%); }
  .intro-proof{ grid-template-columns:var(--proof-cols); }
  /* The mark stops holding a track of its own and takes the whole row above
     the lists. Below this width five tracks leave each list about 120px, which
     wraps "Sustainability" and the email address onto two lines while the logo
     sits in a track twice as wide as the artwork it holds. The lists keep an
     even run — that evenness, not the count, is what the layout is. */
  .foot-top{ grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr); gap:clamp(28px,5vw,46px); }
  .foot-brand{ max-width:330px; }
  .foot-link-pair{ gap:clamp(22px,4vw,38px); }
  /* Contact drops beneath the section list rather than beside it, and the
     nav column gives up max-content so the rows can wrap instead of forcing
     the panel wider than the screen. */
  .overlay-inner{ grid-template-columns:minmax(0,1fr); justify-content:stretch; }
  /* The object lane is the first thing to go: five columns cannot hold a
     figure, a label, an object and a sentence on a tablet, and the sentence is
     the one carrying meaning. Counter, figure and label keep the top line;
     the note drops beneath them and runs the width of the two.

     Every placement is written out rather than left to auto-flow. The desktop
     rule pins all four children to grid-row 1, so the moment the note needs
     row 2 the whole row has to be re-placed explicitly — auto-flow would put
     the note under the COUNTER, in the 46px lane. */
  /* Four columns for three items: the last one is empty on purpose. Without
     it the label is flush to the right margin with a third of the row empty
     between it and its own figure, and a label that far from the number it
     names stops reading as its label. */
  .stat{
    /* --col-num is NOT re-declared here. It is derived from --fig, which this
       breakpoint does not change, so the base calc already gives the right
       width — a % restated here would only drift away from it. */
    --col-cap:22%;
    /* +3.3%, matching the base rule: --col-num loses 44px there and this is
       where it goes, so the note column stays put at this width too. */
    --col-object:17.3%;
    grid-template-columns:var(--col-n) var(--col-num) var(--col-cap) var(--col-object) minmax(0,1fr);
  }
  .st-n{ grid-column:1; grid-row:1; }
  .stat .num{ grid-column:2; grid-row:1; }
  /* .cap is re-declared only to reset the LEFT/RIGHT padding for this
     template; the top term stays the derived cap-height alignment, so it is
     restated verbatim rather than replaced with a value of its own. */
  .stat .cap{ grid-column:3; grid-row:1; padding:calc(var(--fig)*.144 - 5.12px) clamp(12px,1.6vw,28px) 0 0; }
  .st-note{ grid-column:5; grid-row:1; max-width:60ch; }
  /* The stages are a swipe rail at every width now, so this block only has to
     resize the frames and pull the track's left pad back off .container's
     centred edge, which does not apply below 1080 anyway. The pin is not
     switched off here: script never turns it on this narrow, because a screen
     held hostage to move a rail two card-widths is a worse deal than a rail
     the reader can flick. The band stays — it is the composition, not the
     motion, and it survives losing the pin intact.

     A shallower band, because the head above it is taller when the lede wraps
     to three or four lines on a narrow column: at 52% the join would land in
     the caption rather than across the frame. */
  /* The vw term is capped as well as floored. This block runs to 1080, and
     an uncapped 46vw hands a tablet a 470px frame — wider than the phone
     layout it was scaled for and wider than the desktop card it is standing
     in for. The floor keeps a photograph readable at 390. */
  .making{ --card-w:minmax(210px,min(46vw,380px)); --band-h:46%; }
  .making__head{ display:block; }
  .making__head .link-quiet{ margin-top:clamp(18px,3vw,28px); }
  .making-track{ padding-inline:var(--pad); scroll-padding-left:var(--pad); }
  .making .swipe-bar{ margin-inline:var(--pad); }
}
@media (max-width:820px){
  /* The bar keeps the lockup and the menu button, nothing else. Both the CTA
     and the social chips are one tap away inside the overlay — the contact
     card repeats all three — and a phone header carrying four controls has no
     width left for the lockup to stay legible. .btn-menu is NOT switched on
     here any more; it is on at every width now. */
  .header-actions .btn-cta,.header .social-row,.header-rule{ display:none; }
  .banner-inner{ grid-template-columns:1fr; }
  .banner-meta{ text-align:left; align-items:flex-start; }
  .intro-proof{ grid-template-columns:1fr; }
  /* One column, so the divider has nothing to divide and the padding that
     held copy off it would only indent the second and third items. The row
     rules alone carry the list here. */
  .intro-proof li,
  .intro-proof li:not(:last-child),
  .intro-proof li + li{ padding-left:0; padding-right:0; border-right:0; }
  /* The four lines become a rail here and not at 1080 with .making-track:
     five stages break out of a row the moment the screen narrows, but 2x2
     product tiles read perfectly well on a tablet. At phone width the same
     grid gives each shoe about 165px, which is where a product photograph
     stops being a product photograph — so the tiles go wide and sideways
     instead. Same bleed as the making rail, so both read as one device. */
  .prod-grid{
    grid-template-columns:none; grid-auto-flow:column; grid-auto-columns:minmax(220px,62vw);
    overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x mandatory;
    margin-inline:calc(var(--pad) * -1); padding:0 var(--pad) 18px;
    scroll-padding-left:var(--pad);   /* same bleed, same correction as .making-track */
  }
  .prod{ scroll-snap-align:start; }
  /* The stagger is a GRID gesture: it lets a four-across row breathe. On a
     single sideways row it turns into cards that stop at different heights,
     so every swipe lands somewhere new vertically. */
  .prod:nth-child(even){ margin-top:0; }
  /* The 2x2 unstacks into one column: at this width a half-cell leaves the
     verified copy about twenty characters wide, which is a column of hyphens,
     not a paragraph. The cell's own anatomy is unchanged — badge in its gutter,
     copy under the name — so only the pairing and its divider are undone. */
  .cert-list{ grid-template-columns:minmax(0,1fr); }
  .cert:nth-child(odd){ padding-right:0; border-right:0; }
  .cert:nth-child(even){ padding-left:0; }
  .cert{ gap:16px; }
  .cert-pillars{ grid-template-columns:1fr; }
  .cap-row{ grid-template-columns:44px 1fr; }
  .cap-row p,.cap-row .arrow{ grid-column:2; }
  .cap-row .arrow{ justify-self:start; }
  /* Counter in its own lane, the other three stacked beside it. The label
     leaves the figure's line here rather than sitting next to it: at this
     width a 22-character label like "Targeted annual growth" wraps to three
     lines against a figure that is one, and the row reads as broken. */
  /* --col-num, not a .64fr share of the row. A fraction sized the number lane
     against the SCREEN, and on a tablet that put the labels ~340px clear of
     figures only half that wide — the gap the client sent back. The derived
     column is the figure's own width plus its gutter, so the blue sits beside
     the number here exactly as it does on desktop. */
  .stat{ grid-template-columns:var(--col-n) var(--col-num) minmax(0,1fr); row-gap:8px; }
  .stat .num{ grid-column:2; grid-row:1; padding-right:0; }
  /* NO --fig override on .stat--pairs (client, 2026-07-28): 3,000,000 is set
     at the shared figure size here, same as the other three. It used to run
     9vw/64px so it would not shrink beside them, which was defensible while
     the labels dodged it — now that the column is measured to the figure, a
     row with a bigger figure is just a row whose label starts further right. */
  .stat .cap{ grid-column:3; grid-row:1; padding:calc(var(--fig)*.144 - 5.12px) 0 0 clamp(12px,3vw,18px); }
  .st-note{ grid-column:2 / -1; grid-row:2; padding-top:6px; }
  .sec-head{ grid-template-columns:1fr; gap:14px; }
  .sec-head .mono{ padding-top:0; }
  .head-copy{ grid-template-columns:1fr; gap:clamp(18px,4vw,26px); }
  .head-copy .btn-ghost,
  .head-copy .link-quiet{ justify-self:start; margin-bottom:0; }
  .sec-head .h-sec{
    max-width:100%;
    font-size:clamp(32px,10.4vw,46px);
    line-height:1.08;
  }
  .sec-head .lede{ max-width:34ch; }
  footer{ min-height:0; padding-block:46px 24px; }
  .foot-top{ grid-template-columns:1fr; gap:30px; }
  .foot-link-pair{ grid-template-columns:1fr 1fr; }
  .foot-logo{ width:46px; }
  .foot-name{ font-size:20px; }
  .foot-intro{ max-width:32ch; margin-top:22px; }
  .contact-grid{ grid-template-columns:1fr; }
  .route-list{ grid-template-columns:1fr; }
  .route-list li + li{ border-left:0; border-top:1px solid var(--line); }
  .fac-reach{ margin-top:clamp(28px,8vw,44px); }
  .reach-map{ min-height:clamp(310px,78vw,440px); }
  .reach-map::before{ left:2px; width:calc(100% - 4px); }
  .reach-map__routes,
  .markets{
    top:50%; left:2px; width:calc(100% - 4px); height:auto;
    aspect-ratio:680 / 216; transform:translateY(-50%);
  }
  .reach-map__routes{
    width:calc(100% - 4px);
  }
  /* Pin sizes now live in the base rules (desktop shares the 34/42px scale
     since 2026-07-28) — this block keeps only what mobile genuinely changes:
     tighter gap, smaller dot/ring/shadow, and the extra reversed labels. */
  .markets li{ --pin-ring:0 0 0 2px rgba(247,245,242,.90); gap:8px; }
  .markets li::before{
    filter:drop-shadow(0 1px 0 rgba(247,245,242,.90)) drop-shadow(0 7px 12px rgba(1,34,61,.20));
  }
  .markets li::after{ width:11px; height:11px; box-shadow:0 0 0 2px rgba(247,245,242,.90); }
  .markets li[data-origin]::after{ width:13px; height:13px; }
  .markets b{ padding:4px 6px; font-size:10.5px; }
  .markets li:nth-child(2){
    grid-template-columns:max-content var(--pin-w);
    transform:translate(calc(-100% + var(--pin-w) * .5), calc(var(--pin-anchor-y) * -1 + 2px)) scale(.96);
    transform-origin:calc(100% - var(--pin-w) * .5) var(--pin-anchor-y);
  }
  .markets li:nth-child(2)::before{ grid-column:2; grid-row:1 / span 2; }
  .markets li:nth-child(2)::after{ left:auto; right:calc(var(--pin-w) * .5); transform:translate(50%,-50%); }
  .markets li:nth-child(3){
    grid-template-columns:max-content var(--pin-w);
    transform:translate(calc(-100% + var(--pin-w) * .5), calc(var(--pin-anchor-y) * -1 + 2px)) scale(.96);
    transform-origin:calc(100% - var(--pin-w) * .5) var(--pin-anchor-y);
  }
  .markets li:nth-child(3)::before{ grid-column:2; grid-row:1 / span 2; }
  .markets li:nth-child(3)::after{ left:auto; right:calc(var(--pin-w) * .5); transform:translate(50%,-50%); }
  /* The origin and nth-child(4)/(6) reversals moved to the base rules when the
     desktop layout adopted them — only 2 (Europe) and 3 (Japan) reverse here. */
  .markets li:nth-child(2) b,
  .markets li:nth-child(3) b{ grid-column:1; text-align:right; }
  .reach-map.is-in .markets li:nth-child(2),
  .reach-map.is-in .markets li:nth-child(3){ transform:translate(calc(-100% + var(--pin-w) * .5), calc(var(--pin-anchor-y) * -1)) scale(1); }
  .markets li:nth-child(2) b{ transform:translate(-4px,-10px); }
  .markets li:nth-child(3) b{ transform:translate(-2px,-16px); }
  .markets li:nth-child(4) b{ transform:translate(-4px,3px); }
  .markets li[data-origin] b{ transform:translate(-10px,23px); }
}

@media (max-width:430px){
  .stat{ grid-template-columns:var(--col-n) minmax(0,1fr); }
  .stat .num{ grid-column:2; grid-row:1; }
  /* No --fig override here either — one figure size at every width now, which
     is the client's rule and not an oversight. The old 13vw/58px made this the
     one row whose numeral outgrew the other three, and it was the last place
     that survived. 3,000,000 at the shared size still runs only about half the
     column, so nothing is gained by singling it out. */
  /* Label stacks UNDER its figure at this width, so it carries no derived
     alignment padding — there is no shared line left to align to. */
  .stat .cap{ grid-column:2; grid-row:2; padding:0; }
  .st-note{ grid-column:2; grid-row:3; }
}

@media (max-width:560px){
  footer{ padding-block:44px 22px; }
  .foot-top{ grid-template-columns:1fr; gap:28px; }
  .foot-top > :first-child{ margin-bottom:4px; }
  .foot-link-pair{ grid-template-columns:1fr 1fr; gap:22px; }
  .foot-logo{ width:42px; }
  .foot-name{ font-size:18px; }
  .foot-col--plain{ padding-top:0; }
  /* The menu's two type columns go back to one: on a 390px screen two
     abreast "Sustainability"-length rows at the 34px floor cannot fit.
     A single 14-row column overflows the sheet instead, which the overlay
     already handles — it is its own scroll container and the swipe bar
     draws when it does. Column B simply follows column A in flow. */
  .overlay-main nav{ grid-template-columns:minmax(0,1fr); gap:0; }
}

@media (min-width:821px) and (max-width:1279.98px){
  #sustainability .band__body{
    padding-left:calc(var(--pad-l) + 120px + clamp(16px,3vw,56px)) !important;
    grid-template-columns:minmax(0,1fr) !important;
  }
  #sustainability .band__body > .idx{ display:none !important; }
  #sustainability .band__body .h-sec,
  #sustainability .band__body p{ grid-column:1 !important; }
}

@media (min-width:1280px){
  .rails-on #sustainability .band__body{
    padding-left:var(--pad-l) !important;
    grid-template-columns:minmax(0,1fr) !important;
  }
  .rails-on #sustainability .band__body > .idx{ display:none !important; }
  .rails-on #sustainability .band__body .h-sec,
  .rails-on #sustainability .band__body p{ grid-column:1 !important; }
}

@media (prefers-reduced-motion:reduce){
  *{ animation-duration:.01ms !important; transition-duration:.01ms !important; }
  .reveal-line > span{ transform:none; }
  .intro-curtain{ display:none !important; }
  [data-aos],[data-aos="cells"] > *{ opacity:1 !important; transform:none !important; clip-path:none !important; }
  /* The blanket rule above only shortens the push-in to 0.01ms, which turns a
     900ms glide into an instant 7% jump under the pointer — louder than the
     animation it was meant to spare. It is dropped outright, so the card holds
     still and the link is marked by its own focus ring instead. (The frame's
     lift used to be cancelled here too; it no longer exists to cancel.) */
  .prod:hover .slot > picture,
  .prod:focus-visible .slot > picture{ transform:none; }
  .reach-map::before{ transform:translateY(-50%); transition:opacity .2s var(--ease); }
  .reach-map__routes path,
  .reach-map.is-in .reach-map__routes path,
  .reach-map.is-in .reach-map__origin-line{ animation:none; opacity:1; stroke-dashoffset:0; }
  .markets li,
  .reach-map.is-in .markets li{ opacity:1; transition:none; }
  .reach-map.is-in .markets li[data-origin]::after{ animation:none; }

}

/* ============================================================
   SUB-PAGE LAYER
   Everything above this line came out of the homepage. Everything below is
   what the 18 sub-pages need on top of it, and it arrived here on 2026-07-30
   when assets/css/common.css was retired — that sheet was a hand-kept copy of
   the tokens plus these few stub-only rules, and the copy had already drifted
   (--field-2 was #F3F5F8 above and #EBEEF2 there).

   Rules for adding to this section:
   · Only token values from :root. A new hex here is a bug.
   · Nothing lands here before a page emits the markup for it. This project has
     already deleted one component (.page-list) that outlived its markup, and a
     second (.nav) is being dropped in this very edit — see below.
   · Squared, ruled, hairline. border-radius:999px stays on buttons alone.
   ============================================================ */

/* .nav / .nav a / .nav a::after — a desktop nav bar with an underline sweep —
   stood in common.css and matched nothing. The header has carried its links in
   the overlay since the client chose that, and the 2026-07-30 decision was to
   keep it that way and only repoint the hrefs. Rules for a component nobody
   emits read as an unfinished feature to the next person; if a desktop bar is
   ever commissioned, .cap-row already has the sweep it wanted. */

/* --- page hero -----------------------------------------------------
   Every sub-page opens on this. It is the old .stub-hero, renamed: the holding
   page was the only consumer and the shape it had — navy band, two radials,
   a top pad that clears the fixed header — is the shape a real sub-page hero
   wants. The rename carried the .no-entrance guard below with it; splitting
   the two would render this band blank in a background tab, which is the exact
   bug that guard exists to prevent.

   Deliberately short of 100svh, as it was: a full navy screen reads as a
   landing page that failed to load its content. */
.page-hero{
  background:var(--navy-ink); color:var(--paper); position:relative; overflow:hidden;
  /* Not free space — the header is fixed at ~85px, so the first ~85px of this
     is clearance and only the remainder is breathing room. */
  padding-block:clamp(118px,15vh,164px) clamp(50px,7vw,86px);
}
/* The same wash .slot frames carry, so an image-less band still belongs to the
   same family of objects. The 112deg scanline that used to sit on top of it was
   removed at the client's request: a diagonal hatch across open space reads as
   a rendering artefact rather than texture. body::after still lays grain over
   everything, so the surface is not bare. */
.page-hero::before{
  content:""; position:absolute; inset:0; z-index:0; pointer-events:none; opacity:.5;
  background-image:
    radial-gradient(120% 90% at 12% 8%, rgba(1,76,134,.85) 0%, transparent 62%),
    radial-gradient(90% 80% at 88% 96%, rgba(176,131,9,.20) 0%, transparent 58%);
}
/* ::before sits at z-index:0 and would paint over the inline text of a static
   sibling, so the content earns a layer. */
.page-hero > .container{ position:relative; z-index:1; }
.page-hero .lede{ color:rgba(247,245,242,.72); margin-top:24px; }
/* Same two-column head as .sec-head, so a hero and a section start on the same
   left edge and the folio column lines up down the whole page. */
.page-head{
  display:grid; grid-template-columns:minmax(0,120px) minmax(0,1fr);
  gap:clamp(16px,3vw,56px); align-items:start;
}
.page-head > .mono{ padding-top:.7em; }
.page-actions{ display:flex; gap:14px; flex-wrap:wrap; margin-top:clamp(30px,4vw,44px); }
/* Collapses at exactly the width .sec-head does, and for the same reason: a
   120px label column out of 390 leaves the headline 230px to live in, and
   .h-display sets 36px there — "Cutting to final inspection." came out four
   lines deep on a ragged 12ch measure. Kept beside the rule it mirrors rather
   than filed with the other breakpoints, so the two cannot drift apart. */
@media (max-width:820px){
  .page-head{ grid-template-columns:1fr; gap:14px; }
  .page-head > .mono{ padding-top:0; }
  .page-hero .lede{ max-width:34ch; }
}

/* --- breadcrumb ----------------------------------------------------
   Inside the navy hero, not on a paper strip above it. The header's
   transparent state is drawn to sit over a dark band; slipping a light strip
   between the two would put paper-coloured nav text on paper for the first
   ~85px of every sub-page. */
.crumb{ margin-bottom:clamp(18px,2vw,26px); }
.crumb ol{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap; align-items:center; gap:0 10px; }
.crumb li{ display:flex; align-items:center; gap:10px; color:rgba(247,245,242,.5); }
/* The separator is generated, so the markup is a clean list and a screen
   reader is never handed a slash to read out. */
.crumb li + li::before{ content:"/"; color:rgba(247,245,242,.28); }
.crumb a{ color:rgba(247,245,242,.62); text-decoration:none; transition:color .3s var(--ease); }
.crumb a:hover{ color:var(--accent); }
.crumb [aria-current="page"]{ color:var(--paper); }

/* --- placeholder badge ---------------------------------------------
   Squared hairline, not a pill: nothing but a button has a rounded corner
   here. Reserved for genuinely unfinished states — a page that ships with one
   of these is making a promise. */
.flag{
  display:inline-block; margin-bottom:22px;
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.22em; text-transform:uppercase; color:var(--accent);
  border:1px solid rgba(176,131,9,.42); padding:6px 13px;
}

/* --- hero entrance -------------------------------------------------
   The homepage stages its hero with .reveal-line and everything below it with
   the IntersectionObserver. A sub-page hero is above the fold and never
   intersects anything, so the observer half would never fire — this is the
   CSS-only stagger, on the same easing and roughly the same 120ms rhythm. */
@keyframes rise{ from{ opacity:0; transform:translateY(14px) } to{ opacity:1; transform:none } }
.page-hero .mono{        animation:rise 700ms var(--ease) 180ms both; }
.page-hero .flag{        animation:rise 700ms var(--ease) 180ms both; }
.page-hero .h-display{   animation:rise 700ms var(--ease) 280ms both; }
.page-hero .lede{        animation:rise 700ms var(--ease) 400ms both; }
.page-hero .page-actions{animation:rise 700ms var(--ease) 520ms both; }
/* The crumb carries .mono for its type, so it matches the rule three lines up.
   This one is declared AFTER at equal specificity purely so the later
   declaration wins and the crumb leads the stagger instead of arriving with
   the eyebrow. Move it above and it silently inherits 180ms. */
.page-hero .crumb{       animation:rise 700ms var(--ease) 100ms both; }
/* `both` holds the from-state, and a background tab freezes the document
   timeline at currentTime 0 — so without this the hero is five invisible
   elements until the tab is focused. site.js sets .no-entrance when the page
   loads hidden; the stagger is skipped and the content is simply there. */
.no-entrance .page-hero .crumb,
.no-entrance .page-hero .mono,
.no-entrance .page-hero .flag,
.no-entrance .page-hero .h-display,
.no-entrance .page-hero .lede,
.no-entrance .page-hero .page-actions{
  animation:none !important; opacity:1 !important; transform:none !important;
}

/* THE HEADLINE WRITES ITSELF — on every page family, not just the homepage.
   The sub-page H1 used to arrive on the same block `rise` as the eyebrow and
   the lede above and below it: three elements performing one gesture, so the
   title read as a member of the stagger rather than as the thing the stagger
   was leading to. It now carries the homepage banner's .reveal-line mask, so
   the type rises out of its own baseline and the two banners are one
   instrument. The markup is the homepage's, verbatim — which also means the
   three guards already written for it (.no-entrance, reduced-motion, and the
   intro curtain's play-state hold) cover this without a line of new defence.

   `animation:none` on the H1 is load-bearing, not tidying. Leaving `rise` on
   the block while riseIn runs on the lines inside it animates the same text
   twice: the mask travels with the thing it is supposed to be masking, and
   what should read as type appearing reads as a slide.

   The delays continue the hero's existing ladder rather than starting a new
   one — the H1's slot was already 280ms, and .12s between lines is the
   homepage's rhythm. Retime these with .reveal-line's own nth-child delays. */
.page-hero .h-display{ animation:none; }
.page-hero .h-display .reveal-line:nth-child(1) > span{ animation-delay:.28s }
.page-hero .h-display .reveal-line:nth-child(2) > span{ animation-delay:.40s }

/* DESCENDERS, which the homepage got away with and these pages do not.
   .reveal-line is an overflow:hidden box the height of its line box, and at
   the display tier's tight leading Fraunces hangs p, y and g — and every
   Vietnamese mark that sits below the baseline, the ậ in "Nhật Minh" — below
   it. "Made whole, under one roof." happens to contain no descender at all,
   which is the only reason this never surfaced. "Capabilities" does.

   The padding goes on the INNER span, never on the mask. The hidden state is
   translateY(105%) of the SPAN's own height, so growing the span carries the
   rest position clear of the box's bottom edge and keeps it hidden. Growing
   the mask instead leaves the box taller than the travel, and a sliver of the
   glyph tops sits visible before the animation starts. The negative margin
   returns the added height, so every line's top edge stays exactly where it
   was and nothing below the headline moves. */
.page-hero .h-display .reveal-line{ margin-bottom:-.18em; }
.page-hero .h-display .reveal-line > span{ padding-bottom:.18em; }

/* --- the solo holding screen (dang-cap-nhat.html) -------------------
   One navy screen that says it is being updated and hands the visitor back.
   No header, no footer, no index of sibling pages — a catch-all with a
   six-item menu is a menu, not a message. Client decision; do not add the
   chrome back to match the real pages. */
.page-hero--solo{
  min-height:100svh;
  display:grid; place-items:center;
  padding:clamp(56px,9vh,96px) var(--pad);
  text-align:center;
}
.page-hero--solo .solo{
  position:relative; z-index:1;
  display:grid; justify-items:center;
  max-width:44ch;
}
.page-hero--solo .solo__logo{
  display:flex; align-items:center; justify-content:center;
  gap:12px; margin-bottom:clamp(30px,5vh,50px); text-decoration:none;
}
.page-hero--solo .solo__logo img{ display:block; width:auto; height:42px; flex:0 0 auto; }
.page-hero--solo .solo__logo span{
  display:block; color:var(--accent);
  font-family:var(--f-mark); font-stretch:112%; font-weight:650;
  font-size:clamp(21px,5vw,27px); letter-spacing:.08em; line-height:1;
}
.page-hero--solo .lede{ max-width:32ch; }
.page-hero--solo .page-actions{ justify-content:center; }

/* --- footer, sub-page additions ------------------------------------
   The homepage footer has no current page to mark and no legal row. Both
   arrive with the sub-pages: 19 URLs need "you are here", and privacy/terms
   need somewhere to live that is not one of the four content columns. */
/* Same rule as the overlay: "page" for the page itself, "true" for the
   section a child page sits in. The footer marks it with gold alone, which is
   enough at 13px where the overlay needed an underline at 62px. */
.foot-col [aria-current]{ color:var(--accent); }
.foot-bot{
  display:flex; justify-content:center; flex-wrap:wrap; text-align:center;
  gap:8px clamp(14px,1.7vw,26px);
  margin-top:auto; padding-top:clamp(38px,4.2vw,62px); color:rgba(247,245,242,.36);
  font-size:9px; letter-spacing:.19em; line-height:1.45;
}
@media (max-width:1080px){
  .foot-bot{ justify-content:flex-start; text-align:left; }
}
@media (max-width:560px){
  /* Clears the to-top button, which parks in the same bottom-right corner. */
  .foot-bot{ margin-top:0; padding-top:56px; padding-right:58px; }
}

/* --- enquiry form ---------------------------------------------------
   The only place on the site that takes input, and the one component that
   could not be assembled from what the homepage already had.

   --field has sat in :root unused since the tokens were written; this is what
   it was reserved for. The one genuinely new value is --error, declared with
   the other tokens rather than inline here. Otherwise: no new hex, no radius
   (999px stays on buttons), no shadow. A form drawn as a stack of
   ruled fields on white is the same object as the ruled lists everywhere
   else, which is the point — it should not read as a widget dropped into an
   editorial page. */
.form{ display:grid; gap:var(--g-group); max-width:640px; }
.form-row{ display:grid; gap:8px; }
/* Two per row from 640 up. Below that a side-by-side pair of inputs is two
   half-width targets, which is worse than a longer form. */
@media (min-width:641px){
  /* align-items:start is load-bearing, not tidiness. Without it a grid row
     stretches both cells to the taller one, and .form-row is itself a grid —
     so an input beside a field that carries a hint or an error message grows
     to match it. Measured before the fix: a 48px input next to a 158px one. */
  .form-pair{ display:grid; grid-template-columns:1fr 1fr; gap:var(--g-group); align-items:start; }
}
.form label{
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.16em; text-transform:uppercase; color:var(--ink-soft);
}
/* A LABEL, always — never a placeholder standing in for one. A placeholder
   disappears the moment someone starts typing, which is exactly when a
   half-finished form is re-read. */
.form input,
.form select,
.form textarea{
  width:100%; font:inherit; font-size:var(--text-body); color:var(--ink);
  background:var(--field); border:1px solid var(--line); border-radius:0;
  padding:13px 15px;
  transition:border-color .3s var(--ease);
}
.form textarea{ min-height:150px; resize:vertical; }
.form input:hover,
.form select:hover,
.form textarea:hover{ border-color:rgba(20,22,26,.3); }
.form input:focus-visible,
.form select:focus-visible,
.form textarea:focus-visible{
  outline:2px solid var(--navy); outline-offset:2px; border-color:var(--navy);
}
/* Native select arrow removed and redrawn with the site's own arrow mask, so
   the one glyph the whole site uses for "onward" is not joined by a second. */
.form select{
  appearance:none;
  background-image:var(--arrow-down);
  background-repeat:no-repeat;
  background-position:right 14px center;
  background-size:15px;
  padding-right:42px;
}
.form-hint{ font-size:var(--text-small); line-height:var(--lh-tight); color:var(--ink-soft); }
/* Error text, not an error colour. Red on its own fails anyone who cannot see
   it; the message says what is wrong and aria-describedby ties it to the
   field. The border thickens as a second, non-colour signal. */
.form-error{ font-size:var(--text-small); color:var(--error); display:none; }
.form-row.is-invalid .form-error{ display:block; }
/* The server's one failure signal. apply.php / enquiry.php bounce a rejected
   submission to ...?send=failed#apply / #enquiry; the band ships [hidden] and
   the `send failed` block in site.js lifts the attribute. Bordered, not ruled:
   like the pager, it is a status control, not a list. */
.form-fail{
  font-size:var(--text-small); color:var(--error);
  border:1px solid var(--error); padding:12px 16px;
  margin-bottom:var(--g-group);
}
.form-fail a{ color:inherit; }
.form-row.is-invalid input,
.form-row.is-invalid select,
.form-row.is-invalid textarea{ border-width:2px; border-color:var(--error); }
/* The honeypot. Not display:none — some bots skip hidden fields, and some
   password managers do too; off-canvas with aria-hidden and tabindex -1 keeps
   it out of the tab order and off the screen while still being fillable by
   anything reading the DOM naively. */
.form-hp{
  position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden;
}
.form-consent{ display:grid; grid-template-columns:auto 1fr; gap:12px; align-items:start; }
.form-consent input{ width:18px; height:18px; margin-top:3px; }
.form-consent label{
  font-family:var(--f-display); font-size:var(--text-small); letter-spacing:0;
  text-transform:none; line-height:var(--lh-tight); color:var(--ink-soft);
}
.form-consent a{ color:var(--navy); }
/* Live region for the submit result. Empty until something happens, so it
   costs no space and announces once when it does. */
.form-status{ font-size:var(--text-body); color:var(--ink-soft); }
.form-status:empty{ display:none; }

/* On navy the fields keep their white ground — a form is a place to write, and
   inverting it would make the one bright rectangle on the page the thing you
   cannot read. Only the labels move. */
.on-dark .form label{ color:rgba(247,245,242,.72); }
.on-dark .form-hint,
.on-dark .form-consent label,
.on-dark .form-status{ color:rgba(247,245,242,.72); }
.on-dark .form-consent a{ color:var(--accent); }

/* ============================================================
   SECTION ARCHETYPES
   Added 2026-07-30 after a review that was correct and unpleasant: the first
   pass at 18 sub-pages had built 42 copies of .cap-list and almost nothing
   else. Every page was a navy band followed by four identical ruled lists.

   Reusing components is the rule; reusing ONE component is the failure mode.
   The homepage does not read as editorial because it repeats a row — it reads
   that way because it changes shape roughly every screen: a full-bleed video,
   a portrait beside prose, a stat quad, a ruled list, a horizontal photo
   track, a product grid, a photo band, a two-column cert list, a map.

   What follows is that vocabulary generalised so a sub-page can do the same.
   The rule for using them: NO SECTION MAY USE THE SAME ARCHETYPE AS THE ONE
   ABOVE IT. A page that reads .feature, .stepline, .statement, .spec, .faq
   is a page; a page that reads .cap-list four times is a list.

   Every value below is a token. No new colour, no radius (999px is buttons
   only), no shadow, no filled card — the ruled hairline stays the only
   division on this site.
   ============================================================ */

/* --- feature: one big photograph beside its argument ------------------
   The workhorse. A 3:2 photograph and a short piece of prose, alternating
   side down the page so a run of them does not become a column. This is what
   the sub-pages were missing most: the client's 49 factory photographs sat
   unused while the pages talked about a factory nobody could see. */
.feature{
  display:grid;
  grid-template-columns:minmax(0,1.08fr) minmax(0,1fr);
  gap:clamp(32px,4.4vw,72px);
  align-items:center;
}
/* EVERY new .slot context has to declare an aspect ratio. .slot is a FILL
   frame — `.slot > picture` is position:absolute; inset:0 — so a slot with no
   height rule collapses to nothing and the photograph silently does not
   appear. There is no error and no broken-image icon; the section just reads
   as if the picture was never written. Every existing use sets one
   (.prod .slot is 3/4, .intro-figure .slot is 4/5); these are the new ones,
   and each matches the asset the build script actually generates. */
.feature > .slot{ aspect-ratio:3/2; }
.feature--tall > .slot{ aspect-ratio:4/5; }
/* Reverses the columns without touching source order, so the reading order
   stays image-then-argument for a screen reader however the page alternates. */
.feature--flip > .slot{ order:2; }
.feature + .feature{ margin-top:var(--g-section); }
.feature__body > * + *{ margin-top:var(--g-pair); }
/* Paragraphs need the bigger step. --g-pair is a label-to-thing gap; between
   two blocks of running text it is smaller than the line-height, so the two
   read as one paragraph that happens to have a wide leading. */
.feature__body p + p{ margin-top:var(--g-group); }
.feature__body .h-sub{ display:block; }
.feature__body p{
  color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body);
  max-width:var(--measure-narrow); text-wrap:pretty; margin:0;
}
.on-dark .feature__body p{ color:rgba(247,245,242,.78); }
/* A kicker above the sub-head, in the same mono as every other micro-label. */
.feature__body .tag{ display:block; color:var(--accent); margin-bottom:var(--g-pair); }
@media (max-width:900px){
  .feature{ grid-template-columns:1fr; gap:clamp(20px,4vw,30px); }
  .feature--flip > .slot{ order:0; }
}

/* --- stepline: a numbered process, at full size ----------------------
   What the five-stage rail becomes when a page has room to explain rather
   than to summarise. The numeral is the anchor and it is deliberately large:
   on a page about sequence, the sequence should be legible from across the
   room. Photograph optional — steps without one keep the same grid so the
   copy column never shifts. */
.stepline{ border-top:1px solid var(--line); }
.on-dark .stepline{ border-top-color:var(--line-light); }
.step{
  display:grid;
  grid-template-columns:clamp(64px,7vw,110px) minmax(0,1fr);
  gap:clamp(20px,3vw,48px);
  padding-block:clamp(30px,3.6vw,56px);
  border-bottom:1px solid var(--line);
  align-items:start;
}
.on-dark .step{ border-bottom-color:var(--line-light); }
.step__n{
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(26px,3.2vw,46px); line-height:1;
  letter-spacing:-.02em; color:var(--accent);
}
.step__body{ display:grid; gap:var(--g-pair); }
/* The photograph sits in the copy column, under the heading, at a width that
   keeps it a supporting illustration rather than the subject of the step. */
/* The photograph is a SIBLING of .step__body, not a child: stacked under the
   prose it read as a caption to the paragraph above it rather than as the
   stage itself. Sides alternate so a run of five does not become a column. */
.step__img{ grid-column:2; margin-top:var(--g-group); aspect-ratio:3/2; }
/* EVERY RULE HERE IS GATED ON :has(.step__img), and that is the fix rather
   than the flourish. The third column and the alternation exist to hold a
   photograph; a stepline WITHOUT photographs — certifications' four
   programmes, contact's "what happens next" — inherited them anyway, so every
   second entry jumped to column 3 and left an empty column beside it. It read
   as a broken layout, which is exactly what it was.
   If :has() is unsupported the whole block is dropped and every step falls
   back to two columns with its picture underneath: degraded, never broken. */
@media (min-width:1080px){
  .step:has(.step__img){ grid-template-columns:clamp(64px,7vw,110px) minmax(0,1.04fr) minmax(0,.92fr); }
  .step:has(.step__img) .step__img{ grid-column:3; grid-row:1; margin-top:0; }
  .step:nth-child(even):has(.step__img) .step__img{ grid-column:2; }
  .step:nth-child(even):has(.step__img) .step__body{ grid-column:3; grid-row:1; }
}
.step__body p{
  margin:0; color:var(--ink-soft);
  font-size:var(--text-body); line-height:var(--lh-body);
  max-width:var(--measure-body); text-wrap:pretty;
}
.on-dark .step__body p{ color:rgba(247,245,242,.78); }
/* The one detail that turns a description into a specification: a short
   run of labelled facts under the prose, ruled off from it. */
.step__facts{
  list-style:none; margin:var(--g-pair) 0 0; padding:0;
  display:grid; gap:6px; max-width:var(--measure-body);
}
.step__facts li{
  display:grid; grid-template-columns:minmax(0,150px) minmax(0,1fr); gap:16px;
  padding-top:8px; border-top:1px solid var(--line);
  font-size:var(--text-body); line-height:var(--lh-body);
}
.on-dark .step__facts li{ border-top-color:var(--line-light); }
.step__facts b{
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
  padding-top:2px;
}
.on-dark .step__facts b{ color:var(--accent); }
.step__facts span{ color:var(--ink-soft); }
.on-dark .step__facts span{ color:rgba(247,245,242,.78); }
@media (max-width:640px){
  .step{ grid-template-columns:1fr; gap:10px; }
  .step__facts li{ grid-template-columns:1fr; gap:2px; }
}

/* --- the stack's own facts: unruled, and its heading lit ---------------
   Client, 2026-08-07, on capabilities' five stages: "bỏ line đi nhìn cho đỡ
   rối", and separately the heading should go gold as you reach it.

   SCOPED TO .stagestack, NOT TO .step__facts. The ruled cell is this site's
   list vocabulary and thirteen pages use it; stripping the rule at the
   archetype would silently un-rule certifications' programmes, the road
   figures on markets and the products spec strips, none of which the client
   was looking at. What is specific here is the DENSITY: inside the stack a
   step already carries a numeral, a heading, two paragraphs and a photograph,
   so three more hairlines under all of that are the fourteenth and fifteenth
   horizontal lines in one cell. Elsewhere .step__facts is the only ruled thing
   in its block and the rule is what makes it read as a specification.
   The label column and the gap stay: without the rules the mono label IS the
   structure, so it keeps its lane rather than the cells collapsing to prose. */
.stagestack .step__facts li{ border-top:0; padding-top:0; }
.stagestack .step__facts{ gap:10px; }

/* The gold current heading. Now on BOTH stacks: the .m-capabilities scope was
   lifted 2026-08-07 on the client's request for certifications ("scroll tới
   đâu thì tiêu đề in đậm màu vàng"). It is the archetype's behaviour again,
   which is what site.js always assumed — it sets .is-current on every stack.

   The scope existed because certifications' steps put a gold mono kicker
   (SUSTAINABILITY & RECYCLED MATERIALS) directly under the programme name, so
   colour alone gave the lit cell a gold title over a gold kicker over a gold
   numeral and the title/kicker hierarchy collapsed. WEIGHT is what answers
   that, and it is also literally what was asked for: at 700 against the
   kicker's mono 500 the title is unmistakably the title, so gold can stop
   carrying the hierarchy and go back to meaning only "you are here".

   Weight was previously refused here because it reflows, and the objection
   was right in principle — headings twitching down the page as you scroll
   past them would be worse than no highlight. It is safe on these two stacks
   because it was MEASURED, not assumed: the four programme names and the five
   stage names all wrap identically at 500 and 700, so no step changes height.
   That is a fact about these nine strings, not about the archetype.
   Re-measure before adding a step and before any rewording that lengthens a
   heading — the failure is silent, and it looks like scroll jank rather than
   like a typography bug.

   Gated on .js and on the width where the held media column is actually on
   screen. In the narrow rendering every step carries its own picture inline and
   there is no held frame for the highlight to agree with, so a lit heading
   would be marking nothing — the same reason .stagestack__media is hidden
   there. .h-sub keeps its own colour and weight until then. */
@media (min-width:1080px){
  .js .stagestack .step .h-sub{ transition:color .45s var(--ease); }
  .js .stagestack .step.is-current .h-sub{ color:var(--accent); font-weight:700; }
}
@media (prefers-reduced-motion:reduce){
  .js .stagestack .step .h-sub{ transition:none; }
}

/* --- spec: the table this site kept needing --------------------------
   Ruled, never boxed: hairlines top and bottom on the table, a hairline under
   every row, and a vertical rule only where a column genuinely divides. No
   fill, no zebra striping — a striped table is a filled card wearing a
   disguise, and this site has no filled surfaces. */
.spec-wrap{ overflow-x:auto; }
.spec{
  width:100%; border-collapse:collapse; text-align:left;
  font-size:var(--text-body); min-width:520px;
}
.spec caption{
  text-align:left; color:var(--ink-soft);
  font-size:var(--text-small); line-height:var(--lh-tight); padding-bottom:var(--g-pair);
}
.on-dark .spec caption{ color:rgba(247,245,242,.72); }
.spec th{
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
  padding:0 24px 12px 0; vertical-align:bottom;
  border-bottom:1px solid var(--line);
}
.on-dark .spec th{ color:var(--accent); border-bottom-color:var(--line-light); }
.spec td{
  padding:16px 24px 16px 0; vertical-align:top;
  border-bottom:1px solid var(--line);
  color:var(--ink-soft); line-height:var(--lh-tight);
}
.on-dark .spec td{ border-bottom-color:var(--line-light); color:rgba(247,245,242,.78); }
/* The row's subject reads as ink, the rest as supporting detail — the same
   hierarchy .cap-row draws with .h-sub against its paragraph. */
.spec tbody th{
  font-family:var(--f-display); font-weight:500; font-size:var(--text-body);
  letter-spacing:-.01em; text-transform:none; color:var(--ink);
  padding:16px 24px 16px 0; vertical-align:top; line-height:var(--lh-tight);
}
.on-dark .spec tbody th{ color:var(--paper); }
.spec tr:last-child td,
.spec tr:last-child th{ border-bottom:0; }

/* --- spec--drivers: the comparison table, given fixed columns --------
   products.html#quoting, client 2026-08-07 ("rất rối"). A MODIFIER, not an
   edit to .spec: six other tables on the four line pages are signed off on
   auto layout, where the column widths follow the content and that is the
   right answer for a two-column figures table.

   It is the wrong answer for a 3×5 comparison. Auto layout distributes the
   slack by measuring the longest cell, so the widest prose column took it
   all: at 1440 column 1 was narrow enough to wrap "Apparel & household" onto
   two lines — the only two-line row heading in the table, and therefore the
   one the eye reads as significant — while 2 and 3 ended ragged well short
   of their own right edges. Fixed columns make the three column edges the
   grid the eye actually follows down the table.

   22/39/39 rather than a flat third each: column 1 holds names, not
   sentences. 266px at 1440 sets every one of the five on a single line with
   room to spare, and hands the 100px it does not need to the two columns
   that are doing the reading. */
.spec--drivers{ table-layout:fixed; }
/* FIXED COLUMNS ARE A DESKTOP ANSWER TO A DESKTOP PROBLEM.
   .spec-wrap scrolls horizontally below .spec's 520px min-width, and 22/39/39
   of 520 is 114/203/203 — a fixed grid that narrow crushes column 1 into a
   word ladder, which is the fault this modifier exists to remove. Handing the
   layout back to auto below the breakpoint restores exactly the behaviour the
   other six tables have on a phone: the columns measure their own content and
   the wrapper scrolls. Nothing about the desktop grid is conditional on it.

   BOTH HALVES ARE RESET, because table-layout:auto alone would not have done
   it: an explicit `width` on a th is still a sizing hint under auto layout and
   Chrome honours it, so the percentages would have followed the table down.
   At 390 the reset measures 114/203/203 — the same split, because at the 520px
   floor the content happens to want those widths anyway. That is the point:
   they are now what the content asked for rather than a desktop ratio pinned
   onto a phone, so a longer cell can take the space it needs. */
@media (max-width:720px){
  .spec--drivers{ table-layout:auto; }
  .spec--drivers th:first-child,
  .spec--drivers th:nth-child(2),
  .spec--drivers th:nth-child(3){ width:auto; }
}
.spec--drivers th:first-child{ width:22%; }
.spec--drivers th:nth-child(2),
.spec--drivers th:nth-child(3){ width:39%; }
/* THE NOTE MOVES UNDER THE TABLE.
   A caption above the header row is read before the thing it captions, so a
   three-sentence one pushed the table's first row 60px down the band and put
   body copy between the section lede and the content. Below the rules it is
   a footnote, which is what it always was. `caption-side` needs the padding
   flipped with it — the base rule pads the bottom, which under the table is
   a gap to nothing. */
.spec--drivers caption{
  caption-side:bottom;
  padding-bottom:0; padding-top:var(--g-pair);
  max-width:100ch;
}
/* AND THE LINK INSIDE IT HAS TO BE VISIBLE.
   The base sheet sets a{color:inherit;text-decoration:none}, so the
   capabilities cross-link arrived here indistinguishable from the sentence
   around it. That was already true in the .lede this note replaced — probed,
   not assumed: text-decoration `none`, colour identical to the paragraph —
   and it mattered less there because #shared carried the same link one band
   up. #shared's closing line is gone, so this is now the page's only route
   to capabilities.html and it cannot be an invisible one.

   Gold plus a hairline, which is exactly what .figcap--prose a already does
   for a cross-link in on-dark small print. Only one caption on the site
   contains a link (checked across all 22 pages), so the unmodified selector
   is narrow in practice as well as correct in principle. */
.spec caption a{
  color:var(--navy); border-bottom:1px solid var(--line);
  transition:border-color .3s var(--ease);
}
.spec caption a:hover,
.spec caption a:focus-visible{ border-bottom-color:var(--navy); }
.on-dark .spec caption a{ color:var(--accent); border-bottom-color:var(--line-light); }
.on-dark .spec caption a:hover,
.on-dark .spec caption a:focus-visible{ border-bottom-color:var(--accent); }

/* --- statement: one sentence, given a whole band ---------------------
   The rhythm break. A page of sections all the same height reads as a list of
   sections; this is the beat that resets it.

   Weight 400, not the 600 the display tiers take on navy. TRACKER records
   why: the 600 floor exists because Fraunces goes hairline-thin at 52-72px,
   and this sets around 34px where the strokes still carry. Raise the size
   here and the weight has to follow. */
.statement{
  font-family:var(--f-serif); font-optical-sizing:auto;
  font-size:clamp(24px,2.6vw,36px); line-height:1.28;
  font-weight:400; letter-spacing:-.01em;
  max-width:24ch; margin:0; text-wrap:balance;
}
.statement__cite{
  display:block; margin-top:var(--g-group);
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.16em; text-transform:uppercase; color:var(--accent);
}
/* The hairline is the accent's only appearance in this block, which is what
   keeps a quiet band quiet. */
.statement-rule{
  width:64px; height:2px; background:var(--accent);
  /* margin-inline:0 is not tidying. This is an <hr>, and the UA sheet gives it
     margin-inline:auto; no author rule ever overrode it, so the gold hairline
     centred itself in whatever box it was in while the sentence under it stayed
     flush left. It read as a stray dash floating in the middle of the band —
     the same class of bug as the UA figure margin that broke the full-bleed
     plates, and invisible in review for the same reason: nothing in this file
     said "centre", so nothing in this file looked wrong. */
  margin:0 0 var(--g-block); border:0;
}

/* --- vm: the vision / mission band -----------------------------------
   about.html only, and it is a layout rather than a new voice: the two
   sentences it carries are .statement, the same face and size as every other
   statement on the site.

   It exists because .split was the wrong container for them. .split is a
   1fr/1.04fr grid built to hold a sticky photograph beside a body column, and
   used as two plain text lanes it capped both sentences at roughly 480px —
   .statement's own 24ch measure then cut them again, so each ran to five
   short lines and the pair read as two cramped columns rather than as two
   statements. Client, 2026-08-06: change the layout.

   Two full-width rows instead, label in a narrow left rail. Same ruled
   language as .marks and .step__facts — hairline above the run, hairline
   under each row, no box. The rail width is the .step__facts label column
   rounded up, so the mono label lands on the axis the fact sheets use. */
.vm{
  display:grid; margin-top:var(--g-block);
  border-top:1px solid var(--line);
}
.on-dark .vm{ border-top-color:var(--line-light); }
.vm__row{
  display:grid;
  grid-template-columns:minmax(0,170px) minmax(0,1fr) auto;
  gap:clamp(18px,3vw,56px); align-items:start;
  padding:var(--g-block) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .vm__row{ border-bottom-color:var(--line-light); }
.vm__row .tag{ display:block; color:var(--accent); padding-top:.55em; }
/* Overrides .statement's 24ch. That measure is tuned for a statement standing
   alone in a band with nothing beside it; here the sentence has a 900px lane
   and holding it to 24ch would rebuild the wrap this layout exists to fix. */
.vm__row .statement{ max-width:44ch; }
/* The mark in the right margin. currentColor is set here rather than on the
   <svg> so the structure takes navy and the one filled dot inside each mark
   keeps var(--accent) — the two-colour geometric pairing the certification
   marks already use, and the reason neither icon needs a fill or a badge. */
.vm__mark{
  display:block; color:var(--navy);
  width:clamp(44px,4.4vw,62px); align-self:center;
}
.on-dark .vm__mark{ color:var(--accent); }
.vm__mark svg{ display:block; width:100%; height:auto; }
@media (max-width:820px){
  /* The mark moves up beside the label rather than being dropped. It is the
     row's only non-text element and at this width the label line is otherwise
     a single short word with the rest of the row empty beside it. */
  .vm__row{
    grid-template-columns:minmax(0,1fr) auto;
    gap:var(--g-pair) var(--g-pair);
  }
  .vm__row .tag{ padding-top:0; align-self:center; }
  .vm__row .statement{ grid-column:1 / -1; }
  .vm__mark{ grid-column:2; grid-row:1; width:38px; }
}

/* --- figures: a stat row that is not the homepage's stat quad --------
   The quad is a four-row ruled table that owns a whole screen. This is the
   inline version: three or four numbers across one line, for a page that
   needs to state a scale in passing rather than to dwell on it. */
/* NO RULE ABOVE THE ROW (client, 2026-08-07: "bỏ line"). The hairline and its
   padding-top were one gesture, so both go: with the border gone the padding
   was orphan space sitting on top of .sec-head's own padding-bottom, which is
   the double heading gap this sheet spent a pass removing everywhere else. The
   numerals are large enough now to open the block on their own — a rule above
   four 76px figures was fencing something that needed no fence.
   The gap tightened 2.6vw→2.2vw in the same pass, and that is not a taste
   change: it is the ~24px per cell the bigger numeral needed. See below. */
.figures{
  display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
  gap:clamp(20px,2.2vw,36px);
}
/* 80px, up from 66, up from 52 (client, 2026-08-07: "tăng size con số nổi
   bật" — the third pass on this number). The three pages using this row carry
   FOUR, four and three figures, and the 230px floor above is what keeps a
   fifth from ever squeezing four numerals of this size into a row that cannot
   hold them: past four cells auto-fit wraps to a second line rather than
   shrinking the type.
   ONE STRING SETS THIS CEILING AND IT IS MEASURED, NOT CHOSEN: "34,000" plus
   its m², the longest numeral on the site. A first pass at 76px/5.2vw shipped
   it OVERFLOWING — 296px of numeral in a 279px cell at 1440 — because the cell
   is ~279 and not the ~290 the arithmetic assumed. Three things pay for the
   rise, and the size cannot go up again without all three re-measured:
     · the row gap, 2.6vw→2.2vw above       (~+16px of cell at 1440)
     · the unit mark, .42em→.38em below     (~−4px of string)
     · the tracking, -.04em→-.05em here     (~−5px of string)
   That leaves ~3.84px of string per px of font-size, so the safe term is
   cell/3.84 at every width: 63px at 1280, 72px at 1440, 85px at 1680. 4.8vw
   sits under all three and reaches the 80px cap at ~1667. Measured slack:
   9px at 1280, 15px at 1440, 24px at 1680 and above. Verified on all three
   pages — oem-odm's "500–3,000" is the other long string and it runs three-up,
   in a wider cell, so it clears by 10px at its own tightest width. */
.figures > div{ container-type:inline-size; }
.figures > div > b{
  display:block;
  /* Cell-aware ceiling — see the long note at .figures--wall > div > b. The
     comment above ("oem-odm's 500–3,000 ... clears by 10px at its own tightest
     width") is the hand-measured version of this, one string at a time; the
     cap makes it hold for every string at every width. --fig-w rarely binds
     here, because this tier tops out at 80px: at 1280 the cell is 244, so the
     default only starts to bite past a cost of about 3.0. */
  font-size:min(clamp(34px,4.8vw,80px), calc(100cqw / var(--fig-w,2.2)));
  font-weight:420;
  letter-spacing:-.05em; line-height:1; color:var(--ink);
}
/* Any figures row whose longest numeral is a long one. Measured: "150,000/mo"
   costs 4.45px of ink per px of font-size, against 2.11 for "500+" and 0.32
   for a bare "2". Applied per ROW, not globally, so a row of short numerals
   keeps its full size. Re-measure when a figure's string changes. */
.figures--long{ --fig-w:4.6; }
.on-dark .figures > div > b{ color:var(--paper); }
/* .38em, down from .42em, and this is the one change in the pass that helps the
   design as well as the arithmetic: a quieter unit mark is what makes the
   figure beside it read as the big thing. See the ceiling note above — this is
   ~4px of the string's budget.
   .34em WAS TRIED FIRST AND CAME BACK. The boxes are flush at any size (probed:
   the gap between the numeral and the mark is 0px either way), but a bare "+"
   is a narrow glyph centred in a wide advance, so shrinking it 19% while the
   digits grew 21% left its own side bearing reading as a space — "993 +"
   rather than "993+". The m² pair never showed it; the two "+" figures did.
   .38 keeps the mark subordinate without detaching it. */
.figures > div > b i{ font-style:normal; color:var(--navy); font-size:.38em; }
.on-dark .figures > div > b i{ color:var(--accent); }
.figures > div > span{
  display:block; margin-top:10px;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
}
.on-dark .figures > div > span{ color:var(--accent); }
.figures > div > p{
  margin:8px 0 0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); max-width:30ch;
}
.on-dark .figures > div > p{ color:rgba(247,245,242,.78); }

/* --- gallery ---------------------------------------------------------
   Square frames, three across, hairline-gapped rather than carded. Same
   .slot as everywhere else, so the navy placeholder and the scanline wash
   come along and an image that has not loaded is still a composed rectangle. */
.gallery{
  display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
  gap:clamp(10px,1.2vw,18px);
}
.gallery .slot{ aspect-ratio:1/1; }
.gallery--two{ grid-template-columns:repeat(2,minmax(0,1fr)); }
@media (max-width:820px){
  .gallery{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}

/* --- prose: an editorial text block ----------------------------------
   For the pages that are genuinely writing — About's story, the policies.
   Two columns above 900px because a 58ch measure across a 1680 container
   leaves two thirds of the line empty, and a single 100ch line is unreadable. */
.prose{ max-width:var(--measure-body); }
/* `> p + p` is not redundant with `> * + *`, and removing it re-breaks this.
   `.prose p{margin:0}` two lines down is (0,1,1); `.prose > * + *` is (0,1,0),
   because a universal selector contributes nothing to specificity. So the
   shorthand reset won and EVERY paragraph after the first in a .prose block
   ran flush against the one above it — 16 of them across about and the four
   product-line pages, which is why About's story read as one unbroken wall.
   Found 2026-07-31. `> p + p` is (0,1,2) and outranks the reset; the `> * + *`
   form stays for the h3 and ul children it was written for. */
.prose > * + *,
.prose > p + p{ margin-top:var(--g-group); }
.prose p{
  margin:0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}
.on-dark .prose p{ color:rgba(247,245,242,.78); }
/* The opening paragraph carries a little more weight and size, which is how a
   magazine signals "start here" without a graphic device. */
.prose > p:first-child{ font-size:var(--text-lede); color:var(--ink); }
.on-dark .prose > p:first-child{ color:var(--paper); }
.prose--wide{
  max-width:min(100%,1040px);
  columns:2; column-gap:clamp(32px,4vw,72px);
}
.prose--wide p{ margin:0 0 var(--g-group); break-inside:avoid; }
@media (max-width:900px){ .prose--wide{ columns:1; } }
/* A pull quote, ruled off rather than boxed, sized between body and
   .statement so it reads as emphasis inside prose and not as a new section. */
/* `.pull, p.pull` — the second half is not decoration. `.prose p` above is
   (0,1,1) and `.pull` alone is (0,1,0), so inside .prose the body rule won
   both the size AND the colour: every pull quote in the 18 news articles
   rendered at body size in --ink-soft while still carrying the serif face,
   the gold rule and line-height 1.32. It read as a paragraph that had been
   styled by accident. `p.pull` matches `.prose p`'s specificity and sits
   after it, so the quote wins on order. */
.pull,
p.pull,
.brand-line{
  border-left:2px solid var(--accent); padding:2px 0 2px 22px;
  font-family:var(--f-serif); font-optical-sizing:auto;
  font-size:clamp(19px,1.8vw,25px); line-height:1.32; font-weight:400;
  letter-spacing:-.01em; color:var(--ink); max-width:34ch;
}
.on-dark .pull,
.on-dark .brand-line{ color:var(--paper); }

/* --- faq: the disclosure list ----------------------------------------
   Native <details>, so it works with JavaScript off and the browser handles
   the open state. Same ruled row as everything else; the marker is the site's
   arrow, rotated a quarter turn when open. */
.faq{ border-top:1px solid var(--line); }
.on-dark .faq{ border-top-color:var(--line-light); }
.faq > details{ border-bottom:1px solid var(--line); }
.on-dark .faq > details{ border-bottom-color:var(--line-light); }
.faq summary{
  display:grid; grid-template-columns:1fr 24px; gap:20px; align-items:center;
  padding-block:clamp(18px,2vw,26px); cursor:pointer; list-style:none;
  font-size:var(--text-title); line-height:var(--lh-title); font-weight:500; letter-spacing:-.02em;
  color:var(--ink);
}
.on-dark .faq summary{ color:var(--paper); }
.faq summary::-webkit-details-marker{ display:none; }
.faq summary::after{
  content:""; width:20px; height:20px; justify-self:end;
  background:currentColor;
  -webkit-mask:var(--arrow-down) center/contain no-repeat;
          mask:var(--arrow-down) center/contain no-repeat;
  transition:transform .3s var(--ease);
}
.faq details[open] summary::after{ transform:rotate(180deg); }
.faq summary:hover{ color:var(--navy); }
.on-dark .faq summary:hover{ color:var(--accent); }
.faq summary:focus-visible{ outline:2px solid var(--navy); outline-offset:4px; }
.on-dark .faq summary:focus-visible{ outline-color:var(--accent); }
.faq__a{
  padding-bottom:clamp(20px,2.2vw,30px);
  max-width:var(--measure-body);
}
.faq__a p{
  margin:0; color:var(--ink-soft);
  font-size:var(--text-body); line-height:var(--lh-body); text-wrap:pretty;
}
.on-dark .faq__a p{ color:rgba(247,245,242,.78); }
.faq__a p + p{ margin-top:var(--g-pair); }

/* Tighter rows in section 04 (client, 2026-08-07: "giảm khoảng cách"), in the
   same note that cut the run from nine questions to five.
   SCOPED TO .p-control, not applied to .faq itself, and that is a scope
   decision rather than caution: five other pages carry this accordion
   (careers, certifications, terms, privacy-policy, products-sports-shoes) and
   the client was looking at this one. The archetype's own padding is the right
   default for a run that is the whole point of its section — privacy-policy's
   ten clauses want the air. Promote these two values to `.faq` if the client
   asks for the same tightening elsewhere; nothing here depends on the scope. */
.p-control .faq summary{ padding-block:clamp(13px,1.4vw,18px); }
.p-control .faq__a{ padding-bottom:clamp(14px,1.6vw,22px); }

/* --- marks: the membership / accreditation strip ---------------------
   Ruled cells rather than a row of floating logos, so a strip with two marks
   in it still looks composed. Cells divide with a border-right, the same way
   the cert list divides its columns. */
.marks{
  list-style:none; margin:0; padding:0;
  display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
  border-top:1px solid var(--line);
}
.on-dark .marks{ border-top-color:var(--line-light); }
.marks li{
  padding:var(--g-group) clamp(18px,2vw,32px) var(--g-group) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .marks li{ border-bottom-color:var(--line-light); }
.marks li + li{ border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px); }
.on-dark .marks li + li{ border-left-color:var(--line-light); }
.marks b{
  display:block; font-size:var(--text-title); line-height:var(--lh-title); font-weight:500;
  letter-spacing:-.02em; color:var(--ink);
}
.on-dark .marks b{ color:var(--paper); }
.marks span{
  display:block; margin-top:6px;
  font-family:var(--f-mono); font-weight:500; font-size:10.5px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.marks p{
  margin:var(--g-pair) 0 0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); max-width:34ch;
}
.on-dark .marks p{ color:rgba(247,245,242,.78); }

/* --- full-bleed photograph, with or without copy ---------------------
   .band already exists for the homepage's sustainability section. This is the
   plain variant: a photograph that runs edge to edge with nothing on it,
   used as a breath between two dense sections rather than as a slot for a
   headline. */
/* margin:0 kills the UA's default figure margin (1em 40px). Without it every
   plate floated 40px off both edges — measured, the "full-bleed" breath bands
   were the only objects on the site that failed to reach the viewport edge.
   Client request 2026-08-03: these run edge to edge, no side gutter. */
.plate{ position:relative; overflow:hidden; margin:0; }
/* aspect-ratio AND a floor: the ratio is what the asset is cut to, the
   min-height stops a very wide viewport reducing it to a letterbox strip. */
.plate > .slot{ aspect-ratio:21/9; min-height:clamp(260px,32vw,520px); }
/* A scrim under the caption, added 2026-07-31. The caption was paper-white
   with a text-shadow, which held on the one dark photograph the component was
   built against and failed the moment a bright one went through it — measured
   on the leather line plate, where the caption sits on a white bench and a
   lit wall. A shadow cannot rescue light text on a light ground; only a scrim
   can. Shallow and bottom-only, so the picture stays a picture.
   :not(.plate--over) because that variant already owns .slot::after for its
   own, much heavier, full-height gradient. */
.plate:not(.plate--over) > .slot::after{
  content:""; position:absolute; inset:auto 0 0 0; height:34%;
  z-index:1; pointer-events:none;
  /* .82 at the foot, not .74: measured against the leather-line plate, whose
     bottom edge is a white bench under work lights. At .74 over white the
     caption cleared 4.5:1 but only just, and this type is 10.5px. .82 puts it
     near 8:1 with the caption at full paper opacity, and the picture still
     reads as a picture 200px further up. */
  background:linear-gradient(to top, rgba(1,34,61,.82) 0%, rgba(1,34,61,.34) 46%, transparent 100%);
}
.plate:not(.plate--over) figcaption{ color:var(--paper); }
.plate figcaption{
  position:absolute; z-index:2; left:var(--pad-l); bottom:clamp(16px,2vw,26px);
  font-family:var(--f-mono); font-weight:500; font-size:10.5px;
  letter-spacing:.16em; text-transform:uppercase;
  color:rgba(247,245,242,.82); max-width:40ch;
  text-shadow:0 1px 12px rgba(1,34,61,.55);
}
/* THE CAPTION HAS TO CARRY THE CONTAINER'S CENTRING OFFSET, not a bare --pad-l.
   Client, 2026-08-07: the rail was cutting through "THE LENGTH OF ONE LINE".
   Measured at 1920 — caption 176→352px, rail at 224px, straight through the
   middle of it; at 2200 the rail clears the text but the caption is then
   stranded to the LEFT of the spine, outside the content lane entirely.
   A .plate lives outside .container, so left:var(--pad-l) pins the caption to
   the VIEWPORT while every other thing on the page — including the rail, which
   already has this term — stops growing at 1680 and centres. Above 1680 the two
   drift apart at half the surplus width. This was wrong before the rail existed
   on 21 plates across 10 pages; the rail only made it visible.
   Same expression as .sec-rail's `left`, so the two cannot diverge again. */
.plate figcaption{
  left:calc(max(0px, (100vw - 1680px) / 2) + var(--pad-l));
}

/* --- plate--short: the breath, made shorter --------------------------
   Client, 2026-08-07, on both of capabilities' plates: "giảm height hình ảnh".
   At 21/9 a plate is 617px at 1440 and 823px at 1920 — the ratio has no ceiling,
   so on a wide screen the "breath between two dense sections" was most of a
   screen. A fixed height with object-fit:cover crops instead of scaling, so the
   band stops growing while the photograph stays a photograph.
   aspect-ratio:auto and min-height:0 are both required, not belt-and-braces:
   the base rule sets `min-height:clamp(260px,32vw,520px)` (460px at 1440) and a
   min-height BEATS a max-height in CSS, so leaving it in place would have
   silently floored this at 460 and the height would never have applied.
   A MODIFIER RATHER THAN THE ARCHETYPE: 9 other pages carry signed-off plates
   at the taller ratio, and this note is about capabilities. */
.plate--short > .slot{
  aspect-ratio:auto;
  min-height:0;
  height:clamp(260px,30vw,440px);
}
/* 30ch, up from 17ch, and it is what makes the shorter band possible rather
   than merely shorter. The copy block measures 416px at 1440 — 67% of the old
   617px plate — because a 17ch measure breaks "Everything you are about to read
   happens in this room." over THREE lines at 52px. At 30ch it sets in two, the
   block drops to ~275px, and a 440px band still has a strip of photograph above
   the words. The type size is untouched: this is a measure change, not a
   demotion of the headline. */
.plate--short .plate__copy .h-display{ max-width:30ch; }

/* ============================================================
   AMBITION LAYER
   Added 2026-07-30 to the same brief the archetypes answered: the sub-pages
   had stopped repeating one component but were still flat next to the
   homepage. Three things the homepage does and they did not — a moment that
   moves, a composition that breaks the column, and a jump in scale.
   ============================================================ */

/* --- 1. the moment that moves: a stepline numeral that holds ----------
   The numeral parks under the header while its own step scrolls past, then is
   pushed out by the next one. Position:sticky inside the grid area, so the
   travel is bounded by the step itself and no JS is involved — which also
   means it degrades to a static numeral wherever sticky is unsupported and
   nothing about the layout depends on it.

   Desktop only. On a phone the step is barely taller than the viewport, so
   there is nothing to hold against and a sticky numeral just looks stuck. */
@media (min-width:901px){
  .stepline--sticky .step__n{
    position:sticky;
    top:calc(var(--hdr-h) + clamp(18px,4vh,54px));
  }
}
@media (prefers-reduced-motion:reduce){
  .stepline--sticky .step__n{ position:static; }
}

/* --- 2. the composition that breaks the column -----------------------
   A feature whose photograph runs off the edge of the screen. Lives OUTSIDE
   .container — the same trick .making-track uses — rather than fighting its
   padding with a negative margin, because a negative margin would have to be
   written against 100vw and 100vw includes the scrollbar.

   IT BLEEDS RIGHT. The left edge is not available on any page: the section
   rail lives there, and at >=1280 --pad-l is widened specifically to clear it.
   A photograph run to x:0 paints straight over the rail — measured, the "03
   MACHINERY" mark sat behind the machine. The rail is the spine that carries
   the folio down the whole page, so the picture gives way, not the rail.
   .feature--bleed on its own therefore puts copy left and picture right; the
   --flip modifier is kept only for the mirrored ORDER, not for a left bleed. */
.feature--bleed{
  display:grid; align-items:center;
  grid-template-columns:minmax(0,1.06fr) minmax(0,1fr);
  gap:clamp(28px,3.6vw,64px);
  /* The half-gutter .container gets from max-width:1680 + margin-inline:auto.
     Without it the copy keeps only --pad-l and, past 1680, slides left of the
     page margin and straight through the section rail — measured at 1815,
     where the rail line cut through the running text. The percentage resolves
     against this element own width, which IS the viewport here, so no vw and
     therefore no scrollbar error. Left only: the picture still bleeds right. */
  padding-left:max(0px, calc((100% - 1680px) / 2));
}
.feature--bleed > .slot{ aspect-ratio:3/2; }
/* Copy first, on the container's own left edge so the page margin and the
   rail both survive; picture second, running off the right. */
/* THE PADDING IS INSIDE THE CAP, so `max-width:56ch` was never 56ch of text.
   Client, 2026-08-07: "tăng width cụm text trên hình" — measured on
   capabilities/machinery at 1440, the prose column was 475px, or about 42ch.
   box-sizing is border-box sitewide, so 56ch (633px) was the width of the
   padding PLUS the text, and --pad-l is clamp(156px,11vw,176px) above 1280 —
   the lane widened to clear the section rail. Two thirds of the declared
   measure was being spent on that lane, silently, on every page using this
   archetype. Adding --pad-l back makes the number mean what it says.
   This is the same trap the .step__facts measure note records: a measure token
   is a no-op wherever something else is already eating the box. Probe the
   rendered column, never trust the declaration.
   The cap is still not always what binds — the copy TRACK is 714px at 1440
   against this 791px cap, so the grid split decides above ~1500. That is left
   as it is deliberately: pushing the split further buys copy width out of the
   photograph's, and this archetype is on four pages that have all been signed
   off with the picture at its present size. */
.feature--bleed > .feature__body{ padding-left:var(--pad-l); max-width:calc(56ch + var(--pad-l)); order:-1; }
.feature--bleed > .slot{ order:0; }
@media (max-width:900px){
  .feature--bleed{ grid-template-columns:1fr; gap:clamp(20px,4vw,30px); }
  .feature--bleed > .feature__body{ padding-inline:var(--pad-l) var(--pad); max-width:none; order:0; }
}

/* Type ON the photograph, not beside it. The scrim is a gradient rather than
   a flat wash so the top of the frame stays a photograph; it only darkens the
   band the words actually occupy. */
.plate--over .slot::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(to top,
    rgba(1,34,61,.86) 0%, rgba(1,34,61,.52) 34%, rgba(1,34,61,.06) 68%, transparent 100%);
}
/* Exactly .band__body geometry — max-width, auto margins and the same
   padding — because this is the same composition as the homepage
   sustainability band and it has to land on the same grid. Free-floating at
   left:0 it drifted off the page margin on any viewport past 1680. */
.plate__copy{
  position:absolute; z-index:2; left:0; right:0; bottom:0;
  width:100%; max-width:1680px; margin-inline:auto;
  padding:clamp(44px,6vw,82px) var(--pad) clamp(46px,6.5vw,88px) var(--pad-l);
  color:var(--paper);
}
/* The 120px + gap indent that lived here is gone (client, 2026-08-06: "text
   phải nằm sát lề trái"). It was copied from .band__body, where column one
   holds a mono index — and nothing has ever been put in it here, so on both
   pages that use this variant the sentence started ~160px right of the page's
   own left edge, out of line with every heading above and below it. The copy
   now begins exactly on --pad-l, which IS .container's left edge, so the
   photograph's type sits on the same margin as the rest of the page.
   Restore the two-column form only WITH something to put in column one. */
/* Section scale, not page-title scale (2026-08-06, client). This line sits
   in the corner of a photograph mid-page: it introduces the section under
   it, so it takes .h-sec's step. It keeps the .h-display CLASS for the
   serif face and the reveal it is wired to — only the size comes down. */
.plate__copy .h-display{ max-width:17ch; font-size:clamp(28px,3.9vw,52px); }
/* --wide: the same corner, given a longer line (client, 2026-08-07, on
   sustainability). 17ch is ~573px at 1440 and broke that plate's sentence over
   THREE lines, which stacked the block up into the middle of the photograph.
   The number is measured, not chosen: ch is ~34px here (a serif zero at 52px),
   and 2 lines arrive at 20ch — 24ch is the first value that also lands the
   break on the comma, and it still clears the frame's right half at 1680.
   Capabilities and oem-odm keep 17ch; both were signed off at three lines. */
.plate--wide .plate__copy .h-display{ max-width:24ch; }
/* :not(.h-display) is load-bearing. .plate__copy p is 0,1,1 and .h-display
   is 0,1,0, so without it this rule wins on the headline and the one piece of
   display type on the page renders at lede size — which looks like the clamp
   failing rather than like a specificity collision. */
.plate__copy p:not(.h-display){
  margin:var(--g-group) 0 0; max-width:var(--measure-narrow);
  color:rgba(247,245,242,.82); font-size:var(--text-lede); line-height:var(--lh-body);
}
/* The caption variant and the copy variant are exclusive — a plate does one
   or the other, or the corner ends up with two things in it. */
.plate--over figcaption{ display:none; }

/* --- 3. the jump in scale --------------------------------------------
   One section per page may open at display size instead of section size.
   Scoped to a modifier rather than left to whoever is writing the markup,
   because "one" is the whole point: two of these on a page and the page has
   simply moved its baseline up, which is not a jump. */
/* The 78px size override that lived here is gone (2026-08-06, client):
   section titles across the site now hold the ONE base .h-sec scale, with
   the homepage as the reference — "loud" keeps its extra breathing room
   below, it just no longer shouts in a bigger voice. */
.sec--loud .sec-head{ padding-bottom:var(--g-section); }
/* No font-size here any more. This was a SECOND lede scale — 21.6px against
   the base lede's 17.28px at 1440, a 25% difference carried by the same
   class on the same kind of paragraph, so whether a lede was "big" depended
   on which section it happened to land in. "Loud" keeps its extra breathing
   room below the head; it no longer keeps its own type scale, for the same
   reason .sec--loud stopped setting its own .h-sec size. */
.sec--loud .lede{ max-width:46ch; }

/* --- 4. the sideways rail (removed) ----------------------------------
   The .rail-wrap/.rail scroller that lived here (added 2026-07-31) was
   oem-odm's workflow band and had exactly one consumer. That section is
   now the pinned .wf track in Layer O at the foot of this sheet
   (client, 2026-08-08), so the family was deleted rather than left as
   dead selectors. Git history holds it if a plain unpinned rail is ever
   wanted again. */

/* --- 5. the column that holds still ----------------------------------
   The third motion moment, for the four product-line pages: the photograph
   parks under the header while its specification scrolls past it. Same device
   as the sticky numeral and the same reason it is CSS only — position:sticky
   inside a grid area, bounded by the grid itself.

   This is why .ghost-mark clips with overflow-x:clip and never overflow:hidden.
   hidden would make every ghost-marked section a scroll container, and a
   sticky child sticks to its nearest SCROLLING ancestor — so the media column
   would pin to the section box instead of the viewport and simply not move. */
.split{
  display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1.04fr);
  gap:clamp(28px,3.6vw,64px);
  align-items:start;
}
.split__media{ position:sticky; top:calc(var(--hdr-h) + clamp(18px,4vh,54px)); }
.split__media > .slot{ aspect-ratio:4/5; }
/* A caption UNDER a photograph, wherever one is needed. Generalised out of
   .split__media on 2026-07-31 for an honesty reason rather than a visual one.
   The client's photography is overwhelmingly of the leather line, and four of
   the product-line pages need process pictures. A boot being lasted, printed
   on a page headed High Heels, implies it is a heel being lasted — and alt
   text does not undo that, because the reader who is misled never reads it.
   A visible "Pictured: the leather line" does. So the caption is a component,
   not a decoration, and .feature__fig exists to give .feature somewhere to
   put one. */
.figcap,
.split__media figcaption{
  display:block; margin:var(--g-pair) 0 0;
  font-family:var(--f-mono); font-weight:500; font-size:10.5px;
  letter-spacing:.16em; text-transform:uppercase; color:var(--ink-soft);
  max-width:44ch;
}
.on-dark .figcap,
.on-dark .split__media figcaption{ color:rgba(247,245,242,.72); }
.feature__fig{ margin:0; }
.feature__fig > .slot{ aspect-ratio:3/2; }
.feature--tall .feature__fig > .slot{ aspect-ratio:4/5; }
.feature--flip > .feature__fig{ order:2; }
@media (max-width:900px){
  .feature--flip > .feature__fig{ order:0; }
}
.split__body > * + *{ margin-top:var(--g-block); }
/* The form variant. .form is capped at 640px on purpose — a full-width text
   input is a worse target, not a better one — which on a 1680 container left
   half the section empty. This puts the reach-us card in that space and makes
   it hold under the header while the form scrolls, so the phone number is
   still on screen at the bottom of a nine-field form. Source order is
   form-then-card, so the column order is too. */
.split--form{ grid-template-columns:minmax(0,1.55fr) minmax(0,1fr); }
.split--form .split__media{ padding-top:6px; }
@media (max-width:900px){
  .split{ grid-template-columns:1fr; gap:var(--g-block); }
  .split__media{ position:static; }
}
@media (prefers-reduced-motion:reduce){
  .split__media{ position:static; }
}

/* --- 6. stepline, keyed by year --------------------------------------
   The numeral column is sized for "01", and a four-digit year at 46px is
   about 110px of mono — exactly the column's own maximum, so it wrapped. This
   widens the column and steps the size down rather than letting a date break
   across two lines, which is the one thing a timeline may not do. */
.stepline--years .step{ grid-template-columns:clamp(76px,9vw,140px) minmax(0,1fr); }
.stepline--years .step__n{ font-size:clamp(20px,2.2vw,32px); letter-spacing:-.01em; }
/* No :nth-child reset needed: the three-column rules are gated on
   :has(.step__img) and a year list has no photographs, so it never picks them
   up. This modifier now only widens the numeral column. */
@media (max-width:640px){
  .stepline--years .step{ grid-template-columns:1fr; }
}

/* --- 7. the two effects the sub-pages were missing --------------------
   Added 2026-07-31. Both are the site's existing idioms applied where the
   rebuild created new uses for them, not new devices.

   (a) THE SWEEP, ON LINKED .marks CELLS. The cross-links at the foot of the
   four product-line pages, and the indexes on 404 and thank-you, are real
   links drawn as ruled cells — and they were the only links on the site with
   no hover at all. The rule is scoped with :has(a) for exactly the reason
   a.cap-row is scoped to the anchor: About's memberships and Certifications'
   memberships use the same component as STATIC cells, and a sweep under
   something that goes nowhere is a promise the page cannot keep.

   The sweep lands on the cell's BOTTOM edge, which is where every hairline
   sweep on this site moved to; the heading takes the colour of the rule that
   just drew under it plus --w-hover, which is the one hover system the whole
   page follows. */
.marks li{ position:relative; }
.marks li:has(a)::before{
  content:""; position:absolute; left:0; bottom:-1px; height:2px; width:100%;
  background:var(--navy); transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
.on-dark .marks li:has(a)::before{ background:var(--accent); }
.marks li:has(a):hover::before,
.marks li:has(a:focus-visible)::before{ transform:scaleX(1) scaleY(2); }
.marks li:has(a) b{ transition:color .5s var(--ease), font-weight .5s var(--ease); }
.marks li:has(a):hover b{ color:var(--navy); font-weight:var(--w-hover); }
.on-dark .marks li:has(a):hover b{ color:var(--accent); }

/* (b) SPEC ROWS ARRIVE IN ORDER. .spec-wrap took data-aos="fade-up" and the
   whole table appeared as one slab — on a seven-row table that is the largest
   single object on the page moving at once. "cells" could not help: it
   staggers direct children, and a table's only children are <thead>/<tbody>.

   Same transition and the same ladder as "cells", read off the rows instead.
   The observer needs no change; it only ever adds .is-in. */
[data-aos="rows"] tbody tr{
  opacity:0; transform:translateY(14px);
  transition:opacity .7s var(--ease), transform .7s var(--ease);
}
[data-aos="rows"].is-in tbody tr{ opacity:1; transform:none; }
[data-aos="rows"].is-in tbody tr:nth-child(2){ transition-delay:.06s }
[data-aos="rows"].is-in tbody tr:nth-child(3){ transition-delay:.12s }
[data-aos="rows"].is-in tbody tr:nth-child(4){ transition-delay:.18s }
[data-aos="rows"].is-in tbody tr:nth-child(5){ transition-delay:.24s }
[data-aos="rows"].is-in tbody tr:nth-child(6){ transition-delay:.30s }
[data-aos="rows"].is-in tbody tr:nth-child(7){ transition-delay:.36s }
[data-aos="rows"].is-in tbody tr:nth-child(n+8){ transition-delay:.42s }
@media (prefers-reduced-motion:reduce){
  [data-aos="rows"] tbody tr{ opacity:1; transform:none; transition:none; }
  .marks li:has(a)::before{ transition:none; }
}

/* The cell IS the link, so the anchor inside .marks b is plain text and the
   ruled sweep above is the whole affordance. It used to carry .link-quiet,
   which on this site is a pill button — six pills in a six-cell ruled grid
   read as a toolbar rather than as an index, and at 404's column width the
   longest label ("Certifications") was clipped inside its own pill. A filled
   control inside a hairline cell was also the one thing the ruled-not-boxed
   rule exists to prevent. */
.marks b a{
  color:inherit; text-decoration:none;
  display:inline-flex; align-items:center; gap:.42em;
}
.marks b a:focus-visible{ outline:2px solid var(--navy); outline-offset:4px; }
.on-dark .marks b a:focus-visible{ outline-color:var(--accent); }
/* The arrow travels with the cell's sweep, the same distance every other
   hovered row on this site moves its own. */
.marks b .arrow{
  width:.72em; height:.72em; flex:none;
  background:currentColor;
  -webkit-mask:var(--arrow) center/contain no-repeat;
          mask:var(--arrow) center/contain no-repeat;
  transition:transform .5s var(--ease);
}
.marks li:has(a):hover .arrow{ transform:translateX(5px); }
@media (prefers-reduced-motion:reduce){
  .marks b .arrow{ transition:none; }
}

/* ============================================================
   LAYER E — PAGE VARIETY
   Added 2026-07-31. The ambition layer above gave the sub-pages three devices
   the homepage had; this one answers a different complaint, and one that only
   shows up when you read the pages in a row rather than one at a time.

   Measured before writing any of it: 78 of the 99 section headings across the
   18 sub-pages were the same construction — `Line one.<br><span class=
   "accent">Line two.</span>` with data-aos="mask" — and all 18 heroes were
   byte-identical, `<section class="page-hero on-dark">` with no modifier
   anywhere in the codebase. Every page was individually fine. Read third in a
   row, the site had one idea and repeated it.

   WHY THIS BLOCK IS APPENDED RATHER THAN INTERLEAVED
   Several rules here beat an earlier rule at EQUAL specificity and win only
   because they are declared later — .sec-head--beside's `margin-top:0` against
   .sec-head .h-sec + .lede, and .page-hero--figure::before's opacity against
   .page-hero::before. Move this block up the sheet and those silently invert.
   Where that is the mechanism, the rule says so at the point of use.

   Rules for adding here, inherited from the layers above:
   · Only token values from :root. A new hex is a bug.
   · transform/opacity only, per BUILD-BRIEF's motion budget.
   · Nothing lands here before a page emits the markup for it.
   ============================================================ */

/* --- E1. HEADING TREATMENTS ------------------------------------------
   Four departures from the default, applied as modifiers on .sec-head rather
   than on .h-sec — the point is to change the composition the heading sits in,
   not just its size. The default stays as the home key, so the variants read
   as departures rather than as an absent system.

   Which section gets which is decided by the folio number it already carries,
   so there is no judgement call and the result is greppable:

     01 --beside · 02 (default + .accent on one word) · 03 --wide
     04 default  · 05 --figure · 06 --beside · 07 --wide

   Interstitials with no .idx (.statement, .plate, the CTA close) are skipped —
   they already break the rhythm by being a different kind of object.

   There is deliberately NO CSS for the 02 treatment. Moving the accent span
   from the whole second clause onto its last noun phrase needs no new rule:
   .h-sec .accent and .on-dark .h-sec .accent already exist. */

/* (a) --wide: the headline claims the full container and the lede drops into
   the right-hand track beneath it. This is where the empty right 45% goes on
   the sections that were widest open. Markup change is deleting the <br>. */
/* THE ROW GAP HAS TO GO WITH IT. display:contents does not just unwrap the
   div — it promotes the headline and the lede into GRID ITEMS on two
   different rows, and .sec-head's `gap` is a shorthand, both axes. So the
   column gap written for the folio track starts firing between a heading and
   its own second half, on top of whatever margin-top the lede was given.
   Measured on about.html at 1440px: 43.2px of row gap under a 34px margin =
   81px of dead navy on #vision, and 105px on the quiet heads. Zeroing it
   makes the declared margin the WHOLE gap, which is what every rule that
   sets that margin already assumed it was.
   The kicker keeps the old value as a margin of its own: below 1280px it is
   a genuine row above the headline, and that separation was never the part
   that looked wrong. Above 1280px the rail carries the folio and the kicker
   is display:none, which claims no row and no margin either. */
.sec-head--wide{ row-gap:0; }
.sec-head--wide > div{ display:contents; }
.sec-head--wide > .idx{ grid-column:1; margin-bottom:clamp(16px,3vw,56px); }
.sec-head--wide .h-sec{
  grid-column:1 / -1;
  /* The 86px override is gone (2026-08-06, client): one base .h-sec scale
     sitewide, homepage as the reference. --wide still owns the full-row
     grid placement above — that is its remaining job. */
}
/* NO margin-top HERE, deliberately. This carried its own value through two
   rounds of "the gap looks too big" — --g-block, then --g-group — and both
   times the answer was another number nobody could check against anything.
   The base `.sec-head .h-sec + .lede` sets --g-pair for every head on the
   site; --wide's headline is the same 52px .h-sec as a plain head's since
   the 86px override went, so there is nothing left for it to compensate for.
   Falling through to the base rule is what makes the two read alike.
   Anything added back here has to beat the base on declaration order, which
   is why the selector keeps the three-class adjacent-sibling shape. */
.sec-head--wide .h-sec + .lede{
  grid-column:1 / -1; max-width:64ch;
}

/* (b) --beside: heading left, lede right, last lines sitting on a shared
   baseline. The only treatment that needs no markup change at all — the <br>
   and the accent span stay exactly as shipped.

   THE HAIRLINE IS GONE (2026-08-07, client: "bỏ line"). It was the third
   vertical line the eye could meet in one head — rail, folio tick, divider —
   which is the same complaint that took the long folio connector out further
   up this sheet. The gutter it stood in is UNCHANGED: the grid gap and the
   lede's padding both stay, so the two columns keep the separation they were
   composed with and only the stroke is missing.

   CANNED TO THE BOTTOM, NOT THE TOP. Top-aligned, a two-line heading and a
   three-line lede shared a ceiling and then ended 28px apart, so the pair read
   as two unrelated blocks that happened to start together. `last baseline`
   makes the LAST line of each sit on one line — not `end`, which aligns the
   two border boxes and leaves the lede ~6px low, because a 1.66 body leading
   carries less half-leading below its final baseline than a 1.08 display one.
   Where a browser cannot resolve last-baseline alignment the spec's own
   fallback is end alignment, which is that same 6px — degraded, not broken. */
.sec-head--beside{
  grid-template-columns:minmax(0,120px) minmax(0,1.06fr) minmax(0,.94fr);
}
.sec-head--beside > div{ display:contents; }
.sec-head--beside > .idx{ grid-column:1; }
.sec-head--beside .h-sec{ grid-column:2; }
.sec-head--beside .h-sec + .lede{
  grid-column:3; margin-top:0;
  padding-left:clamp(24px,3vw,52px);
}
/* Both halves have to opt in: baseline alignment is a GROUP, and an item left
   on the parent's `align-items:start` simply is not in it. The folio in column
   1 is deliberately left out — it is a caption to the whole head and stays
   pinned to the top edge. */
.sec-head--beside .h-sec,
.sec-head--beside .h-sec + .lede{ align-self:last baseline; }

/* THE TRAP THIS BLOCK EXISTS FOR.
   display:contents dissolves the wrapper <div>, so `.rails-on .sec-head > div
   { grid-column:1 }` in the 1280 block stops matching anything — its target is
   no longer a grid item. The h2 and the lede are the grid items now, and they
   inherit the column numbers set above against a grid that .rails-on has just
   collapsed to ONE column. Without the counterparts below, every --wide lede
   lands in a phantom column 2 the moment the rails come on, and it does so
   only at >=1280 with .rails-on, which is not the width anyone checks first. */
@media (min-width:1280px){
  /* NOT `grid-column:1`. That was the first fix for the phantom column and it
     traded one bug for the thing this whole layer exists to remove: with the
     rails on, .sec-head collapses to a single column, so a lede sent to
     column 1 sits hard left under a full-width headline and leaves the right
     45% empty — the exact shape the default head already had. Giving --wide
     its own two tracks keeps the headline spanning both and drops the lede
     into the right one, which is what the treatment is for. */
  .rails-on .sec-head--wide{ grid-template-columns:minmax(0,1fr) minmax(0,1fr); }
  .rails-on .sec-head--wide .h-sec{ grid-column:1 / -1; }
  .rails-on .sec-head--wide .h-sec + .lede{ grid-column:1 / -1; }
  .rails-on .sec-head--beside{
    grid-template-columns:minmax(0,1.06fr) minmax(0,.94fr);
  }
  .rails-on .sec-head--beside .h-sec{ grid-column:1; }
  .rails-on .sec-head--beside .h-sec + .lede{ grid-column:2; }
}
@media (max-width:900px){
  /* Once the lede drops under the headline, this IS a --wide head, so it gets
     --wide's numbers. Same defect as that treatment had and for the same
     reason: display:contents means .sec-head's row gap lands between the two
     boxes, so --g-block on top of it measured 48px here against the plain
     head's 14px. Zeroed and stepped down together — the gap is only ever what
     the margin says. Safe to scope the row gap to this query: above 900px the
     pair is side by side in one row and the COLUMN gap, which this does not
     touch, is the whole gutter.
     The margin has to be RESTATED rather than dropped: the desktop rule sets
     it to 0 for the side-by-side arrangement, so there is nothing to fall
     back to here. --g-pair is the base head's value, spelled out. */
  .sec-head--beside{ grid-template-columns:minmax(0,120px) minmax(0,1fr); row-gap:0; }
  .sec-head--beside .h-sec{ grid-column:2; }
  .sec-head--beside .h-sec + .lede{
    grid-column:2; margin-top:var(--g-pair);
    padding-left:0;
  }
  /* Stacked, so there is no second column to share a baseline with. Put back
     on the parent's `start` explicitly: the headline is now in the folio's row
     and last-baseline alignment there would drop it to the bottom of whichever
     of the two is taller. That is the folio only in pathological wrapping, but
     the arrangement this reset describes is the one that is actually on screen
     below 900px, and it should not depend on a kicker staying one line. */
  .sec-head--beside .h-sec,
  .sec-head--beside .h-sec + .lede{ align-self:start; }
  .rails-on .sec-head--beside{ grid-template-columns:minmax(0,1fr); }
  .rails-on .sec-head--beside .h-sec,
  .rails-on .sec-head--beside .h-sec + .lede{ grid-column:1; }
}

/* (c) --figure: an oversized numeral beside the heading.
   A SEPARATE aria-hidden element, NOT a promoted .idx. `.rails-on .sec-head >
   .idx{ display:none }` hides the folio entirely at >=1280 — precisely the
   viewports where this numeral should be loudest. Sidestepping the rail system
   also keeps the screen-reader folio single instead of announcing it twice. */
.sec-head--figure > div{
  display:grid; grid-template-columns:auto minmax(0,1fr);
  gap:0 clamp(18px,2.4vw,40px); align-items:start;
}
.sec-head__fig{
  grid-row:1 / span 2;
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(56px,9vw,132px); line-height:.82; letter-spacing:-.04em;
  color:var(--accent);
}
/* --figure's 40px down-size is gone with the loud/wide up-sizes
   (2026-08-06, client): one base .h-sec scale sitewide. The ghost numeral
   behind stays at its own display size — it is a ground device, not type. */
@media (max-width:640px){
  .sec-head--figure > div{ grid-template-columns:minmax(0,1fr); }
  .sec-head__fig{ grid-row:auto; font-size:clamp(44px,13vw,64px); margin-bottom:var(--g-pair); }
}

/* --- E2. HERO VARIANTS -----------------------------------------------
   All four reuse .page-hero, keep .crumb / .page-head and the `rise` stagger,
   and every one of them fills the right column the default hero left empty.

   Which family takes which is a photography decision more than a layout one:
   --figure where there is process photography to spare, --split where there is
   one strong portrait, --type where there is none and the content IS facts. */

/* (a) --figure: a full-bleed photograph under a navy gradient.
   .slot needs no aspect-ratio here — position:absolute; inset:0 gives the
   frame its height, the same way .band .slot does. This is the one hero
   variant where the slot-needs-an-aspect-ratio rule does not bite. */
.page-hero--figure{
  min-height:clamp(520px,74svh,720px);
  display:grid; align-items:end;
}
.page-hero--figure .hero-media{ position:absolute; inset:0; z-index:0; }
.page-hero--figure .hero-media > .slot{ position:absolute; inset:0; }
/* Dark at BOTH ends, light through the middle. The bottom is the obvious half
   — it is what the headline and deck sit on. The top is the half that got
   missed on the first pass: the header is fixed over the first ~85px of this
   band in its transparent-over-hero state, which was designed against a navy
   hero. Over a lit factory photograph at .18 the Enquire pill and MENU went
   effectively invisible, and it read as a rendering fault rather than as a
   contrast problem. .62 at the top puts the chrome back on a ground it was
   drawn for; the photograph still opens up through the middle. */
.page-hero--figure .hero-media::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(to top,
    rgba(1,34,61,.90) 0%, rgba(1,34,61,.62) 34%,
    rgba(1,34,61,.28) 66%, rgba(1,34,61,.40) 86%,
    rgba(1,34,61,.66) 100%);
}
/* The radial wash steps back under a photograph. Equal specificity with
   .page-hero::before — this wins because Layer E is appended, see the header. */
.page-hero--figure::before{ opacity:.24; }
/* Scale on the .slot, translate on the <picture>: the parallax block writes an
   inline transform onto picture (site.js), so the two clocks never share an
   element and neither silently overwrites the other. */
@keyframes heroScale{ from{ transform:scale(1.06) } to{ transform:none } }
.page-hero--figure .hero-media > .slot{ animation:heroScale 900ms var(--ease) both; }

/* (b) --split: type left, a portrait bleeding off the right edge.
   padding-right:0 on the container IS the bleed. Right only — .sec-rail owns
   the left edge, which is why --pad-l widens to clamp(156px,11vw,176px) at
   >=1280 in the first place. */
.page-hero--split > .container{
  display:grid; grid-template-columns:minmax(0,1fr) minmax(0,.82fr);
  gap:clamp(32px,4.4vw,72px); align-items:end;
  padding-right:0;
}
.page-hero--split .crumb,
.page-hero--split .page-head{ grid-column:1; }
.page-hero--split .hero-media{ grid-column:2; grid-row:1 / span 2; }
/* REQUIRED. .slot is a fill frame with no intrinsic size; without this the
   photograph renders as nothing at all and does it silently. */
.page-hero--split .hero-media > .slot{ aspect-ratio:4/5; }
@keyframes heroWipe{
  from{ clip-path:inset(100% 0 0 0); transform:scale(1.04) }
  to{ clip-path:inset(0 0 0 0); transform:none }
}
.page-hero--split .hero-media > .slot{ animation:heroWipe 900ms var(--ease) 240ms both; }
@media (max-width:900px){
  .page-hero--split > .container{
    grid-template-columns:minmax(0,1fr); padding-right:var(--pad);
  }
  .page-hero--split .hero-media{ grid-column:1; grid-row:auto; margin-top:var(--g-block); }
  .page-hero--split .hero-media > .slot{ aspect-ratio:16/10; }
}

/* (c) --type: no photography. The empty space is closed with type and a ruled
   facts strip that reuses .step__facts verbatim, so a page with two images on
   it still opens on something composed.

   The 108px display override that used to sit here is gone (2026-08-06,
   client): --type pages ran their H1 at a larger scale than the legal and
   news-article pages, and the mismatch read as an error once the site had
   all three families side by side. Every sub-page H1 now renders at the one
   base .h-display scale; only the grid layout below remains --type's own. */
@media (min-width:1080px){
  .page-hero--type .page-head > div{
    display:grid; grid-template-columns:minmax(0,1fr) minmax(0,.72fr);
    gap:clamp(28px,3.6vw,64px); align-items:end;
  }
  .page-hero--type .h-display{ grid-column:1 / -1; }
  .page-hero--type .h-display + .lede{ grid-column:2; margin-top:var(--g-block); }
}
.hero-facts{
  grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
  gap:0 clamp(20px,2.6vw,44px); max-width:none;
  margin-top:clamp(34px,4.6vw,66px);
}
.hero-facts li{ grid-template-columns:1fr; gap:6px; padding-top:14px; }
.page-hero .hero-facts{ animation:rise 700ms var(--ease) 600ms both; }

/* (d) --index: the hub hero. The right column carries the page's own contents
   as a one-column .marks list, which already owns the hover sweep, the arrow
   travel and the on-dark variants — the index is genuinely useful on the
   products hub and nowhere else, which is why this variant has one consumer. */
.page-hero--index > .container{
  display:grid; grid-template-columns:minmax(0,1fr) minmax(0,.78fr);
  gap:clamp(32px,4.4vw,72px); align-items:start;
}
.page-hero--index .crumb,
.page-hero--index .page-head{ grid-column:1; }
.hero-index{ grid-column:2; grid-row:1 / span 2; grid-template-columns:1fr; }
.hero-index li + li{ border-left:0; padding-left:0; }
.page-hero .hero-index{ animation:rise 700ms var(--ease) 560ms both; }
@media (max-width:900px){
  .page-hero--index > .container{ grid-template-columns:minmax(0,1fr); }
  .hero-index{ grid-column:1; grid-row:auto; margin-top:var(--g-block); }
}

/* THE HIGHEST-RISK EDIT IN THIS LAYER.
   .no-entrance is a hard-coded selector list, not a general rule: site.js sets
   it when the document loads hidden, because a background tab freezes the
   document timeline at currentTime 0 and `both` holds the from-state forever.
   Any animated hero child not named here renders as nothing until the tab is
   focused — the frozen-timeline bug the original guard was written for.

   clip-path:none is NEW and the reset above does not carry it. --split starts
   from inset(100% 0 0 0); without this line a background-tab --split hero is a
   navy rectangle where the portrait should be, and it looks deliberate. */
.no-entrance .page-hero .hero-media > .slot,
.no-entrance .page-hero .hero-facts,
.no-entrance .page-hero .hero-index{
  animation:none !important; opacity:1 !important;
  transform:none !important; clip-path:none !important;
}

/* --- E3. THE TWO RHYTHM BREAKS ---------------------------------------
   One per page, never both: the rule for this layer is that a page has exactly
   one .sec--loud and exactly one full-viewport moment. Two of either is not a
   jump, it is a raised baseline. */

/* (a) --full: the photograph that takes the screen.
   92svh and not 100, for the reason .page-hero already records one screen up —
   a picture that exactly fills the viewport with nothing peeking below it
   reads as a page that stopped loading. The next section's top edge staying
   visible is what makes this read as a held breath. */
.plate--full > .slot{
  aspect-ratio:auto; min-height:min(92svh,900px);
}
.plate--full figcaption{ bottom:clamp(24px,3vw,40px); }

/* (b) --full statement: the scale jump for the pages with no photography to
   spend on one. */
/* UNUSED BY EVERY PAGE as of 2026-08-07: its only three carriers were the
   high-heels, sports and slippers claim bands, and the client deleted the claim
   band from all four product line pages that day. Kept rather than removed
   because this is a modifier on .p-claim, which is still live on seven pages —
   unlike .form--tight, which went with the viewer form it existed to dress. If
   a page wants the scale jump back, the modifier is here and works. */
.statement--full{
  min-height:78svh; display:grid; align-content:center;
}
.statement--full .statement{
  /* The section-heading scale, not a scale of its own (2026-08-06, client:
     match the homepage's section titles). A statement IS a section here —
     it fills one — so it belongs on the same step as every .h-sec rather
     than floating between the section titles and the page H1, which is
     what 68px did. The height it needs comes from --full's min-height and
     the 20ch measure below, not from the type size. */
  font-size:clamp(28px,3.9vw,52px); line-height:1.16; max-width:20ch;
  /* Weight follows the size, per the note .statement carries about its 400:
     at section-heading size it takes the section heading's 500. */
  font-weight:500;
}
.statement--full .statement-rule{ width:96px; }

/* --- p-claim: the statement band, composed ---------------------------
   Thirteen sub-pages carry exactly one of these, and until now .p-claim was a
   marker class with no rules behind it: a full section's padding wrapped
   around one flush-left sentence and nothing else. On about, where the
   approved line is five words, that is ~70px of ink inside a 292px band with
   more than half the container empty beside it. The client read it as a hole
   in the page rather than as a held beat, and they were right to.

   Air was only half of it. A single column of text inside a two-column
   measure reads as an unfinished layout however much space is around it, so
   the fix here is compositional rather than decorative — nothing is added to
   fill the band, the pieces already in the markup are given positions:

     col 1   the quote, stepped up to the section-heading tier
     ————    a hairline divider, the one this site uses everywhere else
     col 2   the gold rule, then the citation under it

   No new copy. The meta column is the <hr> and the <span> that were already
   there; the rule stops being a stray dash in the middle of the band and
   becomes the head of a column. */
.p-claim > .container{
  /* Both custom properties are read twice — once by the grid template and
     once by the divider's offset. Hard-coding either would let the hairline
     drift off the column boundary the first time the other one changed. */
  --claim-meta:clamp(180px,17vw,290px);
  --claim-gap:clamp(28px,4.4vw,72px);
  position:relative;
  display:grid; align-items:start;
  grid-template-columns:minmax(0,1fr) var(--claim-meta);
  /* 1fr on the second row is doing real work. The quote spans both rows, and
     a grid distributes a spanning item's height across every INTRINSIC track
     it covers — so with two auto rows the quote pushed row 1 open and the
     citation drifted a hundred pixels below its own rule. A flexible track
     absorbs the span instead, which pins row 1 to the rule's own height. */
  grid-template-rows:auto 1fr;
  column-gap:var(--claim-gap);
}
/* The divider is a pseudo-element and not a border on the meta items because
   the meta column is two separate grid items with a gap between them; a border
   on each would draw the line twice with a hole in it. */
.p-claim > .container::before{
  content:""; position:absolute; top:.1em; bottom:.1em;
  right:calc(var(--pad) + var(--claim-meta) + var(--claim-gap) / 2);
  width:1px; background:var(--line);
}
.on-dark .p-claim > .container::before{ background:var(--line-light); }

.p-claim > .container > .statement,
.p-claim > .container > .claim-stack{ grid-column:1; grid-row:1 / span 2; }

/* The head rule. One line crosses the whole container: grey over the quote,
   and the gold rule continues it over the meta column. Both halves reach the
   vertical divider by eating their side of the column gap, so the junction is
   exactly where the divider drops — the band gets a top edge and the gold
   stops reading as a fragment.
   The half-pixel is real: the grey is 1px and the gold is 2px, so centring
   one on the other is an odd offset, and without it the join steps. */
.p-claim > .container::after{
  content:""; grid-column:1; grid-row:1; align-self:start;
  height:1px; background:var(--line);
  margin:calc(.34em + .5px) calc(var(--claim-gap) / -2) 0 0;
}
.on-dark .p-claim > .container::after{ background:var(--line-light); }

/* Full column width, not the 64/96px stub: at the head of a 290px column the
   rule is a masthead for the citation instead of a fragment of one. */
.p-claim > .container > .statement-rule{
  grid-column:2; grid-row:1;
  width:auto; margin:.34em 0 var(--g-group) calc(var(--claim-gap) / -2);
}
.p-claim > .container > .statement__cite{
  grid-column:2; grid-row:2; margin-top:0; line-height:1.9;
}

/* The section-heading tier, matching .statement--full. A statement that owns
   a whole band IS a section, and these were the last ones still set at the
   in-prose 36px — which is why the short ones looked like a caption that had
   lost its picture. The in-prose uses (about's vision/mission and the motto
   inside .split) keep 36px: they are not selected here. */
.p-claim > .container > .statement{
  font-size:clamp(28px,3.6vw,48px); font-weight:500;
  line-height:1.16; max-width:22ch;
}
.p-claim.statement--full > .container > .statement{
  font-size:clamp(28px,3.9vw,52px); max-width:20ch;
}
/* The stacked variant sits a step below the single statement — its presence
   comes from the repetition, not the size — but it was set on a 24ch measure
   that left the quote column two thirds empty before the divider. Wider
   measure, not bigger type. */
.p-claim > .container > .claim-stack .statement{
  font-size:clamp(24px,2.8vw,38px); font-weight:500;
  line-height:1.18; max-width:38ch;
  /* balance, inherited from .statement, is what kept this narrow: it evens
     the lines instead of filling them, which is right for a two-line display
     quote and wrong for a three-line one that has a column to fill. The three
     rows are the same string in the same box, so they still break alike. */
  text-wrap:pretty;
}

/* Contact and news carry no citation — the claim is the whole point of those
   two bands and nothing is being attributed. They keep the head rule and its
   gold terminal, which is what gives the band its width, and lose only the
   vertical divider: a divider needs something on both sides of it. */
.p-claim > .container:not(:has(.statement__cite))::before{ content:none; }

@media (max-width:900px){
  /* Stacked, and the order changes: the quote leads, the rule and the cite
     follow it as a footer. A narrow meta column beside a narrow quote column
     would give both of them a two-word measure. */
  .p-claim > .container{ grid-template-columns:minmax(0,1fr); row-gap:0; }
  .p-claim > .container::before{ content:none; }
  /* The head rule survives the stack — it is the band's top edge — but with
     the meta column gone there is no gap left to eat. */
  .p-claim > .container::after{ margin-right:0; }
  .p-claim > .container > .statement,
  .p-claim > .container > .claim-stack{ grid-column:1; grid-row:1; }
  .p-claim > .container > .statement-rule{
    grid-column:1; grid-row:2; width:96px; margin:var(--g-block) 0 var(--g-pair);
  }
  .p-claim > .container > .statement__cite{ grid-column:1; grid-row:3; }
}

/* The line that writes itself. Reuses the homepage's .reveal-line markup but
   re-gates it on .is-in instead of on load.

   animation:none is load-bearing. .reveal-line > span already carries
   `animation:riseIn .9s forwards`; without the override the reveal fires at
   load, off-screen, and the section is inert by the time anyone reaches it.

   The hidden state is overflow:hidden + translateY, deliberately NOT clip-path.
   Hiding with clip-path zeroes the element's intersection rect, so any observer
   threshold above 0 would never fire on it — that is why the shared observer
   runs at threshold 0, and this construct is built so it stays measurable even
   if that ever changes. */
[data-aos="lines"] .reveal-line > span{
  /* transform:none is not redundant with the .js rule below — it is the whole
     failsafe, and leaving it out is a bug that only shows up with JS dead.
     The BASE .reveal-line > span rule sets transform:translateY(105%)
     unconditionally and relies on its own riseIn animation to pull it back.
     Killing that animation here without restoring the transform parks the
     sentence outside its own overflow:hidden box permanently. Caught by
     measurement, not by reading: with the class toggled at runtime the
     transition animates it home and the state looks fine. */
  animation:none;
  transform:none;
  transition:transform .9s var(--ease);
}
.js [data-aos="lines"] .reveal-line > span{ transform:translateY(105%); }
[data-aos="lines"].is-in .reveal-line > span{ transform:none; }
[data-aos="lines"].is-in .reveal-line:nth-child(2) > span{ transition-delay:.10s }
[data-aos="lines"].is-in .reveal-line:nth-child(3) > span{ transition-delay:.20s }
[data-aos="lines"].is-in .reveal-line:nth-child(4) > span{ transition-delay:.30s }

/* --- E4. THE TWO SIGNATURES THAT NEEDED CSS --------------------------
   Of the five page families, two already had their moment built (about's
   sticky portrait is .split, the process pages' is .stepline--sticky) and
   needed only to be given it exclusively. These two are the new ones. */

/* (a) the counter wall — trust family.
   .figures at display scale on navy, each numeral carrying .js-count. The
   count-up block has existed in site.js since the homepage and runs its own
   observer with its own document.hidden guard; only three pages were using it.
   Three numbers counting up at 150px is the loudest thing a sub-page can do
   and it costs one class plus a data-to attribute per figure. */
.figures--wall{
  grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
  border-top:0;
}
.figures--wall > div{
  border-top:1px solid var(--line); padding-top:var(--g-group);
}
.on-dark .figures--wall > div{ border-top-color:var(--line-light); }
/* THE NUMERALS COLLIDED, and the cause is that the two terms tracked different
   things: font-size followed the VIEWPORT (11vw) while the column followed
   viewport / column-count. auto-fit changes that count as the page narrows, so
   the cell steps down while the type does not, and in the 4-column band the
   numeral simply ran into its neighbour.

   Measured on sustainability, "500+" against its own cell:
       1680  5col  ink 316 / cell 329   fits
       1440  4col  ink 316 / cell 279   OVER 38
       1280  4col  ink 297 / cell 244   OVER 53   <- worst
       1200  4col  ink 278 / cell 250   OVER 28
       1100  3col  ink 255 / cell 314   fits
   It never clipped and never grew the document — it overlapped the next grid
   cell — so an overflow probe, a clipped-text probe and a screenshot diff all
   stay silent. Only ink-vs-cell catches it.

   --fig-w is the numeral's cost in ink per px of font-size, measured per grid
   from its LONGEST string: "500+" is 2.11, "150,000/mo" is 4.45, and the bare
   "2" is 0.32. Dividing the cell by it gives the largest size that still fits.
   One cap for the whole grid, not per cell, because every figure on this site
   is one size — see the stat-label rule. Raising a figure to a longer string
   means re-measuring this number. */
.figures--wall > div > b{
  font-size:min(clamp(64px,11vw,150px), calc(100cqw / var(--fig-w,2.2)));
  letter-spacing:-.05em;
}
/* markets' wall carries "150,000/mo" — 4.45, more than twice the cost of any
   other figure on the site. Capping alone would drop every numeral in that row
   to ~61px and stop it being a wall at all, so the row takes wider columns
   instead: two across rather than four, which buys the cell enough width to
   keep the type near its ceiling. */
.figures--wide{
  /* min(430px,100%), never a bare 430px. A minmax() floor is a HARD minimum:
     below a 430px container the track stays 430 and the cell runs straight out
     of the grid. Measured at 320 before the min() went in — grid clientWidth
     280, scrollWidth 430, the cell spanning 20..450 against a 300px right
     edge, clipped by an ancestor rather than scrolling, so the document
     reported no overflow at all. */
  grid-template-columns:minmax(0,1fr);
}
/* ONE ROW OF FOUR from 900 up — client 2026-08-14, "đổi thành 1 hàng", once
   the long numeral was abbreviated. auto-fit on a 230px floor was deciding the
   count by arithmetic and getting a different answer at nearly every width:
   THREE at 900-1024 (so four figures broke 3 + 1), four at 1280-1490, five
   tracks at 1680. Both pages carrying this row hold exactly four figures, and
   four is the composition — not something to re-derive from a floor.

   FOUR is a DESKTOP row, so it starts at 1280 and not at 900. Client, tablet
   landscape (~1180): every prominent figure row reads 2x2 there. Between 821
   and 1279 the row is two across — which is also what .figures already does in
   that band, so the wall stops being the one figure row with its own count.

   Below 821 it stays single-file: four across a phone gives each figure about
   80px of cell, which is a column, not a row. */
@media (min-width:821px) and (max-width:1279.98px){
  .figures--wall{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (min-width:1280px){
  .figures--wall{ grid-template-columns:repeat(4,minmax(0,1fr)); }
}
/* Plain .figures always closed a section, so it never needed a bottom gap.
   The wall sits mid-section on markets with a .feature under it, and at 150px
   the numerals need room the way a photograph does — without this the last
   cell's caption runs straight into the next block's top edge. */
.figures--wall + *{ margin-top:var(--g-section); }
/* Sustainability 01 only (client, 2026-08-07: "bỏ line trên con số"). The
   sentence under each label went with it in the same pass, so the cell is now
   numeral + label and there is nothing above the numeral for a rule to divide
   — it was fencing four figures that open the block on their own, exactly as
   base .figures was unruled earlier. Unruled PER BLOCK, not at .figures--wall:
   careers, capabilities and markets carry the same wall and keep their rule.
   .p-people is unique to sustainability.html. The padding goes with the border
   or it is orphan space the grid gap has already paid for. */
.p-people .figures--wall > div{ border-top:0; padding-top:0; }

/* (b) the frame ladder — products family.
   Three portraits on staggered baselines, each on a DIFFERENT parallax rate,
   so they visibly slide past one another on scroll. This is the site's first
   relative motion between neighbouring elements and it rides the existing
   parallax block entirely — the only requirement is .slot--px and a data-px.
   Rates are 0.04 / 0.08 / 0.06, inside the brief's 8% cap.

   Judge this one by d(offset)/d(scrollY), not by where the frames end up: the
   endpoints look right in almost any implementation and tell you nothing. */
.gallery--ladder{
  display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
  gap:clamp(14px,1.8vw,30px); align-items:start;
}
/* REQUIRED, as everywhere .slot is not absolutely positioned. */
.gallery--ladder .slot{ aspect-ratio:4/5; }
.gallery--ladder > :nth-child(2){ margin-top:clamp(28px,6vw,96px); }
.gallery--ladder > :nth-child(3){ margin-top:clamp(12px,2.4vw,40px); }
@media (max-width:820px){
  .gallery--ladder{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .gallery--ladder > :nth-child(3){ margin-top:0; }
}

/* --- E5. HOVER -------------------------------------------------------
   Two shared rules. What does NOT get one is the deliberate part: .spec rows
   are not links, and a sweep under something that goes nowhere is a promise
   the page cannot keep — the same argument .marks li:has(a) already makes.
   .plate is a breath and .statement is a sentence; neither is a control. */

/* (a) the ruled-row sweep, lifted out of .marks so any ruled row that IS a
   link can take it. */
.hov-sweep{ position:relative; }
.hov-sweep::before{
  content:""; position:absolute; left:0; bottom:-1px; height:2px; width:100%;
  background:var(--navy); transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
.on-dark .hov-sweep::before{ background:var(--accent); }
.hov-sweep:hover::before,
.hov-sweep:has(:focus-visible)::before{ transform:scaleX(1) scaleY(2); }

/* (b) the frame hover.
   Scales the <img>, NEVER the <picture>. The parallax block writes an inline
   transform onto picture; a CSS transform on the same element loses to it and
   the hover silently does nothing on every .slot--px frame — which is most of
   them. This one selector choice is the whole trick. */
.hov-lift .slot > picture > img{ transition:transform .48s var(--ease); }
.hov-lift:hover .slot > picture > img,
.hov-lift:focus-within .slot > picture > img{ transform:scale(1.04); }

@media (prefers-reduced-motion:reduce){
  .hov-sweep::before{ transition:none; }
  .hov-lift .slot > picture > img{ transition:none; }
  .hov-lift:hover .slot > picture > img,
  .hov-lift:focus-within .slot > picture > img{ transform:none; }
  [data-aos="lines"] .reveal-line > span{ transform:none; transition:none; }
}

/* --- E6. THE CERTIFICATION DISCLAIMER --------------------------------
   Added 2026-07-31. The distinction it carries — GRS is a certification and
   issues a certificate; amfori BSCI, SMETA 4-Pillar and SCAN are audit
   programmes and issue none — is legally load-bearing, and it existed only as
   ordinary lede text on certifications.html. Six other pages listed all four
   programmes together with no disclaimer at all.

   Named, so it can be found and so it cannot be deleted by accident in a copy
   edit. Contrast is the point of the colour choice: --ink-soft on paper and
   .78 paper on navy both clear 4.5:1, and this is the one note on the site
   that must stay legible when everything around it is being made quieter. */
.cert-note{
  display:block;
  margin:var(--g-group) 0 0;
  padding-left:clamp(14px,1.4vw,20px);
  border-left:2px solid var(--accent);
  font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); max-width:64ch; text-wrap:pretty;
}
.on-dark .cert-note{ color:rgba(247,245,242,.78); }
/* When it is the page's own lede it keeps lede size — the rule above is for
   the short form that rides under a list of the four programmes elsewhere. */
.lede.cert-note{ font-size:var(--text-lede); max-width:var(--measure-body); }
/* And when that lede is also a --beside head's right-hand column, its gold mark
   stands in the exact slot the head's own hairline used to occupy. The hairline
   came out sitewide on 2026-08-07; leaving this one behind would read as a
   divider the client asked to be rid of rather than as the emphasis device it
   is elsewhere. One occurrence — certifications.html #difference. The mark is
   untouched in the four places it rides under a list, which is its real job.
   To put it back, delete this rule; nothing else references it. */
.sec-head--beside .h-sec + .lede.cert-note{
  border-left:0; padding-left:clamp(24px,3vw,52px);
}

/* --- E7. REVEAL PACING -----------------------------------------------
   Added 2026-07-31 after the client said the sub-pages felt heavy to scroll.
   The cause was measurable rather than atmospheric: EVERY [data-aos] ran at
   .9s, which is the brief's --dur-slow applied to all of it, and on a page
   with 28 staged elements that reads as wading. The brief's own scale is
   200 / 480 / 900, and 900 is meant for the things worth waiting for.

   The split is by what the element IS, not by where it sits:

   · TEXT arrives quickly. A paragraph that takes most of a second to fade in
     is a paragraph you have already tried to read twice. .48s, and the travel
     drops 26px to 16px — most of the "heavy" feeling is distance, not time.
   · PHOTOGRAPHS keep the slow arrival. A picture unrolling over .9s reads as
     considered; the same picture at .48s reads as a page that snapped.
   · HEADINGS sit between the two at .62s, because the mask reveal is doing
     more work per frame than a fade and needs the room to look smooth.

   Declared after the originals rather than edited into them: the .9s block
   above is shared with the homepage, which the client has already signed off,
   and this layer is scoped to the properties it actually re-times. */

/* Text. Also the default for anything not named below. */
[data-aos]{
  transition:opacity .48s var(--ease), transform .48s var(--ease), clip-path .48s var(--ease);
}
[data-aos="fade-up"]{ transform:translateY(16px); }
[data-aos="slide"]  { transform:translateX(-20px); }

/* Headings — the mask has further to travel. */
[data-aos="mask"]{
  transform:translateY(12px);
  transition:opacity .62s var(--ease), transform .62s var(--ease), clip-path .62s var(--ease);
}

/* Photographs. The one thing on the page that has earned .9s. */
[data-aos="frame"]{
  transition:opacity .9s var(--ease), clip-path .9s var(--ease);
}

/* Runs of cells: shorter per-cell, tighter ladder. At .8s with an .08s step
   the last of eight finished 1.36s after the first, which is long enough that
   a reader reaches the bottom of the run before it has arrived. */
[data-aos="cells"] > *{
  transform:translateY(14px);
  transition:opacity .46s var(--ease), transform .46s var(--ease);
}
[data-aos="cells"].is-in > :nth-child(2){ transition-delay:.05s }
[data-aos="cells"].is-in > :nth-child(3){ transition-delay:.10s }
[data-aos="cells"].is-in > :nth-child(4){ transition-delay:.15s }
[data-aos="cells"].is-in > :nth-child(5){ transition-delay:.20s }
[data-aos="cells"].is-in > :nth-child(6){ transition-delay:.25s }
[data-aos="cells"].is-in > :nth-child(7){ transition-delay:.30s }
[data-aos="cells"].is-in > :nth-child(n+8){ transition-delay:.35s }

/* Table rows, same argument. */
[data-aos="rows"] tbody tr{
  transition:opacity .42s var(--ease), transform .42s var(--ease);
}

/* --- E8. THE SECTION HEAD ARRIVES IN ORDER ---------------------------
   Requested 2026-08-06: the copy should appear in cadence rather than as a
   slab. The problem was structural rather than decorative. A section head is
   three things — folio kicker, heading, lede — and the heading and the lede
   each carried their own [data-aos] with no relationship between them. Two
   siblings a few pixels apart cross the observer's line on the SAME frame, so
   they arrived together every time: one block speaking in three voices at
   once, which is exactly what reads as a slab.

   What changes here is timing, and only timing. Nothing new moves, nothing
   takes longer, and the travel distances stay the ones E7 tuned down after
   the client called the sub-pages heavy — reopening those would undo that
   decision. The parts simply land 90ms apart in reading order.

   90ms is chosen, not rounded to a tenth. Below about 70ms two elements read
   as simultaneous and the rule buys nothing; past about 120ms the lede reads
   as a separate event from its own heading rather than as its second half.

   Deliberately NOT scoped to .sec-head. The heading-then-lede pair is the
   site's basic unit of copy and occurs in .band__body and the split blocks
   too; the adjacency selector is the same one --loud already uses for its
   margin, so this can only fire where a heading is genuinely followed by its
   lede. */
.h-sec + .lede.is-in{ transition-delay:.09s; }

/* The kicker leads, where the kicker is the thing on screen. Above 1280px
   `.rails-on .sec-head > .idx` is display:none and the section RAIL carries
   the folio instead, so this beat belongs to the narrow viewports — which is
   also where the head stacks vertically and the cadence reads most clearly.

   THE .js GATE IS LOAD-BEARING, not stylistic. The kicker is the only element
   in this layer given a hidden start state it did not have before, and it is
   hidden by a rule whose only way back is a SIBLING's class. With the script
   dead that class never lands and the folio would be gone for good — so the
   hidden state is gated on .js and the failure mode is "no animation" rather
   than "no number". Same argument, same gate, as [data-aos="lines"].

   :has() rather than a data-aos on the kicker itself: the folio numerals are
   read out of this element by the rails block in site.js, and the fewer hands
   on that markup the better. */
@media (prefers-reduced-motion: no-preference){
  .js .sec-head > .idx{
    opacity:0; transform:translateY(8px);
    transition:opacity .42s var(--ease), transform .42s var(--ease);
  }
  .js .sec-head:has([data-aos].is-in) > .idx{ opacity:1; transform:none; }
}

/* THE ONE PLACE A GAP OPENED UP — and the patch that only got half of it.
   This read --g-group on a loud --wide head, on the theory that .sec--loud's
   --g-section below the head plus --wide's --g-block above the lede was the
   whole of the void. It was not: --wide's display:contents had also switched
   .sec-head's `gap` on between the two, so a third value nobody had declared
   was adding 43.2px underneath. Lowering the margin could only ever shave a
   third of the hole. The row gap is zeroed at the treatment now and --wide's
   own margin is --g-group for loud and quiet alike, which makes this rule an
   exact restatement of the one it used to override — so it is gone rather
   than left to look load-bearing. */
/* The full-width lede that used to live here moved up into --wide itself.
   It was written as a --loud-only patch on the assumption that only --loud
   opened the hole; measuring all 19 --wide heads showed the same 119k px²
   L-shaped void on the nine QUIET ones too, because --wide sets its own
   86px headline and the size, not .sec--loud, is what creates it. Fixing the
   treatment beat patching its loudest instance. */

/* ============================================================
   LAYER F — SEQUENCES THAT ARRIVE IN ORDER
   Added 2026-07-31. The complaint was specific: timelines, stage runs and the
   long ruled lists scroll past as one undifferentiated slab, because every
   entry looks like the entry above it. The shared reveal helps once — the run
   arrives, then nothing happens for another 800px.

   The device is the site's own hairline, put in motion: each ruled row draws
   its rule left to right as it arrives, in reading order. Nothing new is
   introduced; what the page is already built out of starts behaving like a
   sequence.

   WHY THIS IS NOT SCROLL-LINKED, having been built that way first.
   The obvious implementation is `animation-timeline: view()` — genuinely tied
   to the scrollbar, no JS. It was built, measured, and removed, because a
   view() range cannot complete for any element sitting in the last viewport
   of a page: the document runs out of scroll before the range ends, and the
   animation stops mid-way with `fill: both` holding it there. Measured across
   16 pages and 104 rules: 26 of them stayed part-drawn at the foot of their
   page, permanently. entry-based ranges and cover-based ranges both failed;
   cover made products-leather-shoes worse, 2 undrawn to 6. A hairline frozen
   at 40% width does not read as a subtle effect, it reads as a broken render,
   which is a worse outcome than no effect at all.

   The IntersectionObserver already in site.js has none of that problem: it
   fires once, the transition completes on its own clock, and site.js carries
   a jump-guard sweep for deep links that land past the fold. It loses the
   "tracks your scroll" quality and keeps the thing that was actually asked
   for — a long run that arrives in order rather than all at once.

   TWO GUARDS, both load-bearing:
   · prefers-reduced-motion — the rules are simply there, undrawn.
   · Nothing here animates opacity on content. The rule is a pseudo-element
     and the numerals move on transform alone, so the worst failure is an
     undrawn hairline or a 10px offset, never a blank row.
   ============================================================ */
@media (prefers-reduced-motion: no-preference) {

  /* The real border steps aside and a pseudo-element takes its exact place,
     so nothing moves when the drawn rule replaces the static one. scaleX on a
     1px bar is compositor work — this stays cheap however many rows a page
     carries. */
  .stepline .step,
  .faq details,
  .spec--long tbody tr{ position:relative; }
  .stepline .step,
  .faq details{ border-bottom-color:transparent; }
  .spec--long td,
  .spec--long tbody th{ border-bottom-color:transparent; }

  .stepline .step::after,
  .faq details::after,
  .spec--long tbody tr::after{
    content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px;
    background:var(--line); transform-origin:left center;
    transform:scaleX(0);
    transition:transform .7s var(--ease);
  }
  .spec--long tbody tr::after{ bottom:0; }
  .on-dark .stepline .step::after,
  .on-dark .faq details::after,
  .on-dark .spec--long tbody tr::after{ background:var(--line-light); }

  /* Drawn once the run they belong to is in view. The ladder is read off the
     row's position, so a thirteen-row table writes itself top to bottom
     instead of appearing as a block. .06s a step, stopping at eight: past
     ~.5s the tail is arriving later than a reader reaches it. */
  [data-aos].is-in .step::after,
  [data-aos].is-in details::after,
  [data-aos].is-in tbody tr::after,
  .is-in .step::after,
  .is-in details::after,
  .is-in tbody tr::after{ transform:scaleX(1); }

  .is-in .step:nth-child(2)::after,
  .is-in details:nth-child(2)::after,
  .is-in tbody tr:nth-child(2)::after{ transition-delay:.06s }
  .is-in .step:nth-child(3)::after,
  .is-in details:nth-child(3)::after,
  .is-in tbody tr:nth-child(3)::after{ transition-delay:.12s }
  .is-in .step:nth-child(4)::after,
  .is-in details:nth-child(4)::after,
  .is-in tbody tr:nth-child(4)::after{ transition-delay:.18s }
  .is-in .step:nth-child(5)::after,
  .is-in details:nth-child(5)::after,
  .is-in tbody tr:nth-child(5)::after{ transition-delay:.24s }
  .is-in .step:nth-child(6)::after,
  .is-in details:nth-child(6)::after,
  .is-in tbody tr:nth-child(6)::after{ transition-delay:.30s }
  .is-in .step:nth-child(7)::after,
  .is-in details:nth-child(7)::after,
  .is-in tbody tr:nth-child(7)::after{ transition-delay:.36s }
  .is-in .step:nth-child(n+8)::after,
  .is-in details:nth-child(n+8)::after,
  .is-in tbody tr:nth-child(n+8)::after{ transition-delay:.42s }

  /* THE FAILSAFE THE view() VERSION COULD NOT HAVE.
     If the observer never runs — script blocked, an error before the reveal
     block — .is-in is never written and every rule above would stay at
     scaleX(0), which is the site with its hairlines missing. <noscript>
     covers scripting-off; this covers script-present-but-dead, and it is the
     same argument as [data-aos="lines"]'s .js gate one layer up. */
  html:not(.js) .stepline .step::after,
  html:not(.js) .faq details::after,
  html:not(.js) .spec--long tbody tr::after{ transform:scaleX(1); transition:none; }
}

@media print{
  .stepline{ border-top-color:var(--line) !important; }
  .stepline .step,
  .faq details{ border-bottom-color:var(--line) !important; }
  .spec--long td,
  .spec--long tbody th{ border-bottom-color:var(--line) !important; }
  .stepline::before,
  .stepline .step::after,
  .faq details::after,
  .spec--long tbody tr::after{ display:none !important; }
  .stepline .step__n{ animation:none !important; transform:none !important; }
}

/* ============================================================
   LAYER G — THE SUB-PAGE BANNER, AND MAKING THE SEQUENCES VISIBLE
   Added 2026-07-31, all four items from the same review.
   ============================================================ */

/* --- G1. THE BANNER IS SHORTER ---------------------------------------
   Measured at 1440x900 before this: 1067px, which is 119% of the viewport.
   A banner taller than the screen means the reader scrolls a full page
   before reaching a word of content — the type was set at 108px and ran to
   four lines. 80px brought the same headline to three; on 2026-08-06 the
   client asked for one heading scale across every sub-page, so the 80px
   override is gone too and --type inherits base .h-display (72px cap) —
   which shortens the band further, in the same direction this note argues. */
.page-hero--split .h-display,
.page-hero--index .h-display{ font-size:clamp(34px,5vw,64px); }
.page-hero{ padding-block:clamp(104px,11.5vh,126px) clamp(34px,4.4vw,52px); }

/* --- G2. THE MONOGRAM, BOTTOM LEFT -----------------------------------
   The same ground device the overlay menu uses and the client approved on
   sight, placed in the corner of the banner nothing else occupies. It cannot
   reuse .ghost-mark: that block anchors right and lives on ::before, and
   .page-hero::before is already the radial wash. ::after was free.

   The mask direction is the whole reason the low opacity survives — a flat
   fade leaves every diagonal ending in a crisp line, so you still read a
   LOGO however far the opacity drops. It points at the content, which here
   is up and to the right. */
/* .page-hero already sets overflow:hidden, which clips the monogram for us. */
.page-hero::after{
  content:""; position:absolute; z-index:0; pointer-events:none;
  left:var(--ghost-x,-7%); bottom:var(--ghost-bottom,-10%);
  width:var(--ghost-w,min(40vw,460px)); aspect-ratio:450/414;
  background:url("../images/brand-logo-mark-01.svg") no-repeat center/contain;
  filter:brightness(0) invert(1);
  opacity:var(--ghost-o,.05);
  -webkit-mask-image:linear-gradient(to top right, #000 0%, rgba(0,0,0,.35) 44%, transparent 78%);
          mask-image:linear-gradient(to top right, #000 0%, rgba(0,0,0,.35) 44%, transparent 78%);
}
/* Not under a photograph. --figure already carries a full-bleed image and its
   own gradient; a monogram on top of that is two grounds arguing. */
.page-hero--figure::after{ display:none; }

/* --- G3. THE PLANT, FAINT, BEHIND THE BANNER -------------------------
   A navy wash laid over the photograph rather than an opacity on the image:
   opacity cannot be set on a background layer, and a real <img> at .1 would
   be a second element to keep in the .no-entrance list for no gain. Two
   background layers on the element itself sit UNDER both pseudo-elements, so
   the wash, the monogram and the copy stack in the right order with nothing
   added to the markup.

   .90 over the plate leaves the photograph at roughly a tenth — enough that
   the band has a floor and a texture, not enough to compete with the type. */
.page-hero:not(.page-hero--figure){
  background-image:
    linear-gradient(rgba(1,34,61,.945), rgba(1,34,61,.965)),
    image-set(url("../images/band-hero-ground-01.webp") type("image/webp"),
              url("../images/band-hero-ground-01.jpg") type("image/jpeg"));
  background-size:cover, cover;
  background-position:center, center;
  background-repeat:no-repeat, no-repeat;
}

/* --- G4. THE SEQUENCES, MADE VISIBLE ---------------------------------
   The rule-draw shipped and could not be seen, and the measurement that said
   it worked was not wrong — it was measuring the wrong thing. The animated
   element is a 1px hairline at rgba(247,245,242,.18): an 18%-opacity line one
   pixel tall. Drawing it takes .7s and changes almost nothing a reader can
   perceive, so the effect was real, correct, and invisible.

   The fix is not more travel or more time. It is to draw the line in the
   accent first and let it settle back to the hairline it belongs to — gold at
   full strength IS visible at 1px, and it lands the motion on the one colour
   the site reserves for emphasis. The line is written, then it goes quiet.

   Two keyframes rather than a transition, because the colour has to arrive
   and leave on one clock: a transition can only go somewhere and stay. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes ruleWrite{
    0%   { transform:scaleX(0);   background:var(--accent) }
    55%  { transform:scaleX(1);   background:var(--accent) }
    100% { transform:scaleX(1);   background:var(--line) }
  }
  @keyframes ruleWriteDark{
    0%   { transform:scaleX(0);   background:var(--accent) }
    55%  { transform:scaleX(1);   background:var(--accent) }
    100% { transform:scaleX(1);   background:var(--line-light) }
  }
  /* 2px while it is being written, back to 1 when it settles — a hairline
     that thickens for the half-second it is drawing is the difference between
     seeing the gesture and taking it on trust. */
  .is-in .step::after,
  .is-in details::after,
  .is-in tbody tr::after{
    animation:ruleWrite 1.05s var(--ease) both;
    transition:none;
  }
  .on-dark .is-in .step::after,
  .on-dark .is-in details::after,
  .on-dark .is-in tbody tr::after{ animation-name:ruleWriteDark; }

  /* The numeral takes the same beat. 10px was chosen to be safe and turned out
     to be inaudible next to a row that is already fading in; 22px and a touch
     of accent colour make the year read as arriving rather than as present. */
  @keyframes numeralWrite{
    from{ transform:translateY(22px); opacity:.35 }
    to  { transform:none; opacity:1 }
  }
  .is-in .step__n{ animation:numeralWrite .72s var(--ease) both; }
  .is-in .step:nth-child(2) .step__n{ animation-delay:.06s }
  .is-in .step:nth-child(3) .step__n{ animation-delay:.12s }
  .is-in .step:nth-child(4) .step__n{ animation-delay:.18s }
  .is-in .step:nth-child(5) .step__n{ animation-delay:.24s }
  .is-in .step:nth-child(n+6) .step__n{ animation-delay:.30s }
}

/* The last of the banner's height, taken out of the gaps rather than the type.
   Measured at 1440x900 after the size change: 853px, still 95% of the screen,
   with ~110px of air between the headline and the deck and another 66 above
   the facts. Neither gap was carrying meaning at that size. */
@media (min-width:1080px){
  .page-hero--type .h-display + .lede{ margin-top:var(--g-group); }
}
.page-hero .hero-facts{ margin-top:clamp(26px,3.2vw,44px); }
.page-hero .hero-index{ margin-top:0; }

/* --split is the one variant whose height is set by an image rather than by
   type: a 4/5 portrait in a ~550px column is ~690px tall, and it pushed
   careers and contact to 93% of the screen while every other banner came in
   between 73 and 86. Capping the frame rather than changing the ratio keeps
   the portrait a portrait on narrow columns and only bites where the column
   is wide enough for the ratio to run away. */
@media (min-width:901px){
  /* height, NOT max-height. With aspect-ratio still in force a max-height
     shrinks the box on BOTH axes — 520px tall became 416px wide, the frame
     pulled back from 1440 to 1306 and the right-edge bleed that is the whole
     point of --split quietly disappeared. Dropping the ratio lets the grid
     column set the width and leaves object-fit to do the cropping. */
  .page-hero--split .hero-media > .slot{ aspect-ratio:auto; height:min(58svh,520px); }
}

/* ============================================================
   F. CLIENT REVISION — 2026-08-03
   The sub-page pass the client asked for in review: quieter banners, true
   full-bleed plates (the margin fix lives on .plate itself), ruled form
   fields, a grouped fact sheet instead of the 13-row vendor table, and the
   sticky-media stage stack for the process pages. Every value is a token and
   nothing below introduces a new colour or a filled surface.
   ============================================================ */

/* --- F1. the solo page head ------------------------------------------
   Banners lost the breadcrumb and the mono eyebrow (client: the small ABOUT
   duplicated what the headline now says plainly). With the label column gone
   the two-column head grid would park the headline in the 120px label lane,
   so the solo modifier collapses it. */
.page-head--solo{ grid-template-columns:minmax(0,1fr); }
.page-head--solo .h-display{ max-width:20ch; }

/* --- F2. ruled form fields -------------------------------------------
   The enquiry form joins the site's one division system: rule under the run,
   never a box. Client note on the selects — keep the arrow, drop the outline.
   The same treatment goes to every field, because one underlined select next
   to nine boxed inputs reads as a broken field, not a decision.
   Focus keeps the outline for keyboard users; hover and focus also thicken
   the rule so the state survives without colour vision. */
.form input,
.form select,
.form textarea{
  /* background-COLOR, not the shorthand: the shorthand resets
     background-image and silently deletes the select's arrow glyph. */
  background-color:transparent; border:0; border-bottom:1px solid var(--line);
  padding:11px 2px;
}
.form select{ background-position:right 2px center; padding-right:34px; cursor:pointer; }
.form textarea{ border:1px solid var(--line); padding:13px 15px; }
.form input:hover, .form select:hover{
  border-color:rgba(20,22,26,.38);
}
.form input:focus-visible, .form select:focus-visible{
  outline:none; border-bottom:2px solid var(--navy);
  /* the 1px→2px rule shifts the baseline; the margin compensates */
  margin-bottom:-1px;
}
.form-row.is-invalid input,
.form-row.is-invalid select{ border-width:0 0 2px; }

/* --- F3. the fact sheet ----------------------------------------------
   about.html's answer to a vendor registration form: fifteen facts a
   procurement reader copies straight across, in three named bands.

   The cells are still .step__facts — same <li><b>label</b><span>value</span></li>
   the process stages and the timeline entries use — with two changes, and
   both of them are answers to a specific thing the client rejected:

   (a) The label sits ABOVE the value, not beside it. .step__facts is a
       150px/1fr row everywhere else because there the values are short. Here
       the beside form is what produced the ~200px value lane that made the
       three-column version wrap eleven values out of thirteen.
   (b) A cell can be .is-wide and take two of the three columns. Cell width
       following value length is what lets a band hold long and short facts
       on the same row without either squeezing the long one or stranding the
       short one — and it is why fifteen facts fit six rows with no orphan.

   The band NAME is in a left rail rather than on a heading row, which is
   where the height saving comes from: three group headings on their own rows
   cost ~130px and buy nothing the rail does not. The rail is the same 170px
   lane .vm uses further up the page, deliberately — one device, used twice.

   Cells carry NO rule of their own — 2026-08-07, client: the layout is right
   but "nhiều line làm layout nhìn hơi rối". Counted, they were right: a rule
   under every cell, broken into segments by the column-gap, drew eighteen
   short dashes across the sheet on top of the three band rules. The only
   lines left are the ones that do structural work — one continuous hairline
   opening each band and one closing the sheet, four in total.

   Rows are held apart by the cells' own padding (18px bottom + 18px top),
   which is what the spacing already was with a 1px border between; deleting
   the border costs a pixel a row and changes nothing else, so the height the
   client approved this morning is preserved exactly. */
/* --g-group between bands, not --g-block. The bands are three parts of one
   sheet, and at a full block gap they read as three separate objects that
   happen to be stacked — which also spent 116px of the height the client
   asked to get back. */
.sheet{ display:grid; gap:var(--g-group); }
/* The closing rule goes on the last band's ul, not on .sheet: on .sheet it
   spans the name rail too, and a bottom rule starting 214px left of the three
   above it is exactly the kind of ragged edge this pass is removing. */
.sheet__band:last-child .sheet__facts{ border-bottom:1px solid var(--line); }
.on-dark .sheet__band:last-child .sheet__facts{ border-bottom-color:var(--line-light); }
.sheet__band{
  display:grid; grid-template-columns:minmax(0,170px) minmax(0,1fr);
  gap:clamp(18px,3vw,56px); align-items:start;
}
.sheet__name{
  margin:0;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.16em; text-transform:uppercase; color:var(--accent);
  /* Lands the band name on the first cell's label rather than on the rule
     above it: the ul's border-top, then the cell's own padding-top. */
  padding-top:calc(1px + clamp(13px,1.5vw,18px));
}
.sheet__facts{
  max-width:none; margin-top:0;
  grid-template-columns:repeat(3,minmax(0,1fr));
  column-gap:clamp(20px,2.4vw,40px); row-gap:0;
  border-top:1px solid var(--line);
}
.on-dark .sheet__facts{ border-top-color:var(--line-light); }
/* align-content:start is load-bearing, not tidying. Every cell is itself a
   grid, and a grid item's align-content defaults to normal — which behaves as
   STRETCH for auto-sized rows. A one-line cell sharing a row with a cell that
   wraps therefore inherits the taller row height and pours the spare space
   into its own row gap: ESTABLISHED floated ~20px above 2024 purely because
   MEMBERSHIPS beside it ran to two lines. start pins the label to its value
   and lets the slack fall to the bottom of the cell, where it reads as part of
   the gap to the next row rather than as a hole under a label. */
.sheet__facts li{
  grid-template-columns:minmax(0,1fr); gap:5px; align-content:start;
  padding:clamp(13px,1.5vw,18px) 0;
  /* .step__facts li carries a border-top; this sheet rules bands, not cells. */
  border:0;
}
.sheet__facts li.is-wide{ grid-column:span 2; }
.sheet__facts b{ padding-top:0; }
/* The values are the payload on this one block, so they take full ink rather
   than the --ink-soft .step__facts uses when the surrounding prose is the
   point and the facts are the footnote to it. */
.sheet__facts span{ color:var(--ink); }
.on-dark .sheet__facts span{ color:var(--paper); }
@media (max-width:1080px){
  /* Two columns before the rail goes: at three, a non-wide cell is under
     240px here and the wrapping this layout exists to prevent comes back.
     .is-wide has to be released at the same time. Spanning two of two is a
     whole row, which strands the cell that was meant to sit beside it — the
     plant band rendered as four rows, two of them half empty. At ~470px a
     plain cell already holds both addresses, so the span has nothing left
     to buy here. */
  .sheet__facts{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .sheet__facts li.is-wide{ grid-column:auto; }
}
@media (max-width:820px){
  .sheet__band{ grid-template-columns:minmax(0,1fr); gap:var(--g-pair); }
  .sheet__name{ padding-top:0; }
  .sheet__facts{ grid-template-columns:minmax(0,1fr); }
  .sheet__facts li.is-wide{ grid-column:auto; }
}
/* A footnote set as small print, not as the mono caption .figcap is by
   default. Uppercase monospace is right on four words under a photograph and
   wrong on three sentences under a data sheet, where it becomes a second grey
   block competing with the block it annotates. */
.figcap--prose{
  font-family:var(--f-display); font-weight:450; font-size:var(--text-small);
  letter-spacing:0; text-transform:none; line-height:var(--lh-tight);
  max-width:var(--measure-body); margin-top:var(--g-block);
}
/* The base sheet sets a{color:inherit;text-decoration:none}, which inside a
   grey footnote leaves the cross-link to certifications.html indistinguishable
   from the sentence around it — invisible as a link, and colour alone would
   not be enough even if it were coloured. Navy plus a hairline, the same pair
   .pv__out uses for an inline link in small print. */
.figcap--prose a{
  color:var(--navy); border-bottom:1px solid var(--line);
  transition:border-color .3s var(--ease);
}
.figcap--prose a:hover,
.figcap--prose a:focus-visible{ border-bottom-color:var(--navy); }
.on-dark .figcap--prose a{ color:var(--accent); border-bottom-color:var(--line-light); }

/* --- F4. the stage stack ---------------------------------------------
   Capabilities' five stages, with every photograph on the SAME side (client
   request) and held under the header while its stage scrolls past. The media
   column is a crossfading stack driven by a small observer in site.js;
   the per-step .step__img stays in the markup as the narrow-viewport and
   no-JS rendering, so the photographs never depend on the script.

   Same sticky maths as .split__media, same reason it is desktop-only. */
.stagestack__media{ display:none; }
@media (min-width:1080px){
  .js .stagestack{
    display:grid;
    /* media first in source, so the held photograph takes the left lane the
       way .split's portrait does; the text keeps the wider column. */
    grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);
    gap:clamp(28px,3.6vw,64px);
    align-items:start;
  }
  /* --flip: photograph on the RIGHT, text on the left (client, 2026-08-06,
     for capabilities). A modifier and not a global change, because
     certifications carries the other stack and was signed off with its
     pictures where they are — this is .feature--flip's argument at section
     scale. Source order stays media-then-steps, which is what the narrow and
     no-JS renderings want; `order` swaps only the painted lanes, and the
     column widths swap with them so the copy keeps the wider one. */
  .js .stagestack--flip{ grid-template-columns:minmax(0,1.08fr) minmax(0,.92fr); }
  .js .stagestack--flip .stagestack__media{ order:2; }
  .js .stagestack--flip .stepline{ order:1; }
  .js .stagestack__media{
    display:grid;
    position:sticky; top:calc(var(--hdr-h) + clamp(18px,4vh,54px));
  }
  .js .stagestack__media > .slot{
    grid-area:1/1; aspect-ratio:4/5; max-height:calc(96svh - var(--hdr-h));
    opacity:0; transition:opacity .62s var(--ease);
  }
  /* --short: the held photograph on certifications, at 1/1. A modifier like
     --flip, not a global change — capabilities was signed off at 4/5.

     It went 4/5 → 4/3 in the morning ("cho cụm bên trái ngắn lại") and 4/3 →
     1/1 the same afternoon ("cho hình dài hơn"), so the name is now doing
     less work than it looks: --short still means shorter than the 4/5
     archetype, just not as short as it briefly was. The square is a
     deliberate stop rather than a return to 4/5 — 4/5 is the crop that was
     rejected this morning, and 1/1 is a third taller than what is on screen
     now. If the client asks for taller again, 5/6 is the next step and 4/5
     is the ceiling; past that this modifier has no reason to exist and the
     stack should just drop it. Sources are 3/2 landscape plates, so every
     value here is a centre crop of a wider frame. */
  .js .stagestack--short .stagestack__media > .slot{ aspect-ratio:1/1; }
  .js .stagestack__media > .slot.is-active{ opacity:1; }
  /* .stagestack__n — DELETED 2026-08-07. The mono counter that named the
     held stage in the picture's corner. capabilities dropped it 2026-08-06
     and certifications on 2026-08-07 ("bỏ số trên hình"), both for the same
     reason: it printed "01 / 04" over the photograph while the step's own
     numeral said 01 a few inches away. No page ships the element now, so the
     rule and its site.js counter went with it rather than sitting here as a
     styling hook for markup nothing writes. The archetype answers "which one
     am I on" with the gold current heading instead. */
  /* The numeral and the stage title both take a step up (client: "cho số to
     hơn", "tiêu đề từng cụm to hơn chút"). Not scoped to --flip: this is a
     legibility step for the archetype, and the numeral column above is
     already ~100px at 1440, which holds two digits at 64px with room. */
  .js .stagestack .step__n{ font-size:clamp(34px,4.4vw,64px); }
  /* Still a step up — the client asked for it and it holds — but stated as a
     multiple of --text-title rather than as its own clamp, so it tracks the
     scale instead of drifting from it. It was clamp(22px,2.1vw,30px), i.e.
     1.25x the .h-sub of the day; 1.25 keeps exactly that relationship now
     that the base tier came down. One number to retune if the client wants
     it louder again. */
  .js .stagestack .step__body .h-sub{ font-size:calc(var(--text-title) * 1.25); }
  /* The steps give up their own photographs and their third column to the
     stack — gated on .js so a script-dead page keeps the alternating layout. */
  .js .stagestack .step__img{ display:none; }
  .js .stagestack .step:has(.step__img){ grid-template-columns:clamp(64px,7vw,110px) minmax(0,1fr); }
  .js .stagestack .step:nth-child(even):has(.step__img) .step__body{ grid-column:2; }
}
@media (prefers-reduced-motion:reduce){
  .js .stagestack__media{ position:static; }
  .js .stagestack__media > .slot{ transition:none; }
}

/* --- F5. the journal — DELETED 2026-08-06 -----------------------------
   The .journal/.entry milestone rows were removed with the category-and-
   blog relayout; the card grid that replaced them is LAYER N (.post-grid,
   .post, .cat-bar) at the end of this file. The entry tag / read-more /
   underline-grow voices were re-cut there as .post__*. */

/* --- F6. the veiled hero ---------------------------------------------
   Client, 2026-08-03 (second pass): the banners that were navy stay navy —
   the photograph goes UNDER the ground as a ~8% veil, texture rather than
   picture. The band's layout, height and type are exactly the old --type /
   --index / plain hero; only this faint layer is new. The image is
   decorative here (alt="" in the markup), the radial wash still paints
   beneath it, and the container keeps its z-index:1 above both. */
.page-hero--veil .hero-media{
  position:absolute; inset:0; z-index:0;
  pointer-events:none;
  /* No opacity here on purpose. The flat .08 veil this rule used to carry
     was superseded by H8 below (client, 2026-08-06): opacity .3 clawed back
     by a horizontal mask, ~.04 under the headline. Two same-specificity
     declarations meant only the later one ever applied — the dead .08 was
     removed 2026-08-07 so the computed value has one source. */
}
.page-hero--veil .hero-media > .slot{ position:absolute; inset:0; }

/* ============================================================
   LAYER H — CLIENT NOTES, 2026-08-06
   Three items from one message: the sub-page banner returns to the
   header's own left edge, and the about timeline gets a scroll
   spotlight. (The third — the culture figure counting to six — is
   markup + the existing .js-count block, nothing here.)
   ============================================================ */

/* --- H1. THE BANNER'S EDGE IS THE HEADER'S EDGE ----------------------
   .container clamps at 1680px and centres, so past 1680 the sub-page
   hero indented itself while the header lockup and the homepage
   .banner-inner — both full-bleed on raw --pad-l / --pad — stayed at
   the viewport's own gutter. Measured at 1920x1000: lockup at 176px,
   hero headline at 296px, a 120px step between two things that are
   supposed to share one left edge.

   Only the hero unclamps. The sections below a banner keep .container's
   centring on purpose — that is exactly how the homepage already
   behaves (full-bleed banner over clamped sections), so this makes the
   sub-pages match it rather than diverge from it. */
.page-hero > .container{ max-width:none; }

/* --- H3. THE BANNER STANDS FULL HEIGHT -------------------------------
   Client, 2026-08-06 (same message as H1): raise the about banner, then
   sync every content sub-page to it. This half-reverses G1, which cut
   the band to ~4/5 of a screen — the client has now seen both and wants
   the full-screen open, so the four content variants take min-height and
   anchor their copy to the bottom edge, which is the homepage .banner's
   own posture (padding still guards the fixed header for short
   viewports). Type sizes stay at G1's values; only the ground grows.

   The utility banners — 404, terms, privacy, thank-you, all bare
   .page-hero--veil with a sentence of copy — are left at G1 height on
   purpose: a reader mid-errand does not want a full navy screen between
   them and the one paragraph they came for. Add the variant class, not
   this selector, if one of them is ever promoted to a content page. */
/* 100svh lasted one review (2026-08-06, same day): the client pulled it
   back to about two thirds of a screen — tall enough to be an opening,
   short enough that content shows above the fold. The bottom-anchored
   posture stays. --figure and --split remain in the list only as guards;
   the same review converted every content banner to --type/--veil. */
.page-hero--type,
.page-hero--index,
.page-hero--figure,
.page-hero--split{
  min-height:66svh;
  /* flex-START since the fifth pass (2026-08-06): the client pointed at the
     product pages — crumb and headline riding the band's top, air below —
     and asked every banner for that posture. The bottom-anchor lasted the
     morning; the H6 gap still carries the lede down toward the right. */
  display:flex; flex-direction:column; justify-content:flex-start;
}

/* --- H4. ONE GROUND IMAGE, NOT TWO -----------------------------------
   Client, 2026-08-06 (second pass): G3's band-hero-ground photograph and
   the --veil page photograph were stacking — two half-visible images
   arguing under every banner. The per-page veil is the one that stays;
   G3's background layers go, and the flat navy base plus the radial wash
   is the ground under it. Utility banners without a .hero-media simply
   rest on that flat ground now. */
.page-hero:not(.page-hero--figure){ background-image:none; }

/* --- H5. THE MONOGRAM, DOUBLED ---------------------------------------
   Same review: the corner mark read as a footnote at min(40vw,460px).
   Doubled it becomes the banner's second read — still .05 opacity, still
   masked toward the copy, and overflow:hidden crops what runs past the
   band. Set as the custom property G2 already consumes, so the geometry
   rules stay in one place. */
/* Trimmed ~10% on review (2026-08-06, third pass): 920 put the mark's
   shoulder under the headline; 828 keeps the doubled presence and clears it. */
.page-hero{ --ghost-w:min(72vw,828px); }

/* --- H6. THE HEADLINE RIDES HIGH -------------------------------------
   Same review: with the band bottom-anchored, the headline sat on the
   lede's shoulder at the band's midline. Growing the gap between the two
   is what lifts the title — the lede keeps its seat on the bottom edge,
   the h1 moves up, and the diagonal (title high-left, deck low-right)
   gets the air it was drawn for. svh in the clamp so the lift scales
   with the band, which is itself svh-sized. Desktop only: below 1080 the
   head is a single stacked column and the title is already high. */
@media (min-width:1080px){
  .page-hero--type .h-display + .lede{ margin-top:clamp(90px,14svh,170px); }
}
/* --split's head used to sit in row 1 while the portrait spanned both rows,
   which was invisible when the band hugged the photo — at full height it
   parks the headline mid-air with a dead zone under the lede. Spanning the
   head across the same rows and self-aligning it to end sets type and
   portrait on one shared baseline, the full-screen posture the other three
   variants get from the flex column alone. Desktop only: below 900px the
   grid is a single column and the span would drag the head under the photo. */
@media (min-width:901px){
  .page-hero--split .page-head{ grid-row:1 / span 2; align-self:end; }
}

/* --- H2. THE TIMELINE SPOTLIGHT — REMOVED ----------------------------
   The dim-the-rest scrub lasted one day. It was the about timeline's
   answer to "ấn tượng hơn" on the morning of 2026-08-06; that afternoon
   the client asked for the timeline to be the homepage making section's
   horizontal track instead, and the two devices do not compose. The
   scrub's whole argument was that a VERTICAL run shows more entries at
   once than a reader can attend to, so one is lit and the others sit
   back. A pinned horizontal track already answers that by only having a
   screen's worth of entries on the screen at all — dimming the two
   cards either side of centre would be taking ink off content the
   layout has already finished filtering.

   .stepline--scrub was only ever on about.html, so the class, the
   .is-live state and the 'timeline spotlight' block in site.js all went
   with it. .stepline--sticky and .stepline--years stay: certifications,
   contact and capabilities still run vertical steplines. The horizontal
   timeline is Layer J at the foot of this file. */

/* --- foot-contact: the labelled Contact column -----------------------
   Client redesign 2026-08-06. The overlay drawer's dt/dd voice, scaled to
   footer size: mono accent labels, ruled rows (rule above each row after
   the first — ruled, never boxed), addresses as plain text because an
   address is a fact rather than an action, and the two actions on their
   own closing row. Link colours come from .foot-col a above. */
.foot-contact{ margin:0; display:grid; gap:7px; font-size:var(--text-small); }
.foot-contact > div{
  display:grid; grid-template-columns:60px minmax(0,1fr); gap:12px;
  padding-top:7px; border-top:1px solid var(--line-light);
}
.foot-contact > div:first-child{ border-top:0; padding-top:0; }
.foot-contact dt{
  font-family:var(--f-mono); font-weight:500; font-size:9.5px;
  letter-spacing:.18em; text-transform:uppercase;
  color:rgba(221,199,157,.86); padding-top:3px;
}
.foot-contact dd{ margin:0; line-height:var(--lh-tight); color:rgba(247,245,242,.78); }
/* The __actions row (Enquire / Arrange a visit) lived here for a few hours
   on 2026-08-06 and was cut the same day — the header CTA owns Enquire. */

/* --- journal article links + tags — DELETED 2026-08-06 ---------------
   .entry__tag / .entry__more / the entry heading underline-grow went with
   the journal itself; the same three voices are now .post__tag,
   .post__more and the .post__title/.post-feature__title link rules in
   LAYER N at the end of this file. */

/* --- crumb: the path line on level-2 pages ---------------------------
   Kingmaker runs a breadcrumb on every sub-page and it earns its line:
   with a two-level tree the crumb is the cheapest way to hold "where am
   I" without sending the visitor back to the menu. Level-1 pages skip it
   — "Index / About" is noise. It sits above the H1 inside the hero, in
   the same mono voice as the folio labels; the current page is text, not
   a link, so the row ends where the visitor already is. Colours assume
   the on-dark hero every level-2 page uses. */
.crumb{ margin-bottom:var(--g-group); }
.crumb ol{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap; gap:6px; }
.crumb li{
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase;
  color:rgba(247,245,242,.6);
}
.crumb li + li::before{ content:"/"; margin-right:6px; color:rgba(247,245,242,.35); }
.crumb a{ color:inherit; text-decoration:none; transition:color .35s var(--ease); }
.crumb a:hover,
.crumb a:focus-visible{ color:var(--accent); }
.crumb li[aria-current]{ color:rgba(247,245,242,.92); }

/* --- wheremaps: the two pins on contact ------------------------------
   The brief asked for a map; the buyer's sharper question is distance to
   the port and the airport, so the embeds travel with a ruled facts run
   (.step__facts) carrying the road figures. Each embed sits inside a
   .slot — aspect-ratio is mandatory there, the iframe fills the frame
   the way a photograph would — and the filter mutes Google's palette
   toward the page's own; interacting still hands you the real map. */
.wheremaps{
  display:grid; grid-template-columns:1fr 1fr; gap:var(--g-group);
  margin-top:var(--g-block);
}
.wheremaps__map{ margin:0; }
.wheremaps .slot{ aspect-ratio:4/3; }
.wheremaps .slot > iframe{
  position:absolute; inset:0; width:100%; height:100%; border:0; display:block;
  filter:grayscale(1) contrast(.92) brightness(.97);
}
.wheremaps__facts{ margin-top:var(--g-group); }
/* Single-pin variant (contact, 2026-08-14): the company publishes one address
   now, so the paired grid would leave half a row empty. One column, and the
   frame goes landscape — a 4/3 map at full container width is a portrait-ish
   plate the height of the viewport, which is a lot of grey for one pin. */
.wheremaps--one{ grid-template-columns:1fr; }
.wheremaps--one .slot{ aspect-ratio:2.4/1; }
@media (max-width:820px){
  .wheremaps{ grid-template-columns:1fr; }
  .wheremaps--one .slot{ aspect-ratio:4/3; }
}

/* ============================================================
   LAYER H (continued) — CLIENT NOTES, 2026-08-06, fourth pass
   Appended below later work rather than spliced into Layer H so the
   sheet stays append-only; cascade order is the point, not the grouping.
   ============================================================ */

/* --- H7 (RETIRED SAME DAY) -------------------------------------------
   The banner folio — an injected arrow counter at the band's top-right —
   was cut on sight: the client kept the breadcrumb in the markup as the
   banner's wayfinding instead. The 'hero pagination' JS block and every
   .hero-pagi rule were deleted rather than parked. */

/* --- H8. THE VEIL LEANS RIGHT ----------------------------------------
   Client: more navy on the left, more photograph on the right. One mask
   instead of two opacities — the element opacity rises to .3 and the
   horizontal mask hands it back: ~0 under the left gutter, a whisper
   (~.04, the old flat veil) beneath the headline, the full .3 only past
   the lede's column. The title sits on clean navy again and the image
   finally reads as an image on the side that was empty. */
.page-hero--veil .hero-media{
  opacity:.3;
  -webkit-mask-image:linear-gradient(to right,
    transparent 0%, transparent 8%,
    rgba(0,0,0,.14) 42%, rgba(0,0,0,.55) 72%, #000 98%);
          mask-image:linear-gradient(to right,
    transparent 0%, transparent 8%,
    rgba(0,0,0,.14) 42%, rgba(0,0,0,.55) 72%, #000 98%);
}

/* --- H9. THE HEADLINE, ONE STEP LOUDER -------------------------------
   +10px across the whole clamp, not a new scale: min, preferred and max
   each carry the same +10 so the size the client saw at any width moves
   by the amount they asked for (8–12px). One selector for every banner
   family on purpose — this morning's unification put all sub-page H1s on
   one scale, and this keeps them on one scale, one notch up. */
.page-hero .h-display{ font-size:clamp(52px, calc(7vw + 12px), 92px); }

/* --- H10. THE HEADLINE, ONE MORE NOTCH -------------------------------
   Client, 2026-08-06 (sixth pass): louder again. Same +12 across the
   whole clamp as H9 used, so the size moves by the same amount at every
   width rather than only where the cap bites. This supersedes H9's
   declaration outright — H9's rule is left above as the record of the
   step, and this one wins by being later in the sheet. */
.page-hero .h-display{ font-size:clamp(58px, calc(7.6vw + 14px), 104px); }

/* --- H11. TWELVE PIXELS OF AIR, BOTH ENDS ----------------------------
   Same review: the crumb-and-headline cluster down 12px, the lede down
   12px. With the band top-anchored (H3) and taller than its content,
   both moves are one adjustment — +12 at the top walks the whole column
   down, and -12 at the bottom is what the lede's own slack gives up to
   let it happen. Written as a shifted clamp rather than a new scale so
   the responsive curve G1 tuned is preserved, just displaced.

   Overrides G1's padding-block by position, like H10 above. */
.page-hero{
  padding-block:clamp(116px, calc(11.5vh + 12px), 138px)
                clamp(22px,  calc(4.4vw - 12px),  40px);
}

/* ============================================================
   LAYER I — THE CLOSING BAND, 2026-08-06
   Appended, like every layer above it, because several rules here beat
   an earlier rule at EQUAL specificity and win on declaration order
   alone. Each one says so at the point of use. Moving this block up the
   sheet silently inverts them.
   ============================================================ */

/* --- I1. .p-close IS AN OBJECT, NOT A HEADING TREATMENT --------------
   Every page ends on the same section — folio, headline, lede, two
   buttons — and until now that section had no identity of its own. It
   borrowed whichever E1 heading treatment its folio number happened to
   land on, so the SAME closing move rendered four different ways across
   the site: --figure's oversize numeral on careers/products/
   sustainability, --wide's dropped lede on capabilities/markets/news/
   oem-odm/products-leather-shoes, --beside's split on certifications and
   three product lines, and the bare default everywhere else. Read one
   after another the site had no ending; it had four accidents.

   E1's own comment already anticipated this — "Interstitials with no
   .idx (.statement, .plate, the CTA close) are skipped — they already
   break the rhythm by being a different kind of object." The CTA close
   does carry an .idx, so it was never skipped in practice. This layer
   makes good on the intent instead: one treatment, on all 19 pages,
   applied as .sec-head--close and never as a folio-derived variant.

   THE COMPOSITION, and what each part is for:

     · a full-bleed tinted BAND — the page visibly stops rather than
       running out of text. Matches .group-contact, which is how the
       HOMEPAGE has always closed; home and sub-page now end alike.
     · a hairline SEAM at the band's top edge — the trim mark.
     · the headline SPANS the content width. The old default and --wide
       both left the right 45-60% of the closing screen empty, which is
       what made the strongest sentence on the page read as the weakest.
     · a RULE under the headline, then lede left / actions right, split
       by a second hairline. Both tracks carry something, so nothing
       floats.
     · the buttons STRETCH to their track and stack. Two small pills
       adrift in white were the least emphatic thing on the page; two
       full-track bars with the label at one end and the arrow at the
       other are the same two states of the one button system (see the
       button block near the header) at the scale the moment deserves. */

.p-close{
  /* The same tinted ground .group-contact uses, for the same reason: on
     pure white the closing section is indistinguishable from the body
     copy above it. --field-2 is a cool step, not a second paper stock —
     see the token's own note on why it is not the old warm #EDE9E3.

     NO SEAM RULE (client, 2026-08-06). A hairline used to sit on the
     band's top edge as a trim mark. It was redundant twice over: the
     change of ground already IS the edge, and the ghost plate's own top
     fade (I1c) lands in the same 20% of the band — two edges explaining
     the same join, which is what made it read as a line drawn on the
     page rather than as the page ending. */
  background:var(--field-2);
  /* ASYMMETRIC, and it has to be: <footer> opens with its own
     clamp(50px,6vw,80px) of top padding, so a symmetric bottom pad here
     is counted twice and the band ends up marooned above the navy. The
     top gets MORE than .sec's default because the seam needs air under
     it before the folio starts. Same reasoning as .group-contact. */
  padding-block:calc(var(--sec-pad) * 1.5) calc(var(--sec-pad) * 1.15);
}
/* The band is the page's last statement, so its head owes nothing to the
   block below it — there is no block below it. Beats
   `.sec-head{padding-bottom:var(--g-block)}` at higher specificity. */
.p-close .sec-head{ padding-bottom:0; }

/* --- I1b. THE BAND HAS NO FOLIO (client, 2026-08-06) ----------------
   The eyebrow is gone, not restyled. It was first re-coloured from gold
   to ink on the same brief and that was a misread: the ask was to lose
   the LINE, not the colour.

   It reads correctly. Every other section's folio is wayfinding — it
   says where you are in a numbered sequence. This band is not a place in
   the sequence, it is the thing after the sequence, and numbering the
   call to action "05 / GET IN TOUCH" labels an offer as if it were a
   chapter. Removing it also removes the last thing between the top of
   the band and the headline, which is what the headline wants.

   HIDDEN, NOT DELETED FROM THE MARKUP. The folio stays in all 19 files
   as the section's index, so a later renumbering still has something to
   renumber and .sec-rail keeps a folio to read if a rail ever returns
   here. display:none also takes it out of the accessibility tree, so
   nothing announces a number that is not shown; the section is still
   labelled, by aria-labelledby on the h2.

   THE --accent OVERRIDE THIS BAND CARRIED IS GONE WITH IT. It existed
   because brass drops to ~2.96:1 on the tint, under the 3:1 floor for
   non-text graphics, and the band had two: the rail numeral and the
   folio. The rail is display:none (I2) and the folio is now gone.
   Nothing accent-coloured is left except the buttons' gold hover fill —
   and a closing button that hovers to a different gold from every other
   button on the site is a regression, not a local decision. So the band
   inherits --brass like everywhere else and the one-button system stays
   one system. */
.p-close .sec-head .idx{ display:none; }

/* --- I1c. THE GHOST PLATE --------------------------------------------
   The band's decoration is a photograph of the floor, held far enough
   back to be a ground rather than an image. Client picked this over a
   corner-bracket frame and over the paper grain; the monogram emboss and
   the radial vignette that stood here before were cut on the same note
   ("doi cach decor khac"). Do not re-propose those two as a first idea —
   they have been seen and passed on.

   WHY IT CAN BE A REAL PHOTOGRAPH AND STILL BE QUIET. It is the same
   move .page-hero--veil makes on the ten navy banners: a real image at
   low opacity, reading as texture in the ground. The difference is what
   it sits under. There the photo goes under NAVY, which swallows colour;
   here it goes under paper, where the plate's own blues and the green
   floor would arrive as a cast — a tinted smudge rather than a texture.
   grayscale() is what makes the device portable to the light side, and
   it is already this sheet's treatment for a photograph that has to
   behave like a diagram (see .wheremaps__map's iframe filter).

   ONE IMAGE ON ALL 19 PAGES, and it is chosen rather than convenient:
   band-hero-ground-01 is the only wide plate in the library (1800x692,
   which is very close to the band's own proportion, so `cover` crops
   almost nothing) that appears on NO page in the site. That matters —
   the standing rule for veil images is that they must not repeat a photo
   used elsewhere on the same page, and a single shared closing image
   only satisfies it if the image is unused everywhere. Verified by grep
   across all pages before it was picked. If it is ever placed in a page
   body, this needs a different plate.

   LEGIBILITY IS NOT THE CONSTRAINT HERE, opacity is chosen for looks.
   Worst case is a black pixel of plate under the type: .14 over
   --field-2 lands at ~#D2D4D7, which still runs ~12:1 against --ink and
   ~6.4:1 against the lede's --ink-soft. Both clear AA with room, so the
   ceiling on this value is taste, not contrast.

   THE MASK IS THE SAME LESSON .tex-paper RECORDS. An unmasked layer ends
   in a hard horizontal line at its own boundary, and against a light
   ground that line becomes the only edge on screen. Feathering both ends
   makes the plate read as something showing THROUGH the paper. The top
   fade also keeps the photo off the seam hairline, so the two edges do
   not double up. */
.p-close::before{
  content:""; position:absolute; inset:0; z-index:0; pointer-events:none;
  /* Plain url() first as the floor, image-set() second for webp: a
     browser that cannot parse image-set drops only that declaration and
     keeps the jpg above it. Same webp-with-jpg-fallback contract the
     <picture> elements use, expressed the way CSS has to. */
  background-image:url("../images/band-hero-ground-01.jpg");
  background-image:image-set(
      url("../images/band-hero-ground-01.webp") type("image/webp"),
      url("../images/band-hero-ground-01.jpg")  type("image/jpeg"));
  background-size:cover;
  /* Not `center`. The plate's top third is roof, ducting and lamps; the
     working line sits below the middle, so the crop is pulled down to
     keep people in the frame at the band's short heights. */
  background-position:center 62%;
  filter:grayscale(1) contrast(.94);
  opacity:.14;
  -webkit-mask-image:linear-gradient(180deg, transparent 0%, #000 20%, #000 76%, transparent 100%);
          mask-image:linear-gradient(180deg, transparent 0%, #000 20%, #000 76%, transparent 100%);
}
/* The content wrapper only, NOT `> *` — the same rule and the same
   reason as .ghost-mark > .container: .sec-rail is position:absolute and
   handing it position:relative would drop it back into the flow. It is
   display:none in this band, but the rule outlives that. */
.p-close > .container{ position:relative; z-index:1; }

/* --- I2. --close: ONE CENTRED STACK ----------------------------------
   Revised the same day. The first attempt kept the site's editorial
   left-align and split the band into lede-left / actions-right. It was
   consistent and it read as a SECTION — client: "chưa đạt, nhìn cảm
   giác cta hơn". They were right about the diagnosis: the promise and
   the action were in different columns, the lede held the primary
   reading position and the buttons the secondary, and a horizontal rule
   sat between the headline and everything it was asking for.

   So the whole block is pulled onto one centre axis, in the order a call
   to action is actually read:

       eyebrow  ->  headline  ->  one line of why  ->  the two actions

   Nothing sits beside anything. The rule is gone — it was the device
   that made this read as a section boundary rather than as an offer.

   CENTRING IS NOT A NEW IDEA HERE, it is the one the banners already
   use: .page-hero--solo centres its actions and .page-head--solo runs a
   single centred column. The page now opens and closes on the same axis,
   and the closing band is the only thing in the body that leaves the
   left margin — which is exactly what makes it read as a different kind
   of object. */

/* NO RAIL EITHER.
   The gutter rail went before the folio did, and for a layout reason
   rather than the client's: a centred block with a hairline rail pinned
   to the far left gutter is visibly lopsided, and the rail's vertical
   tag then labels a column that is no longer there. With the folio now
   gone too (I1b) the band simply carries no section marking at all,
   which is the same reading E1 already gives the interstitials —
   .statement carries no rail either, "they already break the rhythm by
   being a different kind of object", and the CTA close was always on
   that list.

   .plate LEFT THAT LIST on 2026-08-07. The client read the gap in the
   spine across a full-bleed photograph as a fault, not as a break, so
   plates now take .sec-rail--bare: the hairline and its fill, no mark.
   This band keeps its exemption, and for the reason above rather than by
   inheritance — a centred stack has no left column for a rail to index.
   Nothing here changes: .p-close is not a plate.

   Safe against the rail JS: every rail reads its OWN section's folio and
   its own rect, with no cross-rail sequence, and `rails.length` still
   counts this one, so .rails-on is still applied. Verified in the block
   before changing it. */
.p-close .sec-rail{ display:none; }
/* AND THE GUTTER GOES WITH THE RAIL.
   .container's padding is ASYMMETRIC above 1280 — the 1280 block widens
   --pad-l to clamp(156px,11vw,176px) while --pad stays clamp(20px,5vw,
   80px), reserving the lane the rail stands in. Every other section
   wants that: its text starts on the same left edge as the rail's label.

   A centred block does not. Centred inside that box the whole stack sits
   43px right of the viewport axis at 1440 — measured, not guessed, and
   invisible in a screenshot: it does not look broken, it looks very
   slightly off, which is the worse failure for a symmetric composition.
   The band has no rail, so it has no reason to keep the rail's gutter.

   Below 1280 --pad-l and --pad are the same clamp, so this is a no-op
   there rather than a second breakpoint to keep in sync. */
.p-close > .container{ padding-left:var(--pad); }

.p-close .sec-head{
  /* One column at every width. Beats both the default two-track rule and
     `.rails-on .sec-head` (equal specificity, later declaration). */
  grid-template-columns:minmax(0,1fr);
  justify-items:center; text-align:center;
  gap:0; padding-bottom:0;
}
/* The .7em was optical alignment for a folio sitting BESIDE a headline,
   holding it down onto the cap line. Above one, it is just a gap. */
.p-close .sec-head .mono{ padding-top:0; }

.sec-head--close > div{
  display:block;
  /* No folio above it any more, so the stack starts at the band's own
     top padding and needs no offset of its own. */
  margin-top:0;
  max-width:min(100%, 1120px); margin-inline:auto;
}
/* Short measure ON PURPOSE. The headline is the offer, and an offer that
   runs the full 1368px of the container reads as a paragraph that
   happens to be large; held to a short centred measure it reads as a
   statement.

   20ch resolves to ~674px at the section scale. MEASURED across all 19
   closing headlines at 1440 (range.getClientRects(), not eyeballed):
   twelve sit on one line under it — the longest is terms.html at 651px,
   23px of slack — and the other seven break to two. None reaches three,
   which is the only hard constraint: a three-line centred headline over
   a two-line lede stops being a statement and becomes a block of text.
   Widen this and the seven start un-wrapping one at a time; narrow it
   past ~19ch and terms.html is the first to go to two. */
.sec-head--close .h-sec{ max-width:26ch; margin-inline:auto; }
/* Selector shape matched to `.sec-head .h-sec + .lede` above — three
   classes, so EQUAL specificity, and this wins on declaration order
   alone. Written as the adjacent-sibling form for exactly that reason;
   `.sec-head--close .lede` loses and the gap stays at --g-pair.
   Which is now the point: the margin came off (2026-08-06, client asked for
   one gap sitewide) and the base rule's --g-pair is what applies. Centred
   and short-measure is what makes this band a closing gesture; sitting the
   lede further from its headline than every other head on the site was not
   doing that work, it was just the one place the pair read as two. */
.sec-head--close .h-sec + .lede{
  margin-inline:auto; max-width:52ch;
}
.sec-head--close .page-actions{
  justify-content:center;
  /* Beats `.page-actions{margin-top:clamp(30px,4vw,44px)}` on
     specificity. Larger than the gap above the lede — the pair has to
     clear the sentence rather than read as a third line of it — but only
     just. At the 58px this first shipped with, the buttons floated free
     of the block they belong to and the CTA came apart into a statement
     and, separately, two buttons. The air that matters is OUTSIDE the
     stack, in the band's own padding; inside it the four parts should
     read as one object. */
  margin-top:clamp(30px,3.4vw,46px);
}

@media (max-width:640px){
  /* The pair stops fitting side by side and wraps. Left ragged, two pills
     of different widths read as debris; stretched to the column they read
     as two choices, which is what they are. */
  .sec-head--close .page-actions{ flex-direction:column; align-items:stretch; }
  .sec-head--close .page-actions > *{ justify-content:center; }
}

/* --- I3. WHAT THE HOMEPAGE SHARES, AND WHAT IT DOES NOT -------------
   .group-contact was already the tinted band with the re-toned accent —
   I1 took its ground and its reasoning rather than inventing a second
   closing treatment.

   IT BRIEFLY ALSO TOOK A SEAM HAIRLINE FROM HERE, AND THAT IS REVERTED.
   The rule was added to .group-contact only so the homepage would rhyme
   with the 19 sub-page bands; when the client took the seam off those
   (I1), a hairline left on the homepage would be the last instance of a
   device the site no longer uses. It goes with them. Nothing else about
   .group-contact was touched by this layer, so the homepage close is
   back to exactly what it shipped as.

   What the two still share is the ground and the accent re-tone. BELOW
   that they are deliberately different, and after the centring in I2
   more different than they were: home is a left-aligned head over three
   ruled route rows, one filled button and the masked monogram, because
   it is the payoff of the whole page and it has three real routes to
   offer. A sub-page has one sentence and two links, and centring is what
   turns that from a section into an offer.

   If the homepage is ever centred to match, the route rows and the
   monogram have to be re-composed with it — a centred head over a
   left-aligned three-column index is the half-done version and reads
   worse than either whole one. */

/* --- H12. THE NARROW-SCREEN FLOOR THE LOUD SCALE NEEDS ---------------
   H10's 58px floor CLIPS. .reveal-line is overflow:hidden — that is how
   the headline rises out of a masked box — and horizontal overflow is
   hidden with it, so a title that does not fit loses its last letters
   rather than wrapping or scrolling. "Sustainability" and
   "Certifications" are single unbreakable words: at 58px they measure
   356px against a 350px box at 390 and a 280px box at 320, and the page
   reported no overflow at all because the clip had already eaten it.

   So the floor becomes width-aware. min() lets the smallest value track
   the viewport below ~370px, where a fixed floor is exactly the thing
   that cannot fit, while every width the client is reviewing on keeps
   H10's scale untouched. Verified against the two longest titles at 320,
   360 and 390 — measured text width vs box width, not screenshots. */
.page-hero .h-display{
  font-size:clamp(min(50px, 13.5vw), calc(7.6vw + 14px), 104px);
}

/* --- H13. THE LEDE IS ACTUALLY ANCHORED TO THE BOTTOM ----------------
   H11 changed the bottom PADDING and the client rightly pointed out
   that the lede never touched it: with the band top-anchored (H3) and
   taller than its content, the deck was parked wherever H6's fixed gap
   left it and the leftover navy fell below — 59px on about, 40px on
   leather shoes, different on every page because the gap is measured
   from a headline whose height varies. "Lưng chừng" is the exact word
   for it.

   So the column is made to fill the band and the deck is aligned to the
   end of it. Four rules because the height has to be handed down the
   whole chain — section, container, head, inner grid — and the first
   link that stays auto-height stops the rest from meaning anything.
   After this the gap under the lede IS the bottom padding, the same
   number on every page.

   H6's margin-top survives as a MINIMUM gap rather than the position:
   the 1fr row cannot size below its content plus that margin, so a
   short band still keeps title and deck apart instead of colliding.

   --type only. --index (products) fills the same space with the .marks
   list below the deck, so its lede is mid-column by design, and the
   utility banners are not flex columns at all. 1080 is where the deck
   moves into its own right-hand column — below that the head is one
   stacked block and a bottom-anchored deck would tear away from it. */
@media (min-width:1080px){
  .page-hero--type > .container{ flex:1; display:flex; flex-direction:column; }
  /* align-items on .page-head is `start` in the base rule, which would
     hold this div at its content height however tall the row gets. */
  .page-hero--type .page-head{ flex:1; align-items:stretch; }
  .page-hero--type .page-head > div{ grid-template-rows:auto 1fr; }
  /* Not left to the parent's align-items:end. Same result today, but the
     deck's position is the point of this block and should not be a
     side effect of a rule written for the headline's baseline. */
  .page-hero--type .h-display + .lede{ align-self:end; }
}

/* ============================================================
   LAYER J — THE ABOUT TIMELINE READS SIDEWAYS, 2026-08-06
   Client: "section timeline ở about đổi lại thành layout scroll
   ngang như section the making ở home." The five entries were a
   vertical .stepline--years with two scroll devices layered on it
   (sticky numerals, spotlight scrub); they are now one horizontal
   track held under a pinned screen, the same object the homepage
   production stages are.

   WHAT WAS TAKEN FROM .making AND WHAT WAS NOT.

   Taken: the three-element split, unchanged in every detail —
     .tl       the tall scroll well. Height is one screen plus the
               track's own overflow, so a pixel of page scroll buys
               exactly a pixel of sideways travel.
     .tl__pin  the one screen that holds still while that happens.
     .tl-track the row of cards, wider than the screen, translated
               by --track-p against --travel.
   Both wells are driven by the SAME script (site.js, 'pinned
   tracks') reading the same three custom properties, so the two
   sections cannot drift apart in behaviour.

   NOT taken: .making__ground, the navy/paper join cut through the
   pin. That device works on the homepage because the cards
   straddling the colour change are PHOTOGRAPHS. These cards are
   prose — a paragraph crossing a ground join is a paragraph half
   of which is unreadable — so this well stays navy end to end and
   inherits .on-dark's ink like the rest of the section. If the
   join is ever wanted here, the cards have to become images first.

   NOT taken: the head. .making pins its lead because its <h2>
   belongs to .m-capabilities above it. This section's <h2> is its
   own, and .sec-rail__mark rests at margin-top:calc(--sec-pad +
   6px) — level with the folio, which is what makes the rail's
   numeral point at the heading it indexes. Pinning a head that a
   100svh box then vertically centres would move the folio a
   couple of hundred pixels down and leave the rail numeral
   pointing at empty navy. So .sec-head stays where it is, above
   the well, and the pin holds the track and its meter.

   THE FALLBACK IS THE BASELINE. .is-pinned is written by script
   and never by this sheet. Without it — no JS, reduced motion, or
   a viewport under 1081px — every .is-pinned rule below stays
   inert and .tl-track is the plain overflow-x rail it already is,
   with the .swipe-bar under it. That is also the phone answer to
   "scroll ngang": a finger flicks the row directly.
   ============================================================ */

/* --- J1. THE WELL ----------------------------------------------------
   Matches .making's own z-index for the same reason: .sec-rail sits at
   2 and runs the section's full height, which here is a whole screen
   plus the travel. Nothing is painted over it — the well is transparent
   and the section's navy shows through — so the rail stays visible the
   whole way down. */
.tl{ position:relative; z-index:3; margin-top:clamp(28px,3.2vw,54px); }
.tl.is-pinned{
  height:calc(100svh + var(--travel,0px));
  /* CLOSING THE HOLE ABOVE THE BAND (client, 2026-08-06, same review as the
     one below). Centring splits the pin's leftover space, so the composition
     starts --pin-lead down from the pin's top edge and the section's lede sat
     355px clear of the first card at 1440x1000 — more on a taller screen,
     because the hole IS the screen's leftover.

     Cancelling the whole lead and re-adding an authored figure makes that
     distance a constant. The figure is .making's own top margin, unchanged
     from the value this rule overrides — the intent was always "one group
     break below the head", and the pin's leftover space was what buried it.
     Measured lede-to-rule with it: 87px at 768, at 1000 and at 1200, where
     before it was 135, 315 and 437. A vw clamp and not a vh one precisely so
     the answer does not start moving with the screen again. The clamp is the
     only number here anyone should ever want to tune.

     THE HEAD CANNOT COLLIDE WITH THE BAND, and the arithmetic is the reason
     rather than a lucky margin. Lifting the well by (lead - gap) puts its top
     edge exactly `gap` below the lede — and since the pin catches when that
     top edge reaches the viewport's, the lede is always `gap` above the
     composition at the moment of catching, whatever the head's height or the
     screen's. It only ever moves further away after that, on its way up and
     out. No guard needed, on any viewport.

     Margin, not padding, and on the WELL: sticky containment is the parent's
     content box, so the pin still catches and releases exactly where it did.
     The well simply starts higher, and its top stretch is empty by
     construction — it is the hole being cancelled. */
  margin-top:calc(clamp(28px,3.2vw,54px) - var(--pin-lead,0px));
  /* CLOSING THE BLANK NAVY UNDER THE CARDS (client, 2026-08-06).
     The pin centres a ~510px composition in 100svh, so it releases with a
     third of a screen of empty ground still showing, and the section's own
     bottom padding follows that. --pin-slack is the measured distance from the
     last card to the pin's foot, written by the 'pinned tracks' block; pulling
     the flow up by exactly it puts the next section's top edge on the line the
     cards end on, leaving --sec-pad as the only gap. Every other section on
     the site closes on exactly that much.

     WHY A MEASURED VALUE AND NOT A CLAMP like .making-foot's. The slack is
     (viewport - composition) / 2, so it runs 134px on a 768 laptop to 334px on
     a 1200 monitor — one authored number is either useless at the top of that
     range or, at the bottom, drags .p-claim up over the last card. The script
     floors it at 0, so a viewport too short to have any slack reclaims
     nothing.

     Margin, not padding, and on the WELL rather than the pin: sticky
     containment is the parent's CONTENT box, which a margin does not touch, so
     the pin still catches and releases on exactly the same two scroll
     positions it did before. Only what follows moves.

     Guarded on .is-pinned, and the property is re-zeroed whenever the class
     comes off — an unpinned rail has no blank band to reclaim, and pulling the
     next section up over it would overlap real content. */
  margin-bottom:calc(var(--pin-slack, 0px) * -1);
}

/* NO .tl__ground, AND THE REASON IS WORTH KEEPING. The reclaim below shortens
   the section, so its navy now runs out before the pin lets go and the
   statement's paper rises into the blank band while the cards are still held.
   The obvious fix — a .making__ground-style strip carrying the navy the last
   stretch — was built and removed the same hour: the blank band IS the pin's
   own box, so painting it navy puts back exactly the ground the reclaim was
   there to remove. Document 240px shorter, screen unchanged.

   What actually happens without it is better than the thing it was guarding
   against. The join stays BELOW the last card at every scroll position (the
   cards sit --pin-slack above the well's foot and the paper starts one
   --sec-pad below the section's), so nothing ever crosses it; the reader sees
   the next section arrive under a band that is still holding, which is the
   same handover .making stages deliberately. Do not "fix" this by restoring
   the ground. */
.tl__pin{
  position:relative; display:flex; flex-direction:column;
  padding-block:clamp(30px,4.6vh,58px) clamp(24px,3.6vh,46px);
}
.tl.is-pinned .tl__pin{
  position:sticky; top:0; height:100svh; overflow:hidden; justify-content:center;
  /* THE ENTRIES LEAVE AT THE MARGIN THEY ARRIVED ON.
     Without this the cards travelling off the left run the full width of the
     screen and straight through .sec-rail, which lives in the gutter at
     --rail-x. .making has the same geometry and does not need the cut because
     what crosses its rail is a PHOTOGRAPH — a hairline over an image reads as
     an overlay. A hairline through a paragraph reads as a rendering fault, and
     it is worst at the end of the travel, where the certification entry's
     seven lines sit right on top of it.

     Cut at the container's inner edge, which is the same line the first card
     starts on and the meter's rule starts on: entries leave at exactly the
     margin they arrived at, and the 26px ramp keeps that from being a razor
     edge through the middle of a word.

     ON THE PIN, NEVER ON THE TRACK. A mask resolves in the element's own local
     box and `transform` is applied AFTER it, so a mask on .tl-track would
     travel with .tl-track and the cut would slide off the screen with the
     cards it is supposed to be cutting. Same trap .sec-rail__fill documents at
     length. The pin does not move, so the cut does not either. */
  --tl-edge:calc(max(0px, (var(--vw,100vw) - 1680px) / 2) + var(--pad-l));
  -webkit-mask-image:linear-gradient(to right, transparent 0, transparent calc(var(--tl-edge) - 26px), #000 var(--tl-edge));
          mask-image:linear-gradient(to right, transparent 0, transparent calc(var(--tl-edge) - 26px), #000 var(--tl-edge));
}

/* --- J2. THE METER IS THE RULE ABOVE THE RUN -------------------------
   Two jobs in one hairline, and that is the economy of it. The site's
   list grammar puts a rule above every run of cells (see the ruled
   lists throughout), and a pinned track needs a readout saying how far
   through the sequence the held screen is. A rule drawn on the TRACK
   would answer neither — it would slide off the screen while claiming
   to measure the slide — so it sits on the pin, static, reading the
   same --track-p the transform reads. They cannot disagree.

   It is in .container rather than full-bleed: the run it rules starts
   on the container's inner edge, so the rule does too. The track then
   runs past the right edge on purpose, which is the whole signal that
   there is more of it.

   DRAWN HERE, AND .making__rule IS NOT — that is deliberate, not an
   oversight to tidy up. The homepage meter carries display:none; its
   pin head already holds a lead paragraph and an outbound link, so the
   held screen says what it is without one. This head holds nothing else,
   and a pinned track with no readout gives the reader no way to know
   whether they are two entries from the end or ten. Turn .making__rule
   back on if the homepage ever wants it; do not delete this one to make
   the two files agree. */
.tl__head{ position:relative; z-index:1; }
.tl__rule{
  position:relative; height:2px; background:var(--line-light);
}
.tl__rule i{
  position:absolute; inset:0; background:var(--accent);
  transform:scaleX(var(--track-p,0)); transform-origin:left;
  will-change:transform;
}
/* Unpinned the meter has nothing to report — --track-p is never written,
   so it would draw a permanently empty gold track under a row the reader
   scrolls themselves. The .swipe-bar under the track is that rail's
   readout, exactly as on .making. */
.tl:not(.is-pinned) .tl__rule i{ display:none; }

/* --- J3. THE ENTRIES -------------------------------------------------
   At rest a row of fixed-width columns wider than the screen: the
   overflow IS the travel, so nothing here has to know there are five
   entries. Add a sixth and the well grows itself.

   The left pad tracks .container's inner edge rather than the
   viewport's, so the first entry starts on the same margin as the
   heading above it — including past 1680px, where .container stops
   growing and centres. --vw is the well's clientWidth, written by
   script; the 100vw fallback is a half-scrollbar out and only above
   1680px, which is invisible. */
.tl-track{
  position:relative; z-index:1;
  display:grid; grid-auto-flow:column;
  /* Wider than .making's cards and deliberately so: those hold a 3/4
     photograph and a two-word caption, these hold up to sixty words of
     prose. Below ~300px the measure drops under about thirty-five
     characters a line, where a paragraph starts reading as a column of
     fragments. */
  grid-auto-columns:var(--card-w,clamp(320px,29vw,460px));
  gap:clamp(20px,2.5vw,44px);
  /* Back to the group gap. This was pushed out to clamp(30px,3.4vw,56px) to
     stop the meter and the cells' own hairlines reading as one doubled rule;
     the cell hairlines are gone, so the distance that separated two lines is
     now just a hole under the only line left. */
  margin-top:var(--g-group);
  padding-inline:calc(max(0px, (var(--vw,100vw) - 1680px) / 2) + var(--pad-l)) var(--pad);
  padding-bottom:18px;
  overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x mandatory;
  scroll-padding-left:var(--pad-l);
}
.tl.is-pinned .tl-track{
  overflow:visible; scroll-snap-type:none; padding-bottom:0;
  transform:translate3d(calc(var(--travel,0px) * var(--track-p,0) * -1), 0, 0);
  will-change:transform;
}
/* Chrome keeps reporting the visible overflow of a grid it is no longer
   scrolling, so an unhidden bar would draw a thumb for travel the reader
   cannot drag. Stated outright, as on .making: a pinned track's readout
   is the meter above it. */
.tl.is-pinned .swipe-bar{ display:none; }
.tl .swipe-bar{ position:relative; z-index:1; }
/* The bar's own colours are drawn for paper — --line track, --navy-ink thumb
   — and that is right on the homepage, where .making's rail sits on the paper
   half of the navy/paper join. This well is navy the whole way down, so the
   thumb was navy on navy: the bar reported the reader's position in a colour
   they could not see, on the one layout (phone, unpinned) where it is the only
   readout there is. Same correction .on-dark .wf .swipe-bar makes for
   oem-odm's pinned track (Layer O), applied per-component rather than in a
   shared selector because tying unrelated components together for one
   colour is how a fix on one breaks the other. */
.on-dark .tl .swipe-bar{ background:var(--line-light); }
.on-dark .tl .swipe-bar__thumb{ background:var(--paper); }

/* NO RULE ON THE CELLS, and this is the second attempt. The first drew a
   hairline over each card as "the run's rule continued through the row". It
   cannot be: the meter above spans .container, each cell's rule spans its own
   column, and the columns run past the container's right edge — so at 1440 the
   meter stopped at 1630 while the third card's rule ran to 1677. Two rule
   systems with different extents, one of them travelling and one of them not,
   which is a set of lines that can never agree at any scroll position. The
   reference has no cell rules at all: .making-step carries a photograph, a
   numeral and two lines of caption, and the gap between cards is the whole
   separation. Same here — the year is the marker, the gap is the divider.

   align-content:start so a short entry does not spread its three lines down a
   tall cell. min-height so five entries of unequal length still read as one
   band across the held screen: without it the tallest card sets the row and
   the shortest looks like a fragment beside it. The floor is vh-based because
   the box it has to hold its own against is 100svh. */
.tl-step{
  position:relative; scroll-snap-align:start;
  display:grid; align-content:start;
  min-height:clamp(260px,46vh,470px);
}
/* The year is the anchor, and it is the one thing in the card sized to be read
   from across the room — the same job .step__n did in the vertical run it
   replaces. Mono because a date is a figure.

   RAISED to the section-heading tier (client, 2026-08-06: "cho con số to lên").
   It was clamp(22px,2.3vw,34px), inherited from .stepline--years, which had
   itself been shrunk from .step__n's clamp(26px,3.2vw,46px) because a
   four-digit year is twice the width of "01" in a 110px gutter column. That
   gutter is gone — the year now has a whole card's width to itself — so the
   reason for the reduction went with it, and the figure lands on 52px at 1440,
   which is the section tier rather than a fourth number. Mono at 52 carries
   noticeably less colour than the Fraunces h2 at the same size, and the two are
   never on screen together anyway: the head scrolls away before the band holds.

   ALL ON ONE LINE (same note: "2024-2025 nằm ngang"). Every marker is a single
   run now — the two that used to break, 02.12 / 2024 and 2024 / 2025, are
   02.12.2024 and 2024–2025 in the markup. At the ceiling the longest of them
   measures ~312px against a 418px card, so nothing is relying on the wrap. */
.tl-step__n{
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(30px,3.6vw,52px); line-height:1.06;
  letter-spacing:-.02em; color:var(--accent);
}
.tl-step h3{
  margin:clamp(12px,1.2vw,18px) 0 0;
  font-size:var(--text-title); font-weight:500;
  letter-spacing:-.025em; line-height:var(--lh-title); color:inherit;
}
/* --text-body, and NOT --text-small the way .making-step p is. That card
   carries a two-word gloss under a photograph, where small is right
   because the photograph is the content. Here the paragraph IS the
   content — a held screen whose only subject is five paragraphs cannot
   set them at caption size, or the pin reads as a screen of footnotes.
   Measured at 1440x1000: the change is worth about 70px of card height,
   which is most of what closes the gap between this composition and
   .making's. */
.tl-step p{
  margin:var(--g-pair) 0 0;
  font-size:var(--text-body); line-height:var(--lh-body);
  color:rgba(247,245,242,.78); text-wrap:pretty;
}
/* max-width overrides .step__facts's --measure-body, which is a reading
   column's number and half again the width of the card it now sits in. */
.tl-step .step__facts{ max-width:none; margin-top:var(--g-group); }
/* Stacked, not the label-beside-value row .step__facts is elsewhere. The
   label column is 150px against a 460px card, so a third of the width
   goes to one mono word and the value wraps to four ragged lines beside
   it — and "CERTIFICATES" measures 88px, which is the whole column with
   nothing left for a longer label. This is the same collapse the run
   already does below 640px, applied for the same reason: the column is
   too narrow to divide. */
.tl-step .step__facts li{ grid-template-columns:minmax(0,1fr); gap:4px; }

/* --- J4. BELOW THE PIN ----------------------------------------------
   Under 1081px the script never adds .is-pinned, so everything above
   rests at its unpinned value and this is the whole of the phone
   layout: a narrower card, the track's own gutters, and the swipe bar
   inset to match. Identical to what .making does at the same width,
   because it is the same rail. */
@media (max-width:1080px){
  /* A flat width rather than .making's minmax(), because these cards carry
     sixty words and that one carries three. minmax(240px, …) resolved to its
     240px MINIMUM on a 390px phone — a 28-character measure, which is a
     paragraph broken into a column of fragments. 84vw gives ~39 characters
     and still leaves a slice of the next entry showing, which is the whole
     affordance telling the reader the row moves. */
  .tl{ --card-w:min(84vw,430px); }
  .tl-track{ padding-inline:var(--pad-l) var(--pad); scroll-padding-left:var(--pad-l); }
  .tl .swipe-bar{ margin-inline:var(--pad-l) var(--pad); }
  .tl__rule{ display:none; }
  /* The floor is a composition rule for a held SCREEN — it exists so five
     entries of unequal length still read as one band inside a 100svh pin.
     Nothing is being held here, so all it does is add a couple of hundred
     pixels of empty navy under the shortest entry. Grid stretch still levels
     the row to its tallest card, which is the alignment that actually
     matters. */
  .tl-step{ min-height:0; }
}

/* --- J5. THE ENTRANCE ------------------------------------------------
   [data-aos="cells"] staggers a grid's children in. The observer watches
   the TRACK, which is full width and always intersects, so the stagger
   runs on arrival for every card including the ones still off the right
   edge — they are revealed before they travel in, never after. The
   generic .is-in rules in Layer F already cover this; the only thing
   needed here is that the transform they animate is not the one the pin
   writes, and it is not: the pin transforms .tl-track, the entrance
   transforms .tl-step. Two elements, two clocks, no fight. */

/* --- H14. MORE AIR AT BOTH ENDS --------------------------------------
   Client, 2026-08-06 (eighth pass): a little more padding top and
   bottom, and the deck a little higher. Since H13 anchored the deck,
   those are ONE adjustment — the gap under the lede IS the bottom
   padding now, so +16 there raises the deck by exactly 16 and widens
   the bottom margin by exactly 16 at the same time. +16 at the top to
   match. Supersedes H11's declaration by position.

   The gap between headline and deck absorbs the change on a band held
   at 66svh (196px → 164px at 1440x900, still clear of the ~178px
   minimum H6 and the grid row-gap guarantee together set); on the pages
   whose copy already sets the band's height, the band grows by the 32px
   instead. Both are the intended reading of "more padding". */
.page-hero{
  padding-block:clamp(132px, calc(11.5vh + 28px), 154px)
                clamp(38px,  calc(4.4vw + 4px),   56px);
}

/* ============================================================
   LAYER K — THE SUB-PAGE MOTION KIT (client, 2026-08-06)
   The sub-pages were signed off as quiet relatives of the homepage; the
   client has now asked for the opposite — every section carries something,
   and the reference set (jonite.com, orgnzm.studio, benjamincreative.me)
   is manufacturers and studios whose pages move. This layer is that budget
   raise. It stays inside the house rules: ruled, never boxed; one button
   system; entrance never gates visibility (everything staged here is either
   gated on .js, driven by the shared [data-aos] observer, or ambient).
   ============================================================ */

/* --- K1. m-ticker: the ruled marquee ---------------------------------
   jonite runs its material properties in an endless strip; ours runs
   memberships, markets, programme names. The strip is a ruled band — hairline
   top and bottom, hairline between cells — not a row of chips.

   The loop needs the content twice (translateX(-50%) hands off exactly at the
   copy seam). The COPY EXISTS ONCE in markup; site.js clones the cells with
   aria-hidden and then sets .is-live. Everything moving is gated on .is-live,
   so with JavaScript off (or reduced motion) the strip is a static wrapped
   list of the single copy — degraded, never broken, and never a half-empty
   band sliding into blankness. */
.m-ticker{
  overflow:hidden;
  border-top:1px solid var(--line); border-bottom:1px solid var(--line);
}
.on-dark .m-ticker{ border-color:var(--line-light); }
.m-ticker__track{
  list-style:none; margin:0; padding:0;
  display:flex; flex-wrap:wrap; width:auto;
}
.m-ticker__track li{
  white-space:nowrap;
  padding:clamp(14px,1.6vw,22px) clamp(22px,2.6vw,44px);
  border-right:1px solid var(--line);
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(12px,1vw,14px); letter-spacing:.18em; text-transform:uppercase;
  color:var(--ink-soft);
}
.on-dark .m-ticker__track li{ border-right-color:var(--line-light); color:rgba(247,245,242,.78); }
/* The gold tick before each entry is the strip's only accent — the same
   restraint argument as .statement-rule. */
.m-ticker__track li::before{
  content:""; display:inline-block; width:6px; height:6px;
  background:var(--accent); margin-right:14px; vertical-align:2px;
}
.m-ticker.is-live .m-ticker__track{
  flex-wrap:nowrap; width:max-content;
  animation:tickerRun var(--ticker-dur,36s) linear infinite;
}
@keyframes tickerRun{ to{ transform:translateX(-50%); } }
/* Paused, not removed, off-screen (site.js) — pause-never-remove. */
.m-ticker.is-paused .m-ticker__track{ animation-play-state:paused; }
@media (prefers-reduced-motion:reduce){
  .m-ticker.is-live .m-ticker__track{ animation:none; flex-wrap:wrap; width:auto; }
}

/* --- K2. m-clock: the live time badge --------------------------------
   jonite and orgnzm both stamp a live local time on the page; for a factory
   selling across five time zones the device is not decoration, it answers
   "are they awake". The numerals are live data, not copy — the <time> is
   empty in markup and no-JS shows only the place name.

   Cells divide like .marks: border-left between, never a box. */
.m-clocks{
  list-style:none; margin:0; padding:0;
  display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr));
  border-top:1px solid var(--line);
}
.on-dark .m-clocks{ border-top-color:var(--line-light); }
.m-clocks > li{ padding:var(--g-group) clamp(18px,2vw,32px) var(--g-group) 0; }
.m-clocks > li + li{ border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px); }
.on-dark .m-clocks > li + li{ border-left-color:var(--line-light); }
.m-clock{ margin:0; }
.m-clock__tz{
  display:block; font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
}
.on-dark .m-clock__tz{ color:var(--accent); }
.m-clock__time{
  display:block; margin-top:8px;
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(24px,2.4vw,38px); line-height:1; letter-spacing:-.02em;
  color:var(--ink); font-variant-numeric:tabular-nums;
}
.on-dark .m-clock__time{ color:var(--paper); }
/* The pulse is the second hand this clock does not have. Gated to when JS has
   actually filled the time in (.is-live), so an empty badge does not blink. */
.m-clock.is-live .m-clock__time::after{
  content:""; display:inline-block; width:7px; height:7px; margin-left:12px;
  background:var(--accent); vertical-align:6%;
  animation:clockTick 2s var(--ease) infinite;
}
@keyframes clockTick{ 0%,100%{ opacity:1 } 50%{ opacity:.25 } }
@media (prefers-reduced-motion:reduce){
  .m-clock.is-live .m-clock__time::after{ animation:none; }
}

/* --- K3. m-tiles: the captioned figure grid --------------------------
   For the sections that were tables about visual things — careers' nine
   floor areas the first offender. Each cell is a photograph over a ruled
   caption; the caption is ALWAYS visible (a hover that reveals information
   is a hover that hides it from every touch screen).

   Ruled, never boxed: hairline above the run, hairline under every cell,
   border-right as divider — the .marks construction at figure scale. The
   numeral chrome is a CSS counter, so the 01–09 ladder survives a reorder.
   Entrance is data-aos="cells" on the grid — already staged, already guarded
   (noscript, reduced-motion, jump-guard) — so this component ships with NO
   new entrance selector to defend. Where a tile IS a link, compose with
   .hov-sweep/.hov-lift in markup; a bare informational tile takes neither
   (E5's argument: a sweep under something that goes nowhere is a promise
   the page cannot keep). */
.m-tiles{
  list-style:none; margin:0; padding:0;
  counter-reset:tile;
  display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
  border-top:1px solid var(--line);
}
.on-dark .m-tiles{ border-top-color:var(--line-light); }
.m-tiles > figure{
  margin:0; position:relative;
  padding:clamp(16px,1.8vw,26px) clamp(16px,1.8vw,26px) clamp(18px,2vw,28px) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .m-tiles > figure{ border-bottom-color:var(--line-light); }
.m-tiles > figure:not(:nth-child(3n)){ border-right:1px solid var(--line); }
.m-tiles > figure:not(:nth-child(3n+1)){ padding-left:clamp(16px,1.8vw,26px); }
.on-dark .m-tiles > figure:not(:nth-child(3n)){ border-right-color:var(--line-light); }
/* REQUIRED, as everywhere .slot is not absolutely positioned. */
.m-tiles .slot{ aspect-ratio:4/3; }
.m-tiles figcaption{ margin-top:var(--g-pair); }
.m-tiles figcaption::before{
  counter-increment:tile; content:counter(tile,decimal-leading-zero);
  display:block; margin-bottom:6px;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; color:var(--accent);
}
.m-tiles figcaption b{
  display:block; font-size:var(--text-title); line-height:var(--lh-title); font-weight:500;
  letter-spacing:-.02em; color:var(--ink);
}
.on-dark .m-tiles figcaption b{ color:var(--paper); }
.m-tiles figcaption span{
  display:block; margin-top:4px;
  font-size:var(--text-small); line-height:var(--lh-tight); color:var(--ink-soft);
  text-wrap:pretty;
}
.on-dark .m-tiles figcaption span{ color:rgba(247,245,242,.78); }
@media (max-width:900px){
  .m-tiles{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .m-tiles > figure:not(:nth-child(3n)){ border-right:0; }
  .m-tiles > figure:not(:nth-child(3n+1)){ padding-left:0; }
  .m-tiles > figure:not(:nth-child(2n)){ border-right:1px solid var(--line); }
  .m-tiles > figure:not(:nth-child(2n+1)){ padding-left:clamp(16px,1.8vw,26px); }
  .on-dark .m-tiles > figure:not(:nth-child(2n)){ border-right-color:var(--line-light); }
}
@media (max-width:560px){
  .m-tiles{ grid-template-columns:1fr; }
  .m-tiles > figure{ border-right:0 !important; padding-left:0 !important; }
}

/* --- K4. faq--media: the disclosure that opens onto a photograph -----
   The plain .faq is a text device; on the pages where an answer has a
   subject you can photograph, the open panel becomes a two-column spread.
   The figure fades in on [open] — user-triggered, so it needs no observer,
   no noscript line and no .no-entrance entry: a closed <details> is closed
   by choice, not by animation. */
.faq--media .faq__a{
  display:grid; grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);
  gap:clamp(24px,3vw,52px); align-items:start;
  max-width:none;
}
.faq--media .faq__a > p,
.faq--media .faq__a > div{ max-width:var(--measure-body); }
/* The posting's meta line — area · place · shape. It sits as the answer's
   first line rather than inside <summary>, where the 1fr/24px summary grid
   wrapped it word-by-word against the arrow column. Full-width so the grid
   auto-placement below it keeps description left, facts right. */
.faq__meta{
  grid-column:1 / -1; margin:0;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.faq__fig{ margin:0; }
.faq__fig .slot{ aspect-ratio:4/3; }
.faq--media details[open] .faq__fig{ animation:faqFigIn .5s var(--ease) both; }
@keyframes faqFigIn{ from{ opacity:0; transform:translateY(10px) } to{ opacity:1; transform:none } }
@media (max-width:720px){
  .faq--media .faq__a{ grid-template-columns:1fr; }
}
@media (prefers-reduced-motion:reduce){
  .faq--media details[open] .faq__fig{ animation:none; }
}

/* --- K5. claim-stack: the statement, three times ---------------------
   benjamincreative's "Be Real / Be Creative / Be Bold" — repetition as
   typography. The approved sentence is rendered three times; rows one and
   three are aria-hidden ghost echoes drawn in hairline stroke, row two is
   the ink. Repetition is layout, not copy: nothing is written, nothing is
   cut.

   Staging is gated on .js AND driven by the shared [data-aos] observer
   (wrapper carries data-aos="stack" — an unknown variant to the base rules,
   so the wrapper itself never hides; only these child rules stage). With
   JavaScript off neither the .js gate nor the observer exists and the stack
   is simply there — which is why this needs NO shell:noscript change. */
.claim-stack .statement + .statement{ margin-top:.04em; }
.claim-stack .statement[aria-hidden="true"]{
  color:transparent;
  -webkit-text-stroke:1px var(--line);
  user-select:none;
}
.on-dark .claim-stack .statement[aria-hidden="true"]{ -webkit-text-stroke-color:var(--line-light); }
.js .claim-stack[data-aos="stack"] > .statement{
  opacity:0; transform:translateY(.35em);
  transition:opacity .8s var(--ease), transform .8s var(--ease);
}
.js .claim-stack[data-aos="stack"].is-in > .statement{ opacity:1; transform:none; }
.js .claim-stack[data-aos="stack"].is-in > .statement:nth-child(2){ transition-delay:.12s }
.js .claim-stack[data-aos="stack"].is-in > .statement:nth-child(3){ transition-delay:.24s }
@media (prefers-reduced-motion:reduce){
  .js .claim-stack[data-aos="stack"] > .statement{
    opacity:1 !important; transform:none !important; transition:none;
  }
}
/* On a phone the echoes cost three screens of scroll for one sentence.
   They are aria-hidden decoration, so dropping them loses nothing. */
@media (max-width:560px){
  .claim-stack .statement[aria-hidden="true"]{ display:none; }
}

/* --- K7. THE SPLIT HERO'S ROWS, PINNED -------------------------------
   The --split variant shipped as dead code with auto-placed rows, and its
   first live consumer (contact) showed why that cannot stand: auto placement
   let the crumb land under the lede. The path line leads, the head follows —
   stated explicitly so source order can never reorder them again. */
.page-hero--split .crumb{ grid-row:1; align-self:end; }
.page-hero--split .page-head{ grid-row:2; }

/* --- K6. THE SEAMS THE KIT CREATES -----------------------------------
   New adjacencies this layer introduces. A .route-list straight under a .faq
   puts the list's border-top on the faq's last border-bottom — a doubled
   hairline reads as a mistake; the section gap says "new object". Same for
   a closing .prose note under a stepline, and a ticker following anything. */
.faq + .route-list,
.stepline + .prose,
.faq + .prose,
.m-tiles + .prose,
* + .m-ticker,
* + .m-clocks{ margin-top:var(--g-section); }
/* EXCEPT STRAIGHT OFF A NAVY BAND (client, 2026-08-07: "bỏ khoảng trắng
   giữa 2 section này, cho section màu xanh liền kề với đoạn slider chạy").
   The gap above is the right default when a ticker follows a paper section:
   the strip is a different object and the section gap says so. Under a full
   -bleed navy band it reads differently — the ground has already changed
   colour, so the colour change IS the boundary and the 75px of paper between
   them reads as a hole rather than as a break.
   Scoped to .p-quoting, not to .on-dark + .m-ticker: markets.html and
   certifications.html put their tickers inside a .container, after paper
   sections, and both are signed off at the full gap. */
.p-quoting + .m-ticker{ margin-top:0; }


/* ============================================================
   LAYER L — THE PRODUCT VIEWER (client, 2026-08-06)

   Two requests, one layer: the sample grid on the four line pages goes from
   four-up to two-up so the photographs are big enough to judge, and a pair
   opens into a viewer that can be zoomed.

   Everything here is additive. The shared .prod / .prod-grid block at the top
   of this sheet is untouched, because the homepage and the products hub still
   run it four-up — the two-up grid is a modifier, not an edit.

   ENTRANCE NEVER GATES VISIBILITY. The dialog's arrival is a @keyframes on
   [open], not an opacity floor waiting for a JS class: an animation that never
   runs leaves the element at its natural, visible state, whereas a class that
   never lands leaves a blank sheet. This project has blanked a page that way
   once already.
   ============================================================ */

/* --- L1. the sample grid, two-up ------------------------------------
   Scoped to the modifier, never to .p-range, so the hub and the homepage keep
   their four-up row. Gap opens with the cells: at 640px wide a 24px gutter
   reads as a hairline crack rather than as a gap. */
/* Written as .prod-grid.prod-grid--two — 0,2,0 — rather than the single class
   it reads as. The base rule at the top of this sheet is also 0,1,0, so a
   single-class modifier would win only because this layer is further down the
   file, and the sheet's own header warns that rule order here is original
   order and that moving a block can flip equal-specificity winners. Doubling
   the class makes the win independent of where the block sits. Same reason the
   two overrides in the media query below are written that way. */
.prod-grid.prod-grid--two{
  grid-template-columns:repeat(2,minmax(0,1fr));
  gap:clamp(20px,2.6vw,44px);
}

/* THE SLOT RATIO IS DELIBERATELY NOT TOUCHED, and this is the counter-intuitive
   part of the whole change — 3/4 was nearly overridden to 2/3 on the reasoning
   that "cover crops a 2:3 source, so uncrop it and the shoe gets bigger".
   Measured, that reasoning is wrong twice over.

   The slot is WIDER than the source (0.75 vs 0.667), so cover scales the image
   to match the cell WIDTH — factor cellWidth/1200 — and the leftover is height
   trimmed off the top and bottom. Which means the rendered shoe is exactly the
   same size at 3/4, 4/5 and 2/3; the ratio only decides how much empty sweep
   surrounds it. Going to 2/3 would have bought 12.5% more cell height and
   nothing else, on a page that is already four rows long.

   Nor is the trim taking shoe. At 3/4 the crop window is 1600 of the 1800
   rows, centred, so it keeps y=100..1700. The tallest subject in the set is
   the knee-high riding boot (p-leather-range-05, roughly y=380..1660) and it
   sits inside that window with room at both ends. 4/5 would tighten the window
   to y=150..1650 and start shaving that boot's sole.

   The photographs get bigger here because the CELL does: 286px wide at four-up
   becomes about 590px at two-up. That is the entire gain, and it is a 2.1x
   linear increase in the shoe. */

/* The stagger is a four-up gesture. It exists to stop a long row reading as a
   filmstrip, and at two-up there is no long row — what is left is two big
   cells at different heights, which reads as a misalignment rather than as a
   rhythm, and adds about 90px to every row of an already tall page. */
.prod-grid--two .prod:nth-child(even){ margin-top:0; }

/* Bigger cells sample the source far less aggressively, so the push-in has
   less headroom before it reveals softness: 403px against a 1200px file was a
   2.5x downsample, 640px is 1.9x. Pulled back to match. */
.prod-grid--two .prod:hover .slot > picture,
.prod-grid--two .prod:focus-visible .slot > picture{ transform:scale(1.04); }

/* --- L2. the two-up responsive ladder --------------------------------
   The shared grid turns into a sideways snap rail at 820px, on the reasoning
   that a phone-width four-up cell gives each shoe about 165px and stops being
   a product photograph. That reasoning is sound and it is exactly why the rail
   is wrong HERE: this grid exists to make the photographs large, and a
   62vw rail makes them small again on the one viewport with least room to
   spare.

   There is a second, unadvertised reason. The rail is only legible because
   site.js draws a .swipe-bar position indicator next to it — and that bar is
   found as the track's NEXT SIBLING. products.html and index.html have one;
   none of the four line pages do. So on a phone today these four grids are
   silent horizontal scrollers with no readout at all, and the slippers page
   shows one shoe with no signal that a second exists. Cancelling the rail is
   the fix; adding four swipe bars would only make a wrong device legible.

   Two-up holds TWO columns down to 560 rather than unstacking at 820. The
   rail's own note reasons from a four-up cell reaching 165px at phone width;
   a two-up cell is 260-390px through that whole band, which is wider than the
   four-up grid ships at on a desktop. One column is right only below 560.

   Every property the rail sets has to be handed back explicitly — grid-auto-*,
   the snap axis and the negative bleed do not reset themselves by re-declaring
   columns. Miss one and the grid keeps a stray bleed or a live snap axis. */
@media (max-width:820px){
  .prod-grid.prod-grid--two{
    grid-template-columns:repeat(2,minmax(0,1fr));
    grid-auto-flow:row;
    grid-auto-columns:auto;
    overflow-x:visible;
    overscroll-behavior-x:auto;
    scroll-snap-type:none;
    margin-inline:0;
    padding:0;
    scroll-padding-left:0;
  }
  .prod-grid.prod-grid--two > .prod{ scroll-snap-align:none; }
}
@media (max-width:560px){
  .prod-grid.prod-grid--two{ grid-template-columns:minmax(0,1fr); }
}

/* --- L2b. the reveal arrives by row ----------------------------------
   The shared cells ladder steps every child by .05s. At four-up that reads as
   a row sweeping in; at two-up children 1 and 2 are side by side and arrive
   50ms apart, which is below the threshold anyone notices — so the whole grid
   lands at once and the cadence is gone. Re-cut so a ROW is the unit and both
   its cells share a delay. Still stops at .35s, per the same argument the
   shared ladder makes about a reader reaching the bottom before it arrives.

   No new reduced-motion or no-JS guard: both existing ones target
   [data-aos="cells"] > * generically and already cover these children. */
.prod-grid.prod-grid--two[data-aos="cells"].is-in > :nth-child(-n+2){ transition-delay:0s; }
.prod-grid.prod-grid--two[data-aos="cells"].is-in > :nth-child(n+3):nth-child(-n+4){ transition-delay:.10s; }
.prod-grid.prod-grid--two[data-aos="cells"].is-in > :nth-child(n+5):nth-child(-n+6){ transition-delay:.20s; }
.prod-grid.prod-grid--two[data-aos="cells"].is-in > :nth-child(n+7){ transition-delay:.30s; }

/* --- L3. the viewer ---------------------------------------------------
   A native <dialog>, not the hand-rolled curtain .overlay uses. The curtain
   predates this and has no focus trap, no focus restore and no inert
   background; showModal() brings all three, and it renders in the top layer,
   which is the part that matters most here — the grain overlay sits at
   z-index 9998 and would otherwise be dusting the product photograph. Nothing
   in this block needs a z-index at all. */
.pv{
  padding:0; border:0; margin:auto;
  width:min(1440px,96vw); max-width:none;
  height:min(940px,92vh); max-height:none;
  overflow:hidden;
  background:var(--field); color:var(--ink);
}
.pv::backdrop{ background:rgba(1,34,61,.9); }

@keyframes pv-rise{ from{ opacity:0; transform:translateY(12px); } }
@keyframes pv-veil{ from{ opacity:0; } }
.pv[open]{ animation:pv-rise .34s var(--ease); }
.pv[open]::backdrop{ animation:pv-veil .34s var(--ease); }

.pv__inner{
  display:grid; grid-template-columns:minmax(0,1.62fr) minmax(0,1fr);
  height:100%; min-height:0;
}

/* --- L4. the image stage ---------------------------------------------
   inset:0 + margin:auto, NOT a centring grid, and the difference is not
   stylistic. As a grid item the photograph sits in an implicit auto row, so
   its own intrinsic height sizes the row and `max-height:100%` then resolves
   against that row rather than against the stage — measured, a 1200x1800 file
   rendered 855x1282 inside an 860px-tall stage and was simply clipped. Against
   an absolutely-positioned containing block the percentage resolves against
   the stage's padding box, which is what it has to mean here.

   Centring this way also keeps the transform origin in the middle of the
   frame, so the pan clamp is symmetric — half the overflow either side, and no
   bookkeeping about which edge is which. */
.pv__view{ display:grid; grid-template-rows:minmax(0,1fr) auto; min-height:0; min-width:0; }
.pv__stage{
  position:relative;
  overflow:hidden; min-height:0;
  background:var(--field-2);
  touch-action:none;            /* the pan gesture is ours, not the browser's */
  cursor:zoom-in;
}
.pv__stage.is-zoomed{ cursor:grab; }
.pv__stage.is-panning{ cursor:grabbing; }
/* The <img> is in the DOM before it has a src for the few milliseconds between
   building the dialog and filling it. Hiding it for that window costs one rule
   and removes the whole class of broken-image flash. */
.pv__img:not([src]){ visibility:hidden; }
.pv__img{
  position:absolute; inset:0; margin:auto;
  width:auto; height:auto; max-width:100%; max-height:100%;
  transform:translate(var(--pv-x,0px),var(--pv-y,0px)) scale(var(--pv-s,1));
  transition:transform .32s var(--ease);
  user-select:none; -webkit-user-drag:none;
}
.pv__stage.is-panning .pv__img{ transition:none; }
@media (prefers-reduced-motion:reduce){
  .pv[open],.pv[open]::backdrop{ animation:none; }
  .pv__img{ transition:none; }
}

/* --- L5. the strip under the photograph -------------------------------
   Caption and angle thumbnails on one ruled line — the same anatomy as
   .prod-cap, so the viewer reads as the card it opened from rather than as a
   separate widget.

   THE ARROWS AND THE ZOOM LEFT THIS STRIP on 2026-08-07. The client asked for
   "mũi tên và nút zoom", and all three were already here — as 34px squares in
   a row that also held five thumbnails and a counter. A control a client
   cannot find while looking for it is not a control. They are on the
   photograph now, at the edges they act on, which is where every other image
   viewer puts them; the strip keeps only what is genuinely a caption. */
.pv__bar{
  display:flex; align-items:center; gap:clamp(12px,1.6vw,24px);
  padding:12px clamp(14px,1.6vw,24px);
  border-top:1px solid var(--line);
}

/* --- L5b. the three controls on the photograph ------------------------
   One plate shared by the arrows and the zoom: a hairline box on a near-opaque
   paper ground. NOT a translucent scrim over the image — the sample pairs are
   shot on white and a dark chrome button on a white plate is the one pairing
   that would need its own contrast argument at every crop. .86 rather than 1
   so the photograph is visibly continuous underneath, and the blur keeps a
   busy crop from reading through the glyph. */
.pv__step,
.pv__zoom{
  position:absolute; z-index:2;
  width:clamp(40px,3.3vw,50px); aspect-ratio:1;
  border:1px solid var(--line);
  background:rgba(255,255,255,.86);
  -webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px);
  transition:background-color .3s var(--ease), border-color .3s var(--ease);
}
.pv__step:hover,
.pv__zoom:not([disabled]):hover{ background:var(--navy); border-color:var(--navy); }
.pv__step[disabled],
.pv__zoom[disabled]{ opacity:.3; cursor:default; }

/* The arrow is a masked ::before, not the button itself. As a masked BOX the
   button could carry no plate behind the glyph, which is the whole point of
   floating it over a photograph. */
.pv__step{ top:50%; transform:translateY(-50%); }
.pv__step::before{
  content:""; position:absolute; inset:0;
  background:var(--ink);
  -webkit-mask:var(--arrow) no-repeat center/19px;
  mask:var(--arrow) no-repeat center/19px;
  transition:background-color .3s var(--ease);
}
.pv__step:hover::before{ background:var(--paper); }
.pv__step--next{ right:clamp(10px,1.3vw,20px); }
/* scaleX AFTER translateY: transforms compose right-to-left, so the mirror has
   to be the inner one or it flips the centring offset too and the button lands
   half its height low. */
.pv__step--prev{ left:clamp(10px,1.3vw,20px); transform:translateY(-50%) scaleX(-1); }

/* The zoom control is a GLYPH now, against the note that used to live here:
   the icon sheet is a shell region sync-shell.js owns, so a page-level icon
   would mean re-syncing 24 pages — but this dialog is built in site.js and
   carries its own inline SVG, which costs those 24 pages nothing.

   Bottom-right, because top-right is where .pv__close sits and two square
   hairline boxes in one corner is a coin toss for the visitor. */
.pv__zoom{
  right:clamp(10px,1.3vw,20px); bottom:clamp(10px,1.3vw,20px);
  display:grid; place-items:center; color:var(--ink);
  transition:background-color .3s var(--ease), border-color .3s var(--ease), color .3s var(--ease);
}
.pv__zoom svg{ display:block; width:21px; height:21px; }
.pv__zoom:not([disabled]):hover{ color:var(--paper); }
/* +/− with nothing in script swapping markup: the magnifier's plus loses its
   vertical stroke once the stage is zoomed, and the accessible name is the
   only thing JS touches. */
.pv__stage.is-zoomed .pv__zoom .pv-plus-v{ display:none; }

/* The arrows sit at the vertical centre of the stage, which on a portrait
   sample pair is squarely on the shoe. Fading them back until the pointer is
   in the frame keeps the photograph the subject; they return to full strength
   on hover or focus, and :focus-visible-within is not a thing, so the button's
   own :focus-visible carries the keyboard case. */
@media (hover:hover){
  .pv__step{ opacity:.55; }
  .pv__stage:hover .pv__step,
  .pv__step:hover,
  .pv__step:focus-visible{ opacity:1; }
  .pv__step[disabled]{ opacity:.3; }
}

/* display:flex would otherwise beat the UA's [hidden]{display:none}, and the
   strip is hidden whenever a pair has only one photograph. */
.pv__thumbs[hidden]{ display:none; }
.pv__thumbs{ display:flex; gap:8px; margin:0; padding:0; list-style:none; }
.pv__thumb{
  width:44px; aspect-ratio:2/3; padding:0; overflow:hidden;
  border:1px solid var(--line); background:var(--field-2);
  transition:border-color .3s var(--ease);
}
.pv__thumb img{ width:100%; height:100%; object-fit:cover; display:block; }
.pv__thumb[aria-pressed="true"]{ border-color:var(--navy); border-width:2px; }
.pv__thumb:hover{ border-color:var(--navy); }
/* .pv__count lived here — "05 / 08" at the right end of this strip. Deleted
   2026-08-07 with the element: the prev/next buttons disable themselves at
   either end, which is the same fact where a visitor acts on it, and on a pair
   with one photograph the counter was the only tenant of a 58px band.
   display:flex on .pv__bar would beat the UA's [hidden]{display:none}, so the
   bar needs the same override its thumbnails already had. */
.pv__bar[hidden]{ display:none; }

/* --- L6. the sheet ----------------------------------------------------
   Its own scroll container, so a long spec table never pushes the photograph
   off the top of the dialog. Ruled, no fill, no shadow: the same object as
   every list on the site. */
.pv__sheet{
  overflow-y:auto; min-height:0;
  border-left:1px solid var(--line);
  padding:clamp(20px,2.4vw,38px);
  /* --g-group, down from --g-block (client, 2026-08-07: "giảm khoảng cách các
     cụm text"). 57.6px between the name, the description, the table and the
     exit line is the page's block rhythm, and this is not a page — it is a
     column inside a dialog where all four groups have to be readable as one
     object without scrolling. .pv__body takes the same step for the same
     reason. */
  display:grid; gap:var(--g-group); align-content:start;
}
.pv__head{ display:grid; gap:6px; }
.pv__head .idx{ font-size:10.5px; letter-spacing:.14em; color:var(--accent); }
/* The cloned fragment arrives as a description followed by a table, and a
   table's caption is rendered above it — so without a gap here the caption
   butts straight onto the last line of the paragraph and reads as a fourth
   sentence of it. */
.pv__body{ display:grid; gap:var(--g-group); align-content:start; }
.pv__sheet .spec{ min-width:0; }
.pv__sheet .spec th{ padding-right:16px; }
.pv__sheet .spec td{ padding-right:0; }
/* The spec comes down a step inside the viewer (client, 2026-08-07: "giảm
   font-size"). --text-small, not --text-body, and this is one of the places
   the small step is legitimately not a caption rule: these are row labels and
   short values — "Confirmed at sampling", "FOB, ex Bình Dương" — not running
   prose, and the panel is a 460px column beside a photograph rather than a
   page column. The description above the table keeps --text-body: it IS
   prose, and dropping it too would flatten the sheet into one texture.
   Row padding follows the type down; at 16px against 14px text the table was
   spending more height on air between rows than on the rows. */
.pv__sheet .spec th,
.pv__sheet .spec td{
  font-size:var(--text-small); line-height:var(--lh-tight);
  padding-top:11px; padding-bottom:11px;
}
.pv__desc p{ margin:0; color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body); }
.pv__desc p + p{ margin-top:var(--g-pair); }
.pv__out{ font-size:var(--text-small); line-height:var(--lh-tight); color:var(--ink-soft); }
.pv__out a{ color:var(--navy); border-bottom:1px solid var(--line); }
.pv__out a:hover{ color:var(--accent); border-bottom-color:currentColor; }

/* .form--tight lived here — the four-field enquiry form inside the viewer. The
   client cut the form on 2026-08-07 ("chỉ hiện thông số, không hiện form") and
   the rules went with it rather than being left behind: nothing in the site
   carries the class now, and a stylesheet that dresses markup no page emits is
   how a component gets rebuilt by accident. .pv__out above is the whole exit. */

/* --- L7. close ---------------------------------------------------------
   Drawn from two bars the way .btn-menu draws its X, because the icon sheet
   holds only the two social marks and adding a third glyph to it would mean
   editing a shell region that sync-shell.js owns. */
.pv__close{
  position:absolute; top:0; right:0; width:52px; height:52px;
  background:var(--field); border-left:1px solid var(--line); border-bottom:1px solid var(--line);
}
.pv__close::before,
.pv__close::after{
  content:""; position:absolute; left:50%; top:50%; width:17px; height:1.5px;
  background:var(--ink); transition:background-color .3s var(--ease);
}
.pv__close::before{ transform:translate(-50%,-50%) rotate(45deg); }
.pv__close::after{ transform:translate(-50%,-50%) rotate(-45deg); }
.pv__close:hover::before,
.pv__close:hover::after{ background:var(--accent); }

/* --- L8. the viewer on a narrow screen ---------------------------------
   One column, and the whole dialog becomes the scroll container rather than
   the sheet: two nested scrollers on a phone is a trap, because the inner one
   swallows the flick that was meant to move the page. */
@media (max-width:900px){
  /* display:block, not a one-column grid. Two auto rows inside a container
     with a definite height do not simply stack and overflow — the tracks get
     sized against the space available rather than against their content, and
     measured at 390x844 the sheet came back up underneath the view and the
     control bar sat on top of the description. Ordinary flow inside one
     scroller has no track sizing to get wrong.

     dvh rather than vh so the panel is not taller than the visible area while
     a mobile browser's address bar is on screen. */
  .pv{ width:100vw; height:100dvh; max-height:none; }
  .pv__inner{ display:block; height:100%; overflow-y:auto; }
  .pv__view{ display:block; }
  .pv__stage{ height:62vh; }
  .pv__sheet{ overflow:visible; border-left:0; border-top:1px solid var(--line); }
}

/* --- L9. utilities -----------------------------------------------------
   Accessible name for the sample cards. Without JavaScript the card is a link
   to the enquiry form with the style already named, so the accessible name has
   to say that; with JavaScript, the block rewrites aria-label to say it opens
   the viewer. Neither state describes the other's behaviour. */
.vh{
  position:absolute; width:1px; height:1px; padding:0; margin:-1px;
  overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0;
}

/* ============================================================
   LAYER M — CAPABILITIES REVISION, 2026-08-06
   Two archetypes the client's pass on capabilities.html asked for, written
   as vocabulary rather than as page overrides so the next page that needs a
   proportion or a two-column mapping extends these instead of forking them.
   Every value is an existing token; no new colour, no radius, no fill beyond
   the one gold segment that IS the data.
   ============================================================ */

/* --- M1. lines-note: the sentence that sets up a grid -----------------
   A caption without a table to hang off. .spec has `caption`; a .prod-grid
   has nowhere to put the "and here is what these numbers do not say" line
   that has to travel with it, and the client's four-line listing needs one. */
.lines-note{
  margin:var(--g-block) 0 var(--g-group);
  color:var(--ink-soft); font-size:var(--text-body); line-height:var(--lh-body);
  max-width:var(--measure-body);
}
.on-dark .lines-note{ color:rgba(247,245,242,.78); }

/* --- M2. ratio: one number shown as a share of another ---------------
   The machinery section's heading is "six hundred machines that do one job
   each", and until now nothing on the page showed what six hundred was a
   share OF. A track and a filled segment, at the same 4px weight as
   .sec-rail, so the site's one horizontal progress gesture reads the same
   whether it is measuring a section or a machine count.

   The BAR IS NOT THE DATA. Both figures are confirmed floors ("+"), so the
   40% is a reading of them, not a measurement — which is why the track is
   aria-hidden and the key underneath carries the numerals as text. Change
   the numbers and this width has to be re-derived by hand; there is
   deliberately no custom property inviting a page to set it from markup. */
/* --measure-narrow is the SAME token .feature__body p carries, and that is what
   keeps the bar's right edge on the paragraph's. Worth stating because an
   override was tried here and taken out again: when the copy column was still
   475px wide (the --pad-l cap bug, fixed above) the container bound both, and
   `max-width:none` looked like it aligned them. It only agreed at that one
   width — at 1680 the container opens to 653px and the paragraph stops at 52ch,
   so the bar ran 47px past the prose it was illustrating. Sharing the token
   holds at every width instead of at the one that was measured. */
.ratio{ margin-top:var(--g-block); max-width:var(--measure-narrow); }
.ratio__track{
  display:block; position:relative; width:100%; height:4px;
  background:var(--line); overflow:hidden;
}
.on-dark .ratio__track{ background:var(--line-light); }
/* Resting state is FULL, and the hidden state is gated on .js — the same
   failsafe [data-aos="lines"] carries and for the same reason. The <noscript>
   block reveals [data-aos] elements, not their descendants, so a fill parked
   at scaleX(0) by an unconditional rule would leave a script-dead page with
   an empty track and no way back. */
/* 50%, not the 40% this drew until 2026-08-14: the client's restated figures
   are 500+ single-purpose out of 1,000+ machines, where they were 600 of 1,500.
   THE WIDTH IS THE DATA — it has to be edited in the same pass as the two
   numerals in .ratio__key on capabilities.html, or the bar quietly argues a
   different proportion than the key beside it prints. */
.ratio__fill{
  position:absolute; inset:0 auto 0 0; width:50%;
  background:var(--accent); transform-origin:0 50%;
}
.js .ratio__fill{ transform:scaleX(0); transition:transform 1.05s var(--ease); }
.js .ratio.is-in .ratio__fill{ transform:scaleX(1); }
@media (prefers-reduced-motion:reduce){
  .js .ratio__fill{ transition:none; transform:scaleX(1); }
}
.ratio__key{
  list-style:none; margin:14px 0 0; padding:0;
  display:flex; justify-content:space-between; gap:20px;
}
.ratio__key li:last-child{ text-align:right; }
/* 40px, up from 27 (client, 2026-08-07: "tăng font-size số"). These two are the
   only numbers the machinery section states, and at 27px they were smaller than
   the .h-sub above them — the argument was outranking its own evidence. 40 puts
   them half a step under .figures' 80px band, which is the right order: that
   row is a section opening on scale, this is a footnote to a paragraph.
   No measurement risk here, unlike .figures: the key is a two-item flex with
   space-between in a 588px-plus track and the longest string is "1,500+", so
   there are ~350px spare. The 10.5px mono labels underneath are what actually
   set the cell width, and they are untouched. */
.ratio__key b{
  display:block; font-size:clamp(26px,2.8vw,40px); font-weight:420;
  letter-spacing:-.03em; line-height:1; color:var(--ink);
}
.on-dark .ratio__key b{ color:var(--paper); }
.ratio__key i{ font-style:normal; font-size:.5em; color:var(--navy); }
.on-dark .ratio__key i{ color:var(--accent); }
.ratio__key span{
  display:block; margin-top:8px;
  font-family:var(--f-mono); font-weight:500; font-size:10.5px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
}
.on-dark .ratio__key span{ color:var(--accent); }
/* The legend swatch, and the only reason the gold segment is legible as
   "the 600". It is a swatch rather than gold TYPE because brass measures
   3.4:1 on paper — fine for a 4px rule, not for a 10.5px label. */
.ratio__key li:first-child span::before{
  content:""; display:inline-block; width:14px; height:3px;
  background:var(--accent); vertical-align:middle; margin-right:9px;
  position:relative; top:-1px;
}

/* --- M3. mgroup: five machine groups as a photographed strip ----------
   THIRD LAYOUT FOR THIS CONTENT, and the note is kept whole because each pass
   was a different diagnosis:
     1. five rows × THREE columns of running prose. Client: "quá rối" — three
        blocks per row, each asking to be read a different way.
     2. five rows × two columns, the left naming the group and whispering its
        equipment, the right stating the buyer's consequence in full ink. That
        fixed the hierarchy and the client accepted the wording, then read the
        SHAPE as "dài dòng" (2026-08-07).
     3. this. Five columns, each opening on a photograph of the equipment.

   Pass 2's problem was measurable and it was not the copy: 857px of band for
   five facts none of which is longer than two lines. A grid row is as tall as
   its tallest cell, so a two-line left cell beside a one-line right cell left
   ~90px of empty paper per row, five times over. Turning the rows into columns
   spends that height on pictures instead — the same five groups, the same
   sentences, in a little over half the band.

   FIVE, matching the groups, and not four-plus-one or a 3+2 grid. An orphan
   row is what a five-item set does to any even grid, and the strip is also the
   one place on this page where the number five is the point: the section above
   it is five stages, and these are the machines that do them. */
.mgroup__cap{
  margin:0 0 var(--g-group); color:var(--ink-soft);
  font-size:var(--text-small); line-height:var(--lh-tight); max-width:var(--measure-body);
}
.on-dark .mgroup__cap{ color:rgba(247,245,242,.78); }
/* NO RULING AT ALL (client, 2026-08-07: "bỏ line"). Per-cell rules went with
   the rows in the rebuild; the one hairline above the run went a day later. The
   five photographs are the division — this is `.prod-grid`'s reading, where a
   grid of frames carries no rule either, and the caption above already marks
   where the strip starts. */
.mgroup{
  list-style:none; margin:0; padding:0;
  display:grid; grid-template-columns:repeat(5,minmax(0,1fr));
  gap:clamp(14px,1.6vw,26px);
}
/* EVERY new .slot context declares an aspect-ratio — a .slot with no height
   rule is a fill frame with nothing to fill and the photograph silently does
   not render. 4/3 rather than the 3/2 the sources are cut to: at this column
   width a wider crop makes a machine too small to identify, which is the only
   job these five pictures have. One source is portrait (p-leather-finish-01,
   900×1125) and object-fit:cover centre-crops it to the boot and the hands,
   which is the subject — checked, not assumed. */
.mgroup .slot{ aspect-ratio:4/3; }
/* Bold and navy (client, 2026-08-07: "tiêu đề in đậm màu xanh"). 650 is
   --w-hover, the weight this site already means by "bolder" — the same jump
   .cap-row, .prod-cap and .cert take on hover — so the strip's five names are
   set in a weight the page already owns rather than a new one. --navy is the
   same tint those hovers travel to, and it is the only strong colour available
   on paper: brass measures 3.4:1 on this ground, which is why the accent is
   never used for type here. */
.mgroup b{
  display:block; margin-top:14px;
  font-size:var(--text-title); font-weight:var(--w-hover);
  letter-spacing:-.02em; line-height:var(--lh-title); color:var(--navy);
}
.on-dark .mgroup b{ color:var(--paper); }
/* ONE description per cell, shorter and at the small step (client: "mô tả ngắn
   và nhỏ hơn, không ngắt dòng"). Two things happened here:
   · the equipment whisper (.mgroup__kit) was removed from the markup — the
     photograph above the name now shows what the equipment is, which is the
     job that line was doing in a text-only layout. Its wording is preserved in
     the HTML comment beside the strip if it is wanted back.
   · the surviving sentence is trimmed to its head clause — "Material yield and
     grain placement." rather than "Material yield, grain placement, and how a
     leather price converts into a pair price." No new claims, just the tail
     removed, so all five now set in two lines instead of four.
   --text-small IS legitimate here, against the rule that the small step is
   captions only: at ~223px this is a strip label under a picture, not running
   prose, and the sentences are now fragments of 34–36 characters. If they ever
   grow back into sentences this should go back to --text-body.
   text-wrap:balance, not pretty: `pretty` optimises the LAST line, which in a
   two-line fragment is what left "stages." alone on line two. `balance` evens
   both lines, which is the "không ngắt dòng" the client is asking for. And
   hyphens:manual stops "in-plant" splitting across lines mid-word. */
.mgroup > li > p{
  margin:10px 0 0; font-size:var(--text-small); line-height:var(--lh-tight);
  color:var(--ink-soft); text-wrap:balance; hyphens:manual;
}
.on-dark .mgroup > li > p{ color:rgba(247,245,242,.78); }
/* Below 1080 the strip becomes the sideways rail the four product tiles already
   become, and for the same reason: five columns of a 1000px viewport is 180px
   each, where a photograph of a machine stops being a photograph of a machine.
   Same bleed and the same scroll-padding correction as .prod-grid, so the two
   read as one device on a phone. [data-swipe] hands the position bar to the
   swipe-rails block in site.js; the bar only turns itself on when the track
   actually overflows, so it stays invisible at desktop width. */
@media (max-width:1080px){
  .mgroup{
    grid-template-columns:none; grid-auto-flow:column; grid-auto-columns:minmax(230px,58vw);
    overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x mandatory;
    margin-inline:calc(var(--pad) * -1); padding:var(--g-group) var(--pad) 18px;
    scroll-padding-left:var(--pad);
  }
  .mgroup > li{ scroll-snap-align:start; }
}
@media (max-width:560px){
  .ratio__key{ display:grid; grid-template-columns:minmax(0,1fr); gap:var(--g-group); }
  .ratio__key li:last-child{ text-align:left; }
}

/* ═════════════════════════════════════════════════════════════════════
   LAYER N — THE NEWS JOURNAL AS A BLOG (client, 2026-08-06)
   The milestones journal is gone: news is a conventional category-and-blog
   page now. One featured post, a ruled category bar that site.js upgrades
   to a filter, and a ruled card grid — the same rule-above-the-run
   construction as .m-tiles, because a blog card here is still a list cell,
   not a box. Articles swap the .p-story split for a centred single column
   in the .prose voice. The old .journal/.entry blocks (F5 and the journal
   links block) were deleted with this layer; their tag / read-more /
   underline-grow voices live on as .post__* below.
   ═════════════════════════════════════════════════════════════════════ */

/* --- N1. cat-bar: the category strip ---------------------------------
   Ruled cells, not pills: hairline above and below, border-right between
   cells, the mono wayfinding voice. The links are honest #posts anchors
   that the news-filter block in site.js upgrades in place; with no JS
   every post simply stays visible. Each label carries its count so the
   bar states how much there is before anything is clicked. */
.cat-bar{
  border-top:1px solid var(--line); border-bottom:1px solid var(--line);
  margin-bottom:0;
}
.cat-bar ul{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap; }
.cat-bar li{ border-right:1px solid var(--line); }
.cat-bar li:last-child{ border-right:0; }
.cat-bar a{
  display:inline-block; padding:15px clamp(16px,1.8vw,26px);
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
  transition:color .3s var(--ease);
}
.cat-bar li:first-child a{ padding-left:0; }
.cat-bar a b{ font-weight:500; color:var(--ink-soft); }
.cat-bar a:hover,
.cat-bar a:focus-visible{ color:var(--accent); }
.cat-bar a.is-active{ color:var(--accent); box-shadow:inset 0 -2px 0 var(--accent); }
/* The bar sits ON the grid (client, 2026-08-08: "thanh tabs sát với tin
   luôn"). Two things made the gap: the bar's own margin, and the grid's
   top rule sitting a --g-group below the bar's bottom rule — two hairlines
   drawing the same edge twice. The bar's bottom border is that edge now. */
.cat-bar + .post-grid{ border-top:0; }

/* --- N2. post-slider: the featured posts -----------------------------
   Client, 2026-08-08: the single featured post became a slider. It is a
   scroll-snap track, not a transform carousel — that way the native
   scroll IS the gesture (touch, trackpad, shift-wheel), the buttons only
   have to call scrollBy, and a script-dead page degrades to a strip the
   visitor can still swipe. [data-swipe] hands the position indicator to
   the existing swipe-rails block, which is the site's established "there
   is more of this sideways" device.

   No rules above or below the slide: the client asked for the horizontal
   hairline under the section head to go, and a slide edge that moves is
   not an edge worth drawing anyway. */
.post-slider__track{
  display:grid; grid-auto-flow:column; grid-auto-columns:100%;
  overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x mandatory;
  scrollbar-width:none;
}
.post-slider__track::-webkit-scrollbar{ display:none; }
.post-slider .post-feature{ scroll-snap-align:start; }
.post-slider__nav{
  display:flex; align-items:center; gap:12px;
  margin-top:var(--g-group);
}
/* Ships hidden and is un-hidden by site.js: with no script the buttons are
   dead controls, while the track itself still swipes natively. display:flex
   would otherwise beat the UA's [hidden]{display:none}. */
.post-slider__nav[hidden]{ display:none; }
.post-slider__count{
  margin-right:auto;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
/* Same object as the product viewer's stepper: a masked arrow in a hairline
   box, prev being the same glyph flipped. */
.post-slider__step{
  width:38px; height:38px; flex:none;
  border:1px solid var(--line);
  background:var(--ink);
  -webkit-mask:var(--arrow) no-repeat center/18px;
          mask:var(--arrow) no-repeat center/18px;
  transition:background-color .3s var(--ease);
}
.post-slider__step--prev{ transform:scaleX(-1); }
.post-slider__step:hover{ background:var(--accent); }
.post-slider__step[disabled]{ opacity:.3; cursor:default; }
.post-slider__step[disabled]:hover{ background:var(--ink); }
.post-feature{
  position:relative;
  display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);
  gap:clamp(28px,3.6vw,64px); align-items:center;
}
.post-feature__fig{ margin:0; }
.post-feature .slot{ aspect-ratio:16/10; }
.post-feature__title{
  margin:var(--g-pair) 0 0;
  font-family:var(--f-serif); font-optical-sizing:auto; font-weight:400;
  font-size:clamp(28px,3vw,44px); line-height:1.12; letter-spacing:-.02em;
  color:var(--ink); text-wrap:balance;
}
.post-feature__ex{
  margin:var(--g-group) 0 0; color:var(--ink-soft);
  font-size:var(--text-body); line-height:var(--lh-body);
  max-width:var(--measure-body); text-wrap:pretty;
}
@media (max-width:900px){
  .post-feature{ grid-template-columns:1fr; gap:var(--g-group); }
}

/* --- N3. post-grid: the ruled card grid ------------------------------
   m-tiles' construction: rule above the run, rule under each cell,
   border-right as divider, nothing filled. The hover sweep lands on the
   cell's BOTTOM edge like every other hairline sweep on the site. When
   the filter narrows the grid to one category (.is-filtered, set by
   site.js) the nth-child divider arithmetic no longer matches the
   visible cells, so the filtered state trades dividers for a plain
   column gap rather than showing rules in the wrong places. */
.post-grid{
  --cellpad:clamp(16px,1.8vw,26px);
  display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
  border-top:1px solid var(--line);
}
.post{
  position:relative; display:flex; flex-direction:column;
  padding:clamp(18px,2vw,28px) 0;
  border-bottom:1px solid var(--line);
}
.post:not(:nth-child(3n)){ border-right:1px solid var(--line); padding-right:var(--cellpad); }
.post:not(:nth-child(3n+1)){ padding-left:var(--cellpad); }
.post[hidden]{ display:none; }
.post-grid.is-filtered{ column-gap:calc(var(--cellpad)*2); }
.post-grid.is-filtered .post{ border-right:0; padding-inline:0; }
/* The sweep, brought back into line with the homepage (client, 2026-08-08:
   "tăng height của line vàng ở dưới cho đồng bộ với home"). It was the only
   one on the site drawn at its declared 2px: .cap-row::before, .cert::before,
   .prod-cap::before and .route-list a::before all land at 2px × scaleY(2) = 4,
   and they take .7s doing it. Same origin as those — `left bottom`, not the
   bare `left` this had, or the scaleY would grow the rule downward out of the
   cell instead of upward off its own baseline. */
.post::after{
  content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;
  background:var(--accent); transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
.post:hover::after,
.post:focus-within::after{ transform:scaleX(1) scaleY(2); }
.post__fig{ margin:0; }
.post .slot{ aspect-ratio:3/2; }
.post__meta{
  margin:var(--g-pair) 0 0;
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--ink-soft);
}
.post__meta b,
.post__tag{ font-weight:500; color:var(--accent); }
.post__title{
  margin:var(--g-pair) 0 0;
  font-size:var(--text-title); font-weight:500;
  letter-spacing:-.02em; line-height:var(--lh-title); color:var(--ink);
}
.post__ex{
  margin:var(--g-pair) 0 0; color:var(--ink-soft);
  font-size:var(--text-body); line-height:var(--lh-body); text-wrap:pretty;
}
/* THE WHOLE CARD IS THE LINK (client, 2026-08-08: "click vào đâu trong
   card blog cũng bấm vào dc"). Done with the title's own ::after stretched
   over the cell rather than by wrapping the card in an <a>: a wrapper would
   swallow the excerpt and the meta into the link's accessible name, and
   nesting the read-more inside it would be invalid. So there is exactly ONE
   link per card, named by the title, and the read-more line is a span — a
   visual affordance, not a second tab stop to the same URL. It still turns
   gold with the card because the hover is driven off the cell. */
.post__more{ margin:14px 0 0; padding-top:0; }
.post__more .mono,
.post-feature__more .mono{
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
  transition:color .3s var(--ease);
}
.post:hover .post__more .mono,
.post:focus-within .post__more .mono,
.post-feature:hover .post-feature__more .mono,
.post-feature:focus-within .post-feature__more .mono{ color:var(--accent); }
.post__more .arrow,
.post-feature__more .arrow{ margin-left:6px; }
.post__title a::after,
.post-feature__title a::after{
  content:""; position:absolute; inset:0; z-index:1;
}
/* Client, 2026-08-08: "hover tiêu đề in đậm màu vàng không gạch chân". The
   growing underline these titles carried over from the old journal headings
   is gone, and the card now answers a pointer the way the whole rest of the
   site does — the title takes the colour of the rule that just drew under it,
   plus --w-hover of weight (a.cap-row .h-sub, .cert h3, .prod-cap .h-sub,
   .route-list b, .marks b). Gold here rather than the navy those four use,
   because THIS cell's sweep is gold; the rule is "match your own sweep", not
   "always navy".

   No transition on the weight, deliberately, and this is the exception the
   .cap-row block anticipated: Jakarta is variable, so 500 → 650 interpolates,
   but every frame of it is a layout pass. That is affordable on a three-word
   heading and not on a two-line card title in a fifteen-cell grid. The weight
   snaps, the colour keeps the fade — which is exactly the fallback that block
   names, and not a text-shadow fake.

   THE WEIGHT IS ON THE GRID CARD ONLY. The featured slide takes the gold and
   nothing else, and that is a measurement, not an oversight: .post__title is
   500-weight Jakarta at --text-title, where 650 re-set all 15 cards to the
   identical line count at 1440, 1024, 760 and 390. .post-feature__title is
   400-weight Fraunces at up to 44px with text-wrap:balance, which puts those
   headlines hard against a wrap boundary — at 1024 the slide titles go two
   lines to three at every step tested, 450 included, not just at 650. A slide
   that grows a line under the pointer shoves its own excerpt and read-more
   down; there is no weight that buys the gesture there, so the featured title
   is not given one. Re-measure this, do not reason about it, if the type
   scale or the slide's column split ever changes. */
.post__title a,
.post-feature__title a{
  color:inherit; text-decoration:none;
  transition:color .5s var(--ease);
}
.post:hover .post__title a,
.post:focus-within .post__title a,
.post-feature:hover .post-feature__title a,
.post-feature:focus-within .post-feature__title a{ color:var(--accent); }
.post:hover .post__title a,
.post:focus-within .post__title a{ font-weight:var(--w-hover); }
@media (max-width:900px){
  .post-grid{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .post:not(:nth-child(3n)){ border-right:0; padding-right:0; }
  .post:not(:nth-child(3n+1)){ padding-left:0; }
  .post:not(:nth-child(2n)){ border-right:1px solid var(--line); padding-right:var(--cellpad); }
  .post:not(:nth-child(2n+1)){ padding-left:var(--cellpad); }
  .post-grid.is-filtered .post{ border-right:0; padding-inline:0; }
}
@media (max-width:560px){
  .post-grid{ grid-template-columns:1fr; }
  .post{ border-right:0 !important; padding-inline:0 !important; }
}

/* --- N4. the pager ---------------------------------------------------
   Client, 2026-08-08: twelve posts to a page. Built by site.js from the
   cards that are actually in the grid, so the numbers can never disagree
   with the content — and absent entirely without JS, where every post is
   already on the page and a pager would be a control for nothing.

   Boxed, unlike every list on this site, and deliberately: these are
   controls, and the hairline box is the same object as .pv__zoom and
   .pv__step in the product viewer. */
.pager{
  display:flex; justify-content:center; align-items:center;
  flex-wrap:wrap; gap:8px;
  margin-top:var(--g-block);
}
.pager__num{
  min-width:38px; height:38px; padding:0 10px;
  border:1px solid var(--line);
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.1em; color:var(--navy);
  transition:color .3s var(--ease), border-color .3s var(--ease);
}
.pager__num:hover{ color:var(--accent); border-color:var(--accent); }
.pager__num[aria-current="page"]{ color:var(--accent); border-color:var(--accent); }
.pager__step{
  width:38px; height:38px; flex:none;
  border:1px solid var(--line);
  background:var(--ink);
  -webkit-mask:var(--arrow) no-repeat center/18px;
          mask:var(--arrow) no-repeat center/18px;
  transition:background-color .3s var(--ease);
}
.pager__step--prev{ transform:scaleX(-1); }
.pager__step:hover{ background:var(--accent); }
.pager__step[disabled]{ opacity:.3; cursor:default; }
.pager__step[disabled]:hover{ background:var(--ink); }

/* --- N5. the article page --------------------------------------------
   Client, 2026-08-08: wider text column, a table of contents, no plate
   under the title. The body measure goes 58ch → 68ch and the contents
   list takes the left margin as a sticky column on desktop, so the
   "content cluster" the client asked to widen is the pair, not just the
   paragraph. The dateline lives in its own mono row under the h1. */
.post-meta{
  margin:var(--g-pair) 0 0;
  font-family:var(--f-mono); font-weight:500; font-size:12px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.post-meta + .lede{ margin-top:var(--g-pair); }
.article-wrap{ display:grid; gap:var(--g-block); align-items:start; }
/* 58ch → 68ch → 78ch (client, three passes, the last on 2026-08-08). The
   cap is set on .article-body, whose font-size is the body's 16.6px, NOT on
   .prose at 15.5px — so a `ch` here is ~11.3px and 78ch draws ~882px, not the
   ~700 the number reads like ([[measure-tokens-noop-in-two-up-cells]]). That
   is all but ~30px of the 912px text column at 1440, which is the point: the
   column, not the token, is the real ceiling now. */
.article-body{ max-width:min(100%,78ch); }
/* .prose carries its own 58ch cap, which would win inside a wider parent. */
.article-body .prose{ max-width:none; }
.article-body .h-sub{ scroll-margin-top:calc(var(--hdr-h) + 24px); }
.article-fig{ margin:0; }
.article-fig .slot{ aspect-ratio:16/10; }

/* THE SPACING THIS BLOCK EXISTS FOR. `.prose p{margin:0}` is (0,1,1) and
   `.prose > * + *` is only (0,1,0), so a paragraph that follows anything
   OTHER than a paragraph — a subhead, a figure — lost its top margin and ran
   flush into it. `.prose > p + p` was written for exactly this reason and
   only covers the paragraph-after-paragraph case. `> * + p` is (0,1,1) and
   later in the sheet, so it wins the tie and covers the rest.

   The subhead then takes its space back deliberately: a big gap ABOVE it and
   a small one below, so the heading reads as belonging to the text it
   introduces rather than floating between two blocks. */
.article-body .prose > * + p{ margin-top:var(--g-group); }
.article-body .prose > .h-sub{ margin-top:calc(var(--g-group) * 1.7); }
.article-body .prose > .h-sub + p{ margin-top:14px; }
.article-body .prose > .article-fig{ margin-top:var(--g-block); }
.article-body .prose > .article-fig + p{ margin-top:var(--g-block); }

/* The contents list: ruled rows, numerals in the folio's voice, never a
   boxed panel. Sticky only where there is a margin to be sticky in.

   No rule ABOVE the label (client, 2026-08-08 — the third "bỏ line" of the
   week, see [[facts-sheets-are-losing-their-rules]]). The row dividers stay:
   they separate entries from each other, which is the job. The one above the
   word CONTENTS separated the list from nothing at all — there is only white
   space over it in the right margin. */
.toc{ padding-top:0; }
.toc__label{
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft);
}
.toc ol{ list-style:none; margin:var(--g-pair) 0 0; padding:0; counter-reset:toc; }
.toc li + li{ border-top:1px solid var(--line); }
.toc a{
  display:grid; grid-template-columns:26px minmax(0,1fr); align-items:baseline;
  padding:10px 0; font-size:13.5px; line-height:1.35;
  color:var(--ink-soft); transition:color .3s var(--ease);
}
.toc a::before{
  counter-increment:toc; content:counter(toc,decimal-leading-zero);
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.1em; color:var(--accent);
}
.toc a:hover,
.toc a:focus-visible{ color:var(--accent); }
/* The section being read, set by site.js' 'toc scrollspy' block. Gold and
   bold — the two things this list had spare, since the numerals were the only
   accent in it and every entry sat at the same weight.

   The weight lands on .toc__t, not on the <a>. The title is the second cell
   of a two-column grid, and as a bare text node it would be an ANONYMOUS grid
   item — unstylable — so the span is what makes "bold the title only" a thing
   that can be said at all. Two reasons it has to be said: the numeral is mono
   500 and smears at 600, and the whole list is inside a sticky column, where a
   row that rewraps as it lights up shoves every entry under it down the margin
   while the reader is looking somewhere else. A variable-font weight step this
   small does not rewrap a 240px column at these lengths — verified — but the
   span is what keeps that true when a longer subhead is written. */
.toc a.is-current{ color:var(--accent); }
.toc a.is-current .toc__t{ font-weight:600; }
/* Client, 2026-08-08: the contents moved to the RIGHT margin and the text
   went flush left, so an article opens on its first sentence rather than on
   a list of its own subheads. The markup order is untouched — .toc still
   comes first in the DOM, which is where a keyboard or screen-reader visitor
   wants a skip-ahead nav — and only the placement is reversed here. Explicit
   grid-row:1 on both, because auto-placement would otherwise put the
   out-of-order .toc on its own row. */
@media (min-width:1080px){
  .article-wrap{
    grid-template-columns:minmax(0,1fr) minmax(0,240px);
    gap:clamp(32px,4vw,80px);
  }
  .article-body{ grid-column:1; grid-row:1; }
  .toc{
    grid-column:2; grid-row:1;
    position:sticky; top:calc(var(--hdr-h) + clamp(18px,4vh,54px));
  }
}
/* The byline block. It was three unlabelled mono fragments on one flex row
   — "WRITTEN BY THE NHẬT MINH TEAM  FILED UNDER SUSTAINABILITY  2 DECEMBER
   2025" — all in the same 11px uppercase voice, with only a gap between
   them. Nothing marked where one fact ended and the next began, and the
   category link was indistinguishable from the words around it.

   Labelled pairs instead, in the footer contact dl's construction: the
   folio's mono for the label, the reading voice for the value. Two type
   sizes doing two different jobs is what makes the row parse at a glance;
   the labels also give the date a name, which "2 December 2025" alone never
   had. Top rule only — no cells, no vertical dividers (house rule: ruled,
   never boxed, and this band is short enough not to need the ruling). */
.article-foot{
  margin-top:calc(var(--g-group)*2); padding-top:var(--g-group);
  border-top:1px solid var(--line);
  display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
  gap:var(--g-pair) clamp(20px,2.6vw,44px);
}
.article-foot > div{ display:grid; align-content:start; gap:7px; }
.article-foot dt{
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft);
}
.article-foot dd{
  margin:0; color:var(--ink);
  font-size:var(--text-small); line-height:var(--lh-tight);
}
/* The site's inline-link voice (.contact-direct): navy word, gold rule under
   it, so the one clickable value in the band announces itself as clickable. */
.article-foot a{
  color:var(--navy); text-decoration:underline;
  text-decoration-color:var(--accent); text-decoration-thickness:1px; text-underline-offset:.22em;
  transition:color .35s var(--ease);
}
.article-foot a:hover,
.article-foot a:focus-visible{ color:var(--accent); }


/* ═════════════════════════════════════════════════════════════════════
   LAYER O — OEM/ODM REVISION (client, 2026-08-08)
   The page de-texted and visualized. Four pieces: the workflow rail
   becomes the third pinned track (.wf), the claim band gets a centred
   pull-quote variant, the brief checklist opens out of its accordion
   into a numbered ruled grid, and the numbers table became a .figures
   band (no CSS here — .figures already exists). The old .rail family
   this replaces was deleted from the sub-page layer; oem-odm was its
   only consumer.
   ═════════════════════════════════════════════════════════════════════ */

/* --- O1. wf: the workflow as a pinned track — REMOVED 2026-08-07 -----
   The .wf family (pinned well, meter, sideways track, seven wide cards)
   lived here until the client read the page as "rất rối". The pin alone
   was three viewport-heights of scroll and made the seven steps a
   horizontal drag; both are gone. The steps are now cells of the shared
   .cellgrid in Layer Q, and the page keeps no pinned track — [data-pin]
   in site.js still serves index.html and about.html untouched. */

/* --- O2. the claim band as a centred pull-quote ----------------------
   oem-odm's instance only; the composed two-column band and the K5
   claim-stack stay untouched for the other twelve pages. The modifier
   returns the container to block flow and removes the composed band's
   two pseudo-rules — the head hairline would draw across the quote and
   the :has(.statement__cite) divider would float with no meta column
   beside it. Decor is a ghost serif quote mark behind the sentence and
   nothing else; the mark is a real aria-hidden span, not pseudo content,
   so no screen reader ever announces a stray quotation mark.

   The centred gold rule that used to sit under the mark came out on
   2026-08-07 ("bỏ line dưới dấu"). It was also the only thing holding
   the mark's height open, so the space it kept is now padding on the
   block — remove that and the ghost quote sets straight through the
   first line of the sentence. */
.p-claim--quote > .container{ display:block; }
.p-claim--quote > .container::before,
.p-claim--quote > .container::after{ content:none; }
.p-claim__quote{
  position:relative; text-align:center;
  padding-top:clamp(46px,6vw,88px);
}
.p-claim__mark{
  position:absolute; left:50%; top:0; transform:translate(-50%,-32%);
  z-index:0; pointer-events:none; user-select:none;
  font-family:var(--f-serif); font-optical-sizing:auto; font-weight:400;
  font-size:clamp(120px,16vw,220px); line-height:1;
  color:var(--accent); opacity:.12;
}
/* 26ch broke the 108-character sentence over four lines and made a wide
   band look like a narrow column (client, 2026-08-07: "tăng width").
   52ch sets it in two at desktop and still measures under the container,
   so the cap is doing work rather than being a no-op. */
.p-claim--quote .statement{
  position:relative; z-index:1;
  margin-inline:auto; max-width:min(100%,52ch);
  font-size:clamp(28px,3.6vw,48px); font-weight:500; line-height:1.16;
}
.p-claim--quote .statement__cite{ position:relative; z-index:1; }

/* The numbers section closes its figures band with a prose note; without
   a seam the note reads as a fourth caption run into the first column.
   Same argument as the K6 seam list, scoped to the one new adjacency. */
.figures + .prose{ margin-top:var(--g-group); }

/* --- O3. check-grid — SUPERSEDED 2026-08-07 --------------------------
   The three-column numbered grid for the brief. Its arithmetic survives
   verbatim as .cellgrid in Layer Q, which the workflow now shares; the
   only things that died with the name are the third column and the
   "if you do not have it" line the client asked to lose. */


/* ═════════════════════════════════════════════════════════════════════
   LAYER P — ABOUT REVISION (client, 2026-08-07)
   Four notes on about.html, all of them subtractive: the story photograph
   comes down in height, and three runs of hairlines lose the rules the
   client is reading as clutter. Every selector here is page- or
   block-scoped for the reason recorded at .stagestack .step__facts —
   .split and .step__facts are the site's shared vocabulary, and unruling
   them at the archetype would silently restyle a dozen pages nobody was
   looking at.
   ═════════════════════════════════════════════════════════════════════ */

/* --- P1. the story photograph, shorter ------------------------------
   4/5 is the portrait every .split on the site uses, and it is right
   where the body column beside it is long. In 01 Story the body just
   lost its lede, so the picture was the tallest thing in the section by
   some way. 1/1 rather than a landscape crop: the frame holds a pair of
   hands at a stitching needle, and 3/2 would cut the machine head out of
   the top of it. .slot is a fill frame — the photograph re-crops inside
   the new ratio, it does not letterbox. Scoped to .p-story so the other
   nineteen .split media columns keep their portrait. */
.p-story .split__media > .slot{ aspect-ratio:1/1; }

/* --- P2. vision & mission: the middle rule only ---------------------
   The band was ruled the way .marks and .step__facts are — hairline above
   the run, hairline under every row — which drew three lines for two
   statements and boxed them in against the .split above. Client: keep the
   one between the pair. What remains is a divider rather than a frame,
   and the band now reads as part of 01 Story instead of as a table
   appended to it. The row padding stays: it is what separates the two
   statements now that only one line does. */
.vm{ border-top:0; }
.vm__row:last-child{ border-bottom:0; }

/* --- P3 / P4. the two fact sheets, unruled --------------------------
   Same edit as .stagestack took on 2026-08-07 ("bỏ line đi nhìn cho đỡ
   rối"), applied to the two runs on this page, and scoped the same way.

   P3, the timeline's certificates cell: the card already carries a year,
   a heading and a paragraph inside a navy well, so the rule under all of
   that was the fourth horizontal line in a 460px box. Note this run is
   already stacked (label above value, J3 above) — the mono label is doing
   the structural work, so losing the rule costs nothing.

   P4, the leadership sheet: three ruled rows sitting directly under two
   paragraphs read as a stray table. Unruled they read as what they are,
   the seniority claim itemised. The 150px label column stays in both, so
   the values still align on their own axis.

   margin-top on the run is untouched in both — that gap is what rules the
   sheet off from the prose now. */
.tl-step .step__facts li,
.p-leadership .step__facts li{ border-top:0; padding-top:0; }
.tl-step .step__facts,
.p-leadership .step__facts{ gap:10px; }


/* ═════════════════════════════════════════════════════════════════════
   LAYER Q — OEM/ODM DE-CLUTTER (client, 2026-08-07)
   "Tổng thể trang này đang rất rối." Measured, the page was 10,704px at
   1440 and ran eight different devices over five sections. Four notes
   here, and the first one is the whole argument: sections 02 and 03 were
   two different shapes — a pinned sideways track and a three-column
   ruled grid — carrying the same kind of content, a numbered list. They
   are one device now. Fewer voices on a page is what "less cluttered"
   actually means; the rest of this layer is spacing that follows from it.
   ═════════════════════════════════════════════════════════════════════ */

/* --- Q1. cellgrid: one ruled, numbered grid for both runs ------------
   The retired .check-grid arithmetic at four columns. Ruled, never
   boxed: hairline above the run, hairline under every cell, border-right
   as the divider — and the divider is written :not(:nth-child(4n))
   rather than li + li because the run wraps, and a sibling rule would
   put a stray vertical at the start of every row after the first.

   :last-child unrules the tail. Seven cells in a four-column grid leave
   the eighth slot empty, and without this the seventh cell draws a
   divider against nothing — the one place the old three-column version
   looked unfinished, now fixed rather than inherited.

   The numeral is a CSS counter, so 01–07 survives any reorder (same
   trick as .m-tiles); nothing in the markup types a number. */
.cellgrid{
  --cellpad:clamp(18px,2vw,32px);
  list-style:none; margin:0; padding:0;
  counter-reset:cell;
  display:grid; grid-template-columns:repeat(4,minmax(0,1fr));
  border-top:1px solid var(--line);
}
.on-dark .cellgrid{ border-top-color:var(--line-light); }
.cellgrid > li{
  counter-increment:cell;
  display:grid; align-content:start; gap:var(--g-pair);
  padding:var(--g-group) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .cellgrid > li{ border-bottom-color:var(--line-light); }
.cellgrid > li:not(:nth-child(4n)){ border-right:1px solid var(--line); padding-right:var(--cellpad); }
.on-dark .cellgrid > li:not(:nth-child(4n)){ border-right-color:var(--line-light); }
.cellgrid > li:not(:nth-child(4n+1)){ padding-left:var(--cellpad); }
.cellgrid > li:last-child{ border-right:0; }
.cellgrid > li::before{
  content:counter(cell,decimal-leading-zero);
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(24px,2.2vw,32px); line-height:1;
  letter-spacing:-.02em; color:var(--accent);
}
.cellgrid > li > b{
  font-size:var(--text-title); line-height:var(--lh-title); font-weight:500;
  letter-spacing:-.02em; color:var(--ink);
}
.on-dark .cellgrid > li > b{ color:var(--paper); }
/* No measure cap. Measured, the cell is 302px at 1440 — about 24ch of body
   copy, well inside any measure worth setting, so a ch cap here would be a
   no-op that only looked like typography. The column is the measure. */
.cellgrid > li > p{
  margin:0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}
.on-dark .cellgrid > li > p{ color:rgba(247,245,242,.78); }

/* The note cell. Section 03 closes with a sentence that used to hang
   under the grid as a .prose block; dropped into the eighth slot it
   fills the hole the seven cells leave and stops the run looking
   truncated. It carries no numeral, so :before is suppressed and the
   counter never advances past 07. */
.cellgrid > li.cellgrid__note{
  counter-increment:none;
  align-content:end;
}
.cellgrid > li.cellgrid__note::before{ content:none; }
.cellgrid__note p{
  margin:0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}

/* --- Q2. cellgrid--steps: the same grid on navy, with the icons ------
   Section 02 keeps what was worth keeping from the pinned track: the
   line-drawn icon per step, in the accent voice, inline in the page
   because shell:icon-defs is a shared verbatim region. The numeral drops
   a tier from the track's 76px — that size was set against a 520px card
   and breaks a 330px column. */
.cellgrid--steps > li{ gap:clamp(8px,.9vw,13px); }
.cellgrid--steps > li > b{ margin-top:4px; }
.cellgrid__ico{
  display:block; width:clamp(30px,2.3vw,38px); color:var(--accent);
  margin-bottom:clamp(6px,.8vw,12px);
}
.cellgrid__ico svg{ display:block; width:100%; height:auto; }

@media (max-width:900px){
  .cellgrid{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .cellgrid > li:not(:nth-child(4n)){ border-right:0; padding-right:0; }
  .cellgrid > li:not(:nth-child(4n+1)){ padding-left:0; }
  .cellgrid > li:not(:nth-child(2n)){ border-right:1px solid var(--line); padding-right:var(--cellpad); }
  .on-dark .cellgrid > li:not(:nth-child(2n)){ border-right-color:var(--line-light); }
  .cellgrid > li:not(:nth-child(2n+1)){ padding-left:var(--cellpad); }
  .cellgrid > li:last-child{ border-right:0; }
}
@media (max-width:560px){
  .cellgrid{ grid-template-columns:1fr; }
  .cellgrid > li{ border-right:0 !important; padding-inline:0 !important; }
}

/* --- Q3. the merged 01: routes and terms in one section --------------
   Terms was its own numbered section until the client asked for the page
   to lose one. It reads as the second half of Routes rather than as a
   fifth argument — both answer "what am I buying and who carries the
   risk" — so it lives inside 01 now.

   It began as a .terms-band sub-band with its own .statement head, and
   that head was deleted the same day: it said what the feature's h-sub
   already said, and the two of them were 290px of section spent
   restating one sentence. The half is the bleed feature and nothing
   else, which is why no band selector survives here. #terms moved onto
   the feature — capabilities.html deep-links to it and the link should
   land on the argument.

   NO rule between the halves. One was drawn and taken out on sight:
   .m-tiles already closes on a full-width hairline, so a second line
   under it read as a double rule — exactly the clutter this revision
   exists to remove. The separation is distance, and it has to be big
   enough to do the job the line was doing. */
.p-routes .feature--bleed{ margin-top:calc(var(--g-section) * 1.5); }

/* --- Q4. the routes as photographs (client, 2026-08-07) --------------
   "Section 01 quá dài => làm gọn hơn rồi visualize lại." .m-tiles is the
   ruled photo three-up the making section left behind — it had been in
   this sheet with no consumer, so the routes reuse it rather than adding
   a fourth grid to the page. The modifier does one thing: the tile's
   counter numeral is a sequence marker, and three routes are a set of
   parallel choices, so it is replaced by the route's own label. The <i>
   is a real element rather than pseudo content because the label is copy
   a screen reader should read. */
.m-tiles--kick figcaption::before{ content:none; }
.m-tiles--kick figcaption i{
  display:block; margin-bottom:6px; font-style:normal;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}

/* --- Q5. the workflow steps lose their numerals ----------------------
   Client, 2026-08-07: "section 02 bỏ số." The icons and reading order
   carry the sequence now, and the heading still says seven steps. Scoped
   to --steps: the brief below is a checklist a buyer works through with
   a colleague and refers to by number, so it keeps its 01–07. The
   counter itself is left running, so restoring the numerals is one
   declaration rather than a rebuild. */
.cellgrid--steps > li::before{ content:none; }

/* --- Q6. the ghost quote mark sits lower ----------------------------
   Client, 2026-08-07: "đẩy dấu ” xịch xuống dưới." At -32% the glyph's
   ink — which lives in the top third of a serif quote's em box, the rest
   being bearing — floated clear of the sentence and read as a stray mark
   at the top of the band rather than as something behind the quote. It
   now overlaps the first line, which is what a ghost is for. */
.p-claim__mark{ transform:translate(-50%,-4%); }

/* --- Q7. the route tiles are height-set, not ratio-set ---------------
   A HEIGHT here, not the archetype's aspect-ratio:4/3, and the reason is
   a bug .m-tiles had never shown because it had no live consumer: its
   cells are not equal width. The first has no left inset and the last no
   right one, so at 1440 the three measure 377 / 351 / 351 — with a ratio
   that makes the first photograph 17px taller than the other two and
   puts three captions on two different baselines. Measured, not guessed.

   A fixed height makes every frame identical whatever its cell measures,
   and .slot is a fill frame — the photograph re-crops inside it, it does
   not letterbox (the frame still has a definite size, which is the thing
   .slot actually requires). It also takes 285px of photograph down to
   about 230, which is most of what the client meant by "quá dài".

   Scoped to .p-routes: the archetype keeps its ratio for whatever uses
   it next, along with its uneven cells. */
.p-routes .m-tiles .slot{ aspect-ratio:auto; height:clamp(180px,16vw,240px); }

/* --- P5. the feature body's vertical rhythm --------------------------
   Client, 2026-08-07, on 03 Leadership: "khoảng cách các loại text chưa
   đồng bộ, nhiều loại text trong 1 đoạn nhìn rối." Measured at 1440px the
   block ran 16 / 0 / 34 / 16 between its five parts, so the sub-head was
   welded to its first paragraph while the two paragraphs sat 34px apart —
   the heading read as the paragraph's first line, and the fact sheet read
   as the second paragraph's tail.

   THE ZERO IS A SPECIFICITY BUG, not an authored value. .feature__body
   sets `> * + * { margin-top:var(--g-pair) }` (0,1,0), and thirty lines
   later .feature__body p sets `margin:0` (0,1,1) for the measure and the
   colour. The type selector wins, the shorthand takes margin-top with it,
   and every first paragraph in the component loses its gap silently.
   p + p only looks right because it re-declares the margin at (0,1,2).
   Twelve blocks across six pages have been running this way.

   Restated rather than repaired at the source: dropping `margin:0` would
   hand every paragraph the UA's 1em back, which is the same class of
   invisible regression as the UA figure margin. Two explicit rules, and
   the rhythm is now 16 / 16 / 34 / 34 — label to head and head to body
   both take --g-pair, and everything that is a NEW BLOCK rather than a
   continuation takes --g-group. Same three-tier ladder the rest of the
   site uses, so nothing here is a per-treatment number. */
.feature__body .h-sub + p{ margin-top:var(--g-pair); }
/* The fact sheet is a different object from the prose above it, so it
   takes the block step, not the label step. This mattered less when a
   hairline sat on top of the run; on about.html P3/P4 removed that rule,
   and 16px of clear space is not enough on its own to stop the sheet
   reading as a fourth paragraph. Applied at the component so the pages
   that kept their rule (certifications, markets, sustainability) hold the
   same rhythm — the client's objection is that these gaps disagree with
   each other, and fixing it on one page would be the same disagreement
   moved somewhere they have not looked yet. */
.feature__body .step__facts{ margin-top:var(--g-group); }
/* Third victim of the same shorthand, found by the same measurement:
   .cert-note is a <p>, it authors `margin:var(--g-group) 0 0` at (0,1,0),
   and .feature__body p's `margin:0` at (0,1,1) has been eating it. On
   markets.html that note — the one the sheet above it says must stay
   legible — sits flush against the fact sheet with a 0px gap and a gold
   rule down its left edge. Restored to the value it already asked for. */
.feature__body .cert-note{ margin-top:var(--g-group); }

/* ═════════════════════════════════════════════════════════════════════
   LAYER R — MARKETS DE-CLUTTER (client, 2026-08-07)
   "Tổng thể trang này đang rất rối." The same sentence that produced
   Layer Q for oem-odm, and the same answer: the page was 9,426px at
   1440 running ELEVEN devices — reach map, ticker, spec table, three
   full-bleed plates, two features, a claim band, an accordion, a
   counter wall and a marks strip — over seven sections.

   Three of those are gone in the markup (the claim band, one plate,
   the accordion; each carries its own note in markets.html) and the
   spec table became the geo grid below. What is left here is the
   spacing and the two new/retuned components. Everything is scoped to
   this page's section classes: .reach-map and .marks are shared, and a
   client looking at one page must not silently restyle six others.
   ═════════════════════════════════════════════════════════════════════ */

/* --- R1. 01 Reach: the map box was mostly empty ----------------------
   "Khoảng cách giữa các cụm quá lớn => giảm nhiều," and most of that
   gap was not a margin at all. .reach-map carries min-height
   clamp(380px,42vw,640px) = 604.8px at 1440, while everything drawn
   inside it — the land mask, the routes SVG and the pin list — is
   width-fitted at aspect-ratio 680/216 inset by clamp(4px,2vw,30px),
   so the drawing is only ~388px tall and sits vertically centred. That
   left 108px of nothing above the map and 108px below it, which the
   eye reads as two enormous gaps around the head and the ticker.

   Sizing the box off the same aspect the contents use deletes both at
   once. 680/216 and not a tighter number: the box is measured on the
   FULL width and the drawing on the width minus its two 2vw insets, so
   this exact ratio leaves inset × 216/680 ≈ 18px of air — about 9px top
   and bottom — at EVERY width, which is the one value here that does
   not drift as the viewport grows.

   Measured rather than reasoned, because a clipped pin would be silent:
   at 1280 / 1440 / 1920 the closest any pin box comes to an edge is
   Europe at +29px from the top and New Zealand at 24px from the bottom,
   and the origin pin — the only one that grows a 22px sonar ring — sits
   more than 100px inside both.

   NOT APPLIED TO .reach-map ITSELF. index.html's copy has the same dead
   space, but the homepage is signed off and its section rhythm was
   composed against that height. If the client asks for the homepage
   next, move these two declarations to the component and re-shoot both.

   The mobile block at max-width:900px keeps its taller min-height on
   purpose: down there the label pills are what need the room, not the
   land, so this override stops before it. */
@media (min-width:901px){
  .p-reach .reach-map{ min-height:0; aspect-ratio:680 / 216; }
  /* Head to map, and map to ticker. Both were a tier too generous once
     the 108px of dead space above and below stopped padding them. */
  .p-reach .fac-reach{ margin-top:var(--g-group); }
  .p-reach .m-ticker{ margin-top:var(--g-block); }
}

/* --- R2. 02 Requirements: the geo grid -------------------------------
   Replaces the three-column .spec table. Five ruled cells, one per
   market, each opening with that country's own silhouette — the client
   asked for "map hoặc icon quốc gia hoặc stroke map quốc gia đó" and the
   shape is the thing that lets a buyer find their own row without
   reading four others first.

   Ruled, never boxed, and the same anatomy as .cellgrid and .marks:
   hairline above the run, hairline under each cell, border-right as the
   divider, asymmetric padding holding the copy off it. The dividers are
   written :not(:nth-child(5n)) rather than li + li so the run stays
   correct when it wraps to three and two columns below. */
.geo{
  --geo-pad:clamp(16px,1.9vw,30px);
  list-style:none; margin:0; padding:0;
  display:grid; grid-template-columns:repeat(5,minmax(0,1fr));
  /* SUBGRID, and it is doing real work rather than showing off. The five
     "asked for first" paragraphs run two, three and four lines, so with
     each cell laying itself out the gold label landed at five different
     heights and the run read as five loose columns instead of one table.
     Ten rows because at the 3-column breakpoint the run wraps to two
     cell-rows of five; unused rows collapse to zero and row-gap is 0, so
     the same declaration is correct at every width. */
  grid-template-rows:repeat(10,auto);
  border-top:1px solid var(--line);
}
.on-dark .geo{ border-top-color:var(--line-light); }
.geo > li{
  display:grid; grid-row:span 5; grid-template-rows:subgrid; align-content:start;
  padding:var(--g-group) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .geo > li{ border-bottom-color:var(--line-light); }
.geo > li:not(:nth-child(5n)){ border-right:1px solid var(--line); padding-right:var(--geo-pad); }
.on-dark .geo > li:not(:nth-child(5n)){ border-right-color:var(--line-light); }
.geo > li:not(:nth-child(5n+1)){ padding-left:var(--geo-pad); }
.geo > li:last-child{ border-right:0; }

/* The silhouette. A square box at every size, because each path was
   fitted into the same 0 0 100 100 viewBox — that is what makes a
   country as small as South Korea carry the same optical weight as the
   contiguous 48 without anything here having to know which is which.

   Navy and not gold: this is the map register, the same ink the section
   01 land layer uses, and the gold in the cell is the one mono label. */
.geo__map{
  display:block; width:clamp(50px,4.6vw,72px); margin-bottom:var(--g-pair);
  color:var(--navy);
}
.on-dark .geo__map{ color:var(--accent); }
.geo__map svg{ display:block; width:100%; height:auto; fill:currentColor; opacity:.86; }
.geo > li > b{
  font-size:var(--text-title); line-height:var(--lh-title); font-weight:500;
  letter-spacing:-.02em; color:var(--ink);
}
.on-dark .geo > li > b{ color:var(--paper); }
/* No measure cap on either paragraph — the column is ~215px at 1440,
   which is under 20ch, so any ch cap wide enough to read as a measure
   would be wider than the cell and therefore a no-op. */
.geo__ask{
  margin:var(--g-pair) 0 0;
  font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink); text-wrap:pretty;
}
.on-dark .geo__ask{ color:var(--paper); }
/* The only label in the cell, and the only thing separating the two
   paragraphs — deliberately not a hairline. Five cells already carry
   two rules each; a third inside every one of them is the ruled density
   this client has asked us to drop three times. */
.geo__lab{
  display:block; margin-top:var(--g-group);
  font-size:10.5px; letter-spacing:.14em; color:var(--accent);
}
.geo__hold{
  margin:8px 0 0;
  font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}
.on-dark .geo__hold{ color:rgba(247,245,242,.78); }
/* Was the table's <caption>. Under the run rather than over it: it is a
   qualification of what the five cells say, not a title for them. */
.geo-note{
  margin:var(--g-block) 0 0; max-width:66ch;
  font-size:var(--text-small); line-height:var(--lh-body);
  color:var(--ink-soft);
}

@media (max-width:1280px){
  .geo{ grid-template-columns:repeat(3,minmax(0,1fr)); }
  .geo > li:not(:nth-child(5n)){ border-right:0; padding-right:0; }
  .geo > li:not(:nth-child(5n+1)){ padding-left:0; }
  .geo > li:not(:nth-child(3n)){ border-right:1px solid var(--line); padding-right:var(--geo-pad); }
  .on-dark .geo > li:not(:nth-child(3n)){ border-right-color:var(--line-light); }
  .geo > li:not(:nth-child(3n+1)){ padding-left:var(--geo-pad); }
  .geo > li:last-child{ border-right:0; }
}
@media (max-width:820px){
  .geo{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .geo > li:not(:nth-child(3n)){ border-right:0; padding-right:0; }
  .geo > li:not(:nth-child(3n+1)){ padding-left:0; }
  .geo > li:not(:nth-child(2n)){ border-right:1px solid var(--line); padding-right:var(--geo-pad); }
  .on-dark .geo > li:not(:nth-child(2n)){ border-right-color:var(--line-light); }
  .geo > li:not(:nth-child(2n+1)){ padding-left:var(--geo-pad); }
  .geo > li:last-child{ border-right:0; }
}
@media (max-width:560px){
  .geo{ grid-template-columns:1fr; }
  .geo > li{ border-right:0 !important; padding-inline:0 !important; }
}

/* --- R3. 03 Getting there: the fact sheet loses its rules ------------
   "Những đoạn như này bỏ line, canh lại khoảng cách các text cho phù
   hợp." The fourth request of this exact shape in two days, and the
   same three declarations as the other three: border-top and its
   padding-top come off together (one gesture — leaving the padding
   behind is how orphan space doubles the parent's own), and the run's
   gap opens from 6px to 10px so the mono label is what structures the
   sheet now that the hairline is not.

   Scoped to .p-terms, never to .step__facts: thirteen pages use the
   ruled version and the client is looking at one section. The 150px
   label column stays — without the rules it IS the structure.

   The run's own margin-top is left alone at --g-group (Layer P5). That
   gap is what rules the sheet off from the prose above it once the
   hairline is gone, and .cert-note below takes the same step. */
.p-terms .feature__body .step__facts{ gap:10px; }
.p-terms .feature__body .step__facts li{ border-top:0; padding-top:0; }

/* --- R4. 04 Partners: the accordion became a ruled three-up ----------
   Reuses .marks, which 06 Locations runs further down the same page.
   Two things have to be restated for it.

   (a) An explicit three-column grid instead of the base
   auto-fit/minmax(190px,1fr). Auto-fit was written for a two-cell
   strip; with three cells it wraps at widths nobody chose, and the
   base's `li + li` divider then draws a border-left on the first cell
   of the second row — a vertical rule against nothing. Explicit columns
   plus nth-child dividers is the same fix .cellgrid and .geo carry.

   (b) The question is a full sentence, so it is set at the title tier
   like the rest of .marks but given the paragraph's measure rather than
   the strip's — "What kind of buyer is this factory a good fit for?"
   set to 34ch would break after two words. */
.marks--qa{ grid-template-columns:repeat(3,minmax(0,1fr)); }
.marks--qa > li + li{ border-left:0; padding-left:0; }
.marks--qa > li:not(:nth-child(3n+1)){
  border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px);
}
.on-dark .marks--qa > li:not(:nth-child(3n+1)){ border-left-color:var(--line-light); }
.marks--qa > li > b{ max-width:24ch; }
.marks--qa p{ max-width:42ch; }
@media (max-width:900px){
  .marks--qa{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .marks--qa > li:not(:nth-child(3n+1)){ border-left:0; padding-left:0; }
  .marks--qa > li:not(:nth-child(2n+1)){
    border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px);
  }
  .on-dark .marks--qa > li:not(:nth-child(2n+1)){ border-left-color:var(--line-light); }
}
@media (max-width:640px){
  .marks--qa{ grid-template-columns:1fr; }
  .marks--qa > li{ border-left:0 !important; padding-left:0 !important; }
}

/* ═════════════════════════════════════════════════════════════════════
   LAYER S — PRODUCT LINE DE-CLUTTER (client, 2026-08-07)
   "Chỉ có section 01 ok, các section khác đang rất rối và dài dòng."
   The same diagnosis as Layer Q and Layer R, on the four line pages, and
   the same answer: the page was running six devices — a two-column split
   with prose and a fact sheet, a stepline, a marks run with paragraphs, a
   seven-row spec table (an accordion on sports) and two CTA blocks — over
   five sections, and two of those sections were the SAME numbered list in
   two different shapes.

   Almost nothing new is needed here, which is the point: 02 and 03 are
   .cellgrid (Layer Q) on paper and on navy, and the audit run is .marks
   with its paragraphs removed and the programmes' own marks added. One
   modifier below, and one measured note about the sports/slippers plate.
   ═════════════════════════════════════════════════════════════════════ */

/* --- S1. marks--audit: the four programmes as their own marks ---------
   Was four ruled cells each carrying a name, a kind and a sentence
   explaining what "audit programme" means. The sentence is what made the
   run 300px tall on every line page; the mark plus the kind label says
   the same thing, and certifications.html carries the explanation.

   The KIND LABEL IS NOT DECORATION and no rule here may hide it: GRS
   issues a certificate and the other three do not, and that distinction
   is the reason this company never writes "certified to BSCI".

   Explicit four columns and nth-child dividers rather than the base
   auto-fit + `li + li`: auto-fit drops to three at ~790px and the base
   sibling rule then draws a border-left on the first cell of the second
   row — a vertical against nothing. Same fix .cellgrid, .geo and
   .marks--qa already carry. */
.marks--audit{ grid-template-columns:repeat(4,minmax(0,1fr)); }
.marks--audit > li + li{ border-left:0; padding-left:0; }
.marks--audit > li:not(:nth-child(4n+1)){
  border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px);
}
.on-dark .marks--audit > li:not(:nth-child(4n+1)){ border-left-color:var(--line-light); }

/* A FIXED FIRST ROW, and the height is measured rather than chosen: the
   four marks are 123×56, 158×34, 130×34 and 70×44, so at natural size
   their names would sit at three different heights and the run would read
   as four loose columns. 56px is the tallest mark; the shorter three
   centre in it and all four names start on one line. Sizing the images to
   a common height instead would shrink GRS to 75px wide against amfori's
   158px — these are licensed marks and their proportions are not ours to
   even out. */
.marks--audit > li{ display:grid; grid-template-rows:56px auto auto; align-content:start; }
.marks--audit > li > b{ margin-top:var(--g-pair); }
.marks__mark{
  align-self:center; justify-self:start;
  max-width:100%; height:auto;
  /* The source files are dark marks on transparent. brightness(0) flattens
     any colour in them to one ink first — without it, invert() on a
     coloured mark returns its complement rather than a knockout. */
  filter:brightness(0); opacity:.66;
}
.on-dark .marks__mark{ filter:brightness(0) invert(1); opacity:.78; }

@media (max-width:900px){
  .marks--audit{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .marks--audit > li:not(:nth-child(4n+1)){ border-left:0; padding-left:0; }
  .marks--audit > li:not(:nth-child(2n+1)){
    border-left:1px solid var(--line); padding-left:clamp(18px,2vw,32px);
  }
  .on-dark .marks--audit > li:not(:nth-child(2n+1)){ border-left-color:var(--line-light); }
}
@media (max-width:560px){
  .marks--audit{ grid-template-columns:1fr; }
  .marks--audit > li{ border-left:0 !important; padding-left:0 !important; }
}

/* --- S2. the plate on the two photo-poor pages ------------------------
   Sports and slippers lost their only in-body photograph with the .split,
   and their galleries are three and two cards by client decision — so the
   de-clutter would have left them text from the fold down. Both are given
   a floor photograph instead, and both sources are PORTRAIT (900×1125)
   where every other plate on the site is cut wide.

   --short, not --full, and that is the whole reason: at --full's
   min(92svh,900px) a 900-wide source is scaled 1.6× to cover 1440 and
   loses 900px of its own height, which on both of these frames cuts the
   head off the person working. At --short's 440px ceiling the same crop
   keeps the bench, the machine and the hands, which is what the picture
   is for. If a wide original of either arrives, swap --short for --full in
   the two files; there is deliberately NO RULE under this note, because
   .plate--short already does the whole job and a declaration written only
   to give a comment somewhere to live is the next reader's dead end. */

/* ═════════════════════════════════════════════════════════════════════
   LAYER T — CAREERS AS A JOB BOARD (client, 2026-08-07)
   Six instructions in one note, and five of them point the same way:
   stop arguing and start listing.

     · the packing-bench plate is cut
     · 03 The floor loses its lede and its closing paragraph, gains the
       benefits the old page never stated, and moves to the TOP — the
       reason to work here comes before the vacancies, not after them
     · the claim band loses every rule and its citation
     · the openings stop being a <details> accordion and become a ruled
       board of rows — number, role, team, tags — on the client's own
       reference layout
     · a row opens a job page, and the application form lives THERE

   That last one is the structural change. The form was one section at
   the bottom of careers.html and every posting anchored to it with
   data-pos; there are five job pages now, each carrying its own copy of
   the form with its own role preselected in the markup. The
   `careers apply` block in site.js still runs and still does nothing
   harmful — the select it looks for exists, the .js-apply-for anchors
   no longer do.

   Scoped to this page family throughout. .p-claim, .cellgrid and the
   form field voices are shared with twelve other pages and a careers
   revision must not silently restyle them.
   ═════════════════════════════════════════════════════════════════════ */

/* --- T1. perks: the benefits, beside a photograph ---------------------
   "thêm vào quyền lợi (có hình ảnh và icon minh họa)" — one block that
   has to carry both a photograph and per-item icons without becoming a
   third grid on a band that already runs .figures.

   So it is not a grid. It is the site's ruled list, given a photograph
   as its left column: rule above the run, rule under every row, icon in
   the accent voice in a fixed gutter so the titles align down the
   column whatever the glyph is. Nothing is filled and nothing is boxed,
   which is the only reason a list of benefits does not read as the
   marketing tile grid this site has spent four layers removing.

   The photograph is sticky. The list is taller than the image on any
   desktop width, and an image that scrolls away while its own list is
   still running leaves the column empty — the same failure .split fixed
   for the contact form. */
.perks{
  display:grid; grid-template-columns:minmax(0,.82fr) minmax(0,1.18fr);
  gap:clamp(28px,4vw,72px); align-items:start;
}
.perks__fig{ margin:0; position:sticky; top:calc(var(--hdr-h) + clamp(18px,4vh,54px)); }
/* 4/5, not 3/4. The plate is a 3:2 landscape frame and the machinist sits
   right of centre in it; a 3/4 crop of a 1400×933 original keeps 700px of
   width and cuts her shoulder off, so the frame is one step less tall and
   the crop is pushed right to hold her whole. Measured against the file,
   not guessed — see the alt text for what is actually in shot. */
.perks__fig .slot{ aspect-ratio:4/5; }
.perks__fig .slot img{ object-position:62% center; }
.perks__fig figcaption{
  margin-top:var(--g-pair);
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.perks__list{
  list-style:none; margin:0; padding:0;
  border-top:1px solid var(--line);
}
.on-dark .perks__list{ border-top-color:var(--line-light); }
.perks__list > li{
  display:grid; grid-template-columns:clamp(30px,2.4vw,38px) minmax(0,1fr);
  column-gap:clamp(14px,1.6vw,24px); row-gap:6px;
  padding:clamp(18px,2vw,26px) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .perks__list > li{ border-bottom-color:var(--line-light); }
/* The icon spans both rows so a two-line benefit does not push it up
   against the title's cap height. */
.perks__ico{
  grid-row:1 / span 2; align-self:start;
  display:block; width:100%; color:var(--accent);
}
.perks__ico svg{ display:block; width:100%; height:auto; }
.perks__list > li > b{
  font-size:var(--text-title); line-height:var(--lh-title); font-weight:500;
  letter-spacing:-.02em; color:var(--ink);
}
.on-dark .perks__list > li > b{ color:var(--paper); }
.perks__list > li > p{
  margin:0; font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}
.on-dark .perks__list > li > p{ color:rgba(247,245,242,.78); }
@media (max-width:900px){
  /* The photograph goes back to being a plate above the list: at this
     width a 3/4 image in a 40% column is a postage stamp. */
  .perks{ grid-template-columns:1fr; gap:var(--g-group); }
  .perks__fig{ position:static; }
  .perks__fig .slot{ aspect-ratio:16/9; }
}

/* The band runs two blocks now — the four figures and the benefits — so
   the second one is labelled. Mono accent, the same wayfinding voice
   .feature__body .tag and .stepline's category label already carry; a new
   voice for three words would be a fourth thing on a band that has a
   folio, a heading and a stat row on it already. */
.perks-label{
  display:block; color:var(--accent);
  margin:var(--g-section) 0 var(--g-group);
}

/* --- T2. jobfilter — DELETED 2026-08-07 ------------------------------
   Team and Type, one day old. Client: "hình 1: bỏ." They were the client's
   own reference layout and they went for the reason the reference could not
   show — five rows. Two dropdowns over a list you can read in one glance are
   a control that costs more attention than the sorting it saves, and the
   team is already printed on every row. The empty state (.joblist__none) went
   with them: nothing can empty the board now.

   The `careers board` block in site.js is deleted too, and the rows lost
   their data-team / data-type. If filters ever come back they come back with
   a board long enough to need them — that is when the attributes are worth
   re-adding, not before. */

/* --- T3. joblist: the board ------------------------------------------
   The client's reference read as rows of cards. It is one ruled list
   here — hairline above the run, hairline under every row, nothing
   filled — because that is what this site draws a list as, and a row of
   five filled cards on a page that already runs .figures and .perks
   would be the third box family on one screen.

   THE WHOLE ROW IS THE LINK, done the way .post does it: the title's
   own <a> carries an ::after stretched over the row. A wrapping <a>
   would pull the team name and both tags into the link's accessible
   name — "Closing machinist Team Closing Full-time Open" — and there is
   nothing to gain from that. One link per row, named by the role.

   The numeral is a CSS counter. [hidden] is display:none, so a filtered
   board renumbers itself 01, 02, 03 with no gaps and nothing in the
   markup ever types a number. */
.joblist{
  list-style:none; margin:0; padding:0;
  counter-reset:job;
  border-top:1px solid var(--line);
}
.on-dark .joblist{ border-top-color:var(--line-light); }
.job{
  counter-increment:job;
  position:relative;
  display:grid; align-items:center;
  grid-template-columns:clamp(38px,4vw,64px) minmax(0,1fr) auto;
  column-gap:clamp(16px,2.4vw,40px); row-gap:10px;
  padding:clamp(20px,2.4vw,34px) 0;
  border-bottom:1px solid var(--line);
}
.on-dark .job{ border-bottom-color:var(--line-light); }
.job[hidden]{ display:none; }
/* The hover sweep, on the bottom edge like every other one on the site,
   and at the homepage's weight (client, 2026-08-07: "line height ở dưới to
   lên bằng với home"). 2px x scaleY(2) = 4, over .7s — the same numbers
   .cap-row::before, .cert::before, .prod-cap::before, .route-list a::before
   and .post::after all land on. The -1px puts it ON the row's own rule
   rather than under it, and the origin is `left bottom`: with a bare `left`
   the scaleY grows the rule downward out of the row instead of upward off
   its own baseline — the exact bug .post's note records. */
.job::after{
  content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;
  background:var(--accent); transform:scaleX(0); transform-origin:left bottom;
  transition:transform .7s var(--ease);
}
.job:hover::after,
.job:focus-within::after{ transform:scaleX(1) scaleY(2); }
.job__num{
  font-family:var(--f-mono); font-weight:500;
  font-size:clamp(12px,1.05vw,14px); letter-spacing:.06em;
  color:var(--ink-soft); align-self:center;
}
.on-dark .job__num{ color:var(--accent); }
.job__num::before{ content:counter(job,decimal-leading-zero); }
/* The inline-statement tier — clamp(24px,2.6vw,36px) is .statement's own
   ramp, not a fourth heading size. A board of roles is a list of short
   noun phrases and it wants the serif at that weight, which is exactly
   what that tier already is. */
.job__title{
  margin:0; font-family:var(--f-serif); font-optical-sizing:auto;
  font-weight:400; font-size:clamp(24px,2.6vw,36px);
  line-height:1.14; letter-spacing:-.02em; color:var(--ink);
  text-wrap:balance;
}
.on-dark .job__title{ color:var(--paper); }
/* THE HOMEPAGE'S HOVER, NOT THE JOURNAL'S (client, 2026-08-07: "in đậm
   tiêu đề màu vàng, không gạch chân"). The underline-grow this started with
   was .post's, and .post is a card with a photograph over it; a row with
   nothing but type on it was drawing a second line a few pixels under the
   one the sweep already draws, which read as a smudge rather than as two
   devices.

   What replaces it is the system .cap-row states for the whole site: the
   title takes the colour of the rule that just swept under it and gains
   --w-hover of weight. Here that rule is gold, so the title is gold. The
   weight is the one uncomposited hover on the site — a layout pass per
   frame — and it is affordable for the same reason it is there: one text
   node at a time, and the longest of these is two words. */
.job__title a{
  color:inherit; text-decoration:none;
  transition:color .5s var(--ease), font-weight .5s var(--ease);
}
.job:hover .job__title a,
.job:focus-within .job__title a{ color:var(--accent); font-weight:var(--w-hover); }
.job__title a::after{ content:""; position:absolute; inset:0; z-index:1; }
.job__meta{
  display:grid; justify-items:end; gap:10px; text-align:right;
}
.job__team{
  display:block;
  font-size:var(--text-small); line-height:var(--lh-tight); color:var(--ink-soft);
}
.on-dark .job__team{ color:rgba(247,245,242,.78); }
.job__team i{
  display:block; margin-bottom:4px; font-style:normal;
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.job__tags{ display:flex; flex-wrap:wrap; justify-content:flex-end; gap:6px; }
/* Hairline chips, square. The reference draws these as rounded pills;
   999px is the button system's radius on this site and lending it to a
   piece of metadata makes a label look like a control you can press.
   Square hairline is what .pager and the viewer's steppers already use
   for "a small bordered thing that is not prose". */
.job__tags b{
  font-family:var(--f-mono); font-weight:500; font-size:10px;
  letter-spacing:.12em; text-transform:uppercase; color:var(--ink-soft);
  border:1px solid var(--line); padding:4px 9px;
}
.on-dark .job__tags b{ color:rgba(247,245,242,.78); border-color:var(--line-light); }
.job__tags b.is-open{ color:var(--accent); border-color:var(--accent); }
@media (max-width:820px){
  /* Two columns: the numeral keeps its gutter, the meta drops under the
     title and goes left-aligned with it. Right-aligned metadata under a
     left-aligned title is the classic narrow-screen tell. */
  .job{ grid-template-columns:clamp(30px,7vw,44px) minmax(0,1fr); }
  .job__meta{ grid-column:2; justify-items:start; text-align:left; }
  .job__tags{ justify-content:flex-start; }
}

/* --- T4. the claim band loses its rules ------------------------------
   Client, 2026-08-07: "bỏ toàn bộ line, bỏ ONE SITE · EVERY STAGE ON IT."
   The citation is deleted from the markup, which takes the vertical
   divider with it through p-claim's own :not(:has(.statement__cite))
   rule. What that rule does NOT take is the head hairline (::after) or
   the gold stub (.statement-rule) — so both go here, per block, the way
   every other "bỏ line" on this site has been done. .p-claim itself is
   untouched: twelve other pages still draw the composed version.

   With the meta column gone the grid is one track, and the quote may
   have the width the divider used to cost it. */
.p-claim--bare > .container{ grid-template-columns:minmax(0,1fr); }
.p-claim--bare > .container::before,
.p-claim--bare > .container::after{ content:none; }
.p-claim--bare > .container > .claim-stack .statement{ max-width:46ch; }

/* --- T5. the job page: one band, details beside the form -------------
   Client, 2026-08-07: "trang career detail chia thành 1 cột, trái là
   details, viết chi tiết ra, phải là form apply có border xung quanh."

   It shipped as two stacked sections — 01 The role, then 02 Apply — which
   put the whole reason to apply above the fold and the form somewhere past
   it. One band now: the argument runs down the left and the form sits
   beside it the entire way, so there is never a scroll position where the
   page is asking for an application and not offering one.

   THE FORM IS BOXED, AND THAT IS A DEVIATION. "Ruled, never boxed" governs
   every list on this site; the client asked for a border here in as many
   words, and the reason it survives review is that this is not a list — it
   is a control panel inside an editorial column, the same argument .pager
   and the product viewer's steppers already won. Hairline only, no fill and
   no radius: a filled card would make it the loudest object on a paper
   page, and 999px belongs to the button system. */
.jobdetail{
  display:grid; grid-template-columns:minmax(0,1.12fr) minmax(0,.88fr);
  gap:clamp(28px,3.4vw,68px); align-items:start;
}
.jobdetail__body{ min-width:0; }
/* STICKY, on the client's instruction (2026-08-14: "trang chi tiết tuyển dụng,
   pin form giúp tôi"). This rule used to say the opposite in as many words, so
   here is what changed and what did not.

   What did not change is the measurement that blocked it. The box is 985px
   against a 900px laptop viewport, and a form pinned at the header offset
   would run from 103px to 1088px — putting its own consent row and Send
   button ~190px below the fold with no way to reach them. That failure is
   real and pinning the TOP would still cause it.

   That failure is real, and the answer chosen for it was to shrink the form
   rather than to outsmart it: pairing email with phone and cutting the
   textarea to three lines took the box from 985px to ~870px. It still does not
   fit a 900px laptop, and the client has settled that too — "pin đoạn trên
   form cũng dc r" — so the panel pins its TOP and the tail hangs below the
   fold. Nothing is stranded down there: the pin releases at the end of
   .jobdetail and the box scrolls up into full view.

   --jobapply-top is therefore one number, written by the 'sticky apply panel'
   block in site.js: the header's height plus 24. It is in JS at all only
   because the header is sized by its content and CSS has no way to ask.
   The fallback is 0px — the panel would tuck under the header, never broken,
   and it only shows if the block throws.

   Sticky needs a scrollable ancestor chain with no overflow clipping between
   here and the scroller: probed on the page, aside → .jobdetail → .container
   → .sec → main are all overflow:visible. Adding overflow to any of them
   silently kills this. */
.jobapply{
  border:1px solid var(--line);
  padding:clamp(24px,2.4vw,38px);
  position:sticky; top:var(--jobapply-top,0px);
}
.jobapply__note{
  margin:var(--g-pair) 0 var(--g-group);
  font-size:var(--text-small); line-height:var(--lh-tight); color:var(--ink-soft);
}
/* The form fills its box and PAIRS its fields 2-up (client, 2026-08-14:
   "giảm kích thước form... mail và phone chung 1 hàng").

   This rule used to force `grid-template-columns:1fr` and stack them, on the
   grounds that the base pairs from 641px UP — a viewport query, so at 1440 it
   would put two inputs in a ~400px column and hand the applicant two ~190px
   targets. That objection was real and it has been overruled for a reason
   that outweighs it: four stacked rows made the box 985px tall, which does
   not fit a laptop viewport, and a form that cannot be pinned from its top is
   a worse outcome than a 190px input. 190px still holds an email address at
   this font size, and both fields are single-line.

   It stops at the 1080 breakpoint, where .jobdetail stacks and the panel goes
   full width — the base rule already pairs correctly there. */
.jobapply .form{ max-width:none; }

/* THE SAME DENSITY AS CONTACT (client, 2026-08-07: "giảm khoảng cách các
   field trong form cho giống với trang contact"). Every declaration below
   is U1 and U6 with .p-enquiry swapped for .jobapply — measured against
   contact rather than eyeballed, and the reasoning lives there rather than
   being restated here.

   U1 explicitly scoped itself away from careers on the grounds that this
   form has a different field count and a different page around it. That was
   right when it was written and is what the client has now overruled: the
   stacked pairs made it WORSE here, not better, because a pair that stacks
   is two rows at the form's gap instead of one. Eight fields at --g-group
   was 272px of nothing inside a single object.

   Only the ROW axis is set on .form-pair. Since 2026-08-14 the box does pair
   its fields, so the column gap matters again — it is left at the base's
   --g-group, which is what every other paired form on the site uses.

   THE MEDIA BLOCK MUST STAY LAST of these — it overrides .jobapply
   .form-row at identical specificity and wins only by being later, exactly
   the ordering trap U6 flags. */
.jobapply .form{ gap:var(--g-pair); }
.jobapply .form-row{ gap:6px; }
.jobapply .form-pair{ gap:var(--g-pair); }
/* 76px, from 112px — about three lines instead of five (client: "giảm kích
   thước form WHAT YOU HAVE MADE"). It is a min-height, not a height, so the
   field still grows if the browser or the applicant drags it; what shrank is
   the empty box a first-time visitor is looking at. The hint under it already
   says four lines is enough, so a five-line well was asking for more than the
   copy does. */
.jobapply .form textarea{ min-height:76px; }
.jobapply .form-consent{ margin-top:var(--g-pair); }
.jobapply .form .page-actions{ margin-top:var(--g-pair); }
@media (min-width:641px){
  .jobapply .form-row{ gap:2px; }
  /* [type=file] excluded: its padding wraps a UA button, not text, and
     squeezing it clips the control. Same exclusion as U6. */
  /* .fsel__btn joins the list 2026-08-14, and the omission is the exact trap
     U6 flags for .p-enquiry: the listbox trigger IS one of these fields, so
     anything that moves their box has to move its box too. Measured here, the
     input was 38px and the trigger 48px — their underlines sat 10px apart.
     It was invisible for as long as this box stacked its fields, because a
     row on its own has nothing to line up with; pairing them on 2026-08-14 is
     what put "The role" beside "Your name" and made the 10px a visible step.
     Any future change to the field box belongs on all three at once. */
  .jobapply .form input:not([type=file]),
  .jobapply .form select,
  .jobapply .fsel__btn{ padding-top:6px; padding-bottom:6px; }
}

/* .jobapply__alt — DELETED 2026-08-07. The "Or skip the form" panel and its
   write-to / call / language rows are gone on the client's note. The two
   contact routes it carried are on every page of the site already — the
   footer's Contact column and the header's Enquire button — and inside a
   bordered box directly under a Send button they read as a second, quieter
   way to do the thing the box exists for. */
@media (max-width:1080px){
  /* Stacked, form last. A 300px form column is worse than a form you have
     to scroll to, and sticky on a stacked layout pins nothing. */
  .jobdetail{ grid-template-columns:1fr; gap:var(--g-block); }
  .jobapply{ position:static; }
}

/* The left column's own furniture.

   (a) The role's summary line — team, place, type — in the mono label voice
   with the accent, so the first line of detail reads as information rather
   than as a subtitle competing with the lede above it.
   (b) Two fact sheets and a list of tasks, each under its own h-sub. The
   sheets take the unruled treatment about and markets took (P3/P4, R3):
   unruled per block, never at .step__facts itself.
   (c) .job-points is the task list — ruled under each line, gold marker,
   no bullet glyph. It is the site's list construction at list size; a <ul>
   with disc bullets would be the only one on the site. */
.p-role .step__facts li{ border-top:0; padding-top:0; }
.p-role .step__facts{ gap:12px; }
.jobdetail__sub{ margin:var(--g-block) 0 var(--g-group); }
.job-meta{
  display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 var(--g-group);
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent);
}
.job-points{
  list-style:none; margin:0; padding:0;
  border-top:1px solid var(--line);
}
.job-points > li{
  position:relative; padding:14px 0 14px 26px;
  border-bottom:1px solid var(--line);
  font-size:var(--text-body); line-height:var(--lh-body);
  color:var(--ink-soft); text-wrap:pretty;
}
/* A rule, not a disc. 7px of gold on the text's own first baseline — the
   .5em puts it there without a magic number that drifts when --text-body
   moves with the viewport. */
.job-points > li::before{
  content:""; position:absolute; left:0; top:calc(14px + .5em * var(--lh-body));
  width:12px; height:1px; background:var(--accent);
}

/* ═════════════════════════════════════════════════════════════════════
   LAYER U — CONTACT (client, 2026-08-07)
   Lettered U, not S. Two parallel sessions took S (product lines) and
   T (careers) the same afternoon, and the first two passes of this
   work — written as "Layer S" before those landed — were lost when the
   sheet was rewritten from a stale read. Everything below is restored
   and consolidated into ONE block, in cascade order, so there is a
   single place to look for contact's overrides.

   Ordering is load-bearing in one spot, flagged at U6.

   The client's message was three gaps: the enquiry heading sits too far
   off its form, the labels too far off their rules, the section-02
   heading too far off the maps. Two of the three are not values anybody
   chose — they are two spacings stacking where one was meant to.
   Fixing the stack is most of the reduction.
   ═════════════════════════════════════════════════════════════════════ */

/* --- U1. The enquiry form closes up --------------------------------
   The base .form runs on --g-group (34px at desktop), which is the
   site's gap between two DIFFERENT things — a heading and the run
   beneath it, a figure and its neighbour. Between two fields of one
   form it is not rhythm, it is a stall: eight fields at 34px is 272px
   of nothing inside a single object, and the submit leaves the screen.

   The new step is --g-pair, the token for parts of the SAME thing,
   which is what these fields are. Scoped to .p-enquiry, never to .form:
   the careers pages run the same component through apply.php with a
   different field count and a different page around it. */
.p-enquiry .form{ gap:var(--g-pair); }
.p-enquiry .form-row{ gap:6px; }
/* Row gap tight, column gap not. Stacked on a phone these two fields
   are consecutive rows and take the form's own step; side by side on a
   desktop they are two separate targets and keep the wide --g-group.
   One shorthand covers both because only one axis is ever live. */
.p-enquiry .form-pair{ gap:var(--g-pair) var(--g-group); }
/* Four lines is still four lines — the field scrolls and it resizes —
   and it is what lets the consent row and the button sit inside the
   same screen as the first field. */
.p-enquiry .form textarea{ min-height:112px; }
/* The consent row is a checkbox beside a two-line sentence, not a
   field: it wants a breath above it so it does not read as field nine.
   The submit takes the same step, for the same reason in reverse. */
.p-enquiry .form-consent{ margin-top:var(--g-pair); }
.p-enquiry .form .page-actions{ margin-top:var(--g-pair); }

/* --- U2. .form-pair had no gap at all below 641px ------------------
   NOT part of any client request — a base-component bug the tightening
   exposed. .form-pair is only declared display:grid inside the 641px
   media query, so below it the element is a plain block and its two
   rows have NO gap. Measured on contact at 430px: 0 / 10 / 0 / 10px
   down the form, a rhythm that alternates because every other pair is
   invisible to the .form gap. The five careers forms carry the same
   defect. Declaring the grid unconditionally fixes both; the columns
   stay inside the media query, where they belong. */
.form-pair{ display:grid; gap:var(--g-group); }

/* --- U3. Both section heads close up on their bodies ---------------
   .sec-head carries padding-bottom:var(--g-block) — 58px at 1440 — and
   that is correct everywhere else, because everywhere else the head is
   a heading AND a lede and the padding separates a two-part block from
   the body. Section 01 lost its lede this morning, so the padding is
   now measuring itself against a single line of type: 58px under a
   heading reads as twice what it read as under a paragraph.

   Down one step on the same scale, to --g-group. Not a new number and
   not a value invented for this page — the next token down is the
   point, so both sections stay in the site's rhythm and share one. */
.p-enquiry .sec-head{ padding-bottom:var(--g-group); }
.p-where .sec-head{ padding-bottom:var(--g-group); }

/* --- U4. Section 02's head-to-map gap was doubled ------------------
   .sec-head owns padding-bottom:var(--g-block) and .wheremaps owned
   its own margin-top:var(--g-block). Both fired, so the lede sat 115px
   off the maps. The margin goes: the section head already owns that
   gap, and owns it on every other section of the site. */
.wheremaps{ margin-top:0; }

/* --- U5. A hidden error banner was pushing the form down -----------
   .split__body > * + * is an owl selector, and #send-failed ships with
   [hidden] immediately before the form. display:none takes the banner
   out of layout but NOT out of the DOM, so `+` still matched and the
   form carried margin-top:var(--g-block) — 58px of space under nothing.
   Measured at 1440: the heading-to-first-label gap read 92px where 34
   was intended, and 58 of that was this.

   The fix is the selector, not a margin:0 on .form — site.js reveals
   the banner by removing the attribute, and at that moment the gap
   SHOULD come back. Keying on [hidden] gets both states right, and it
   corrects the same phantom gap on the careers forms, about, terms and
   sustainability, which all lead a .split__body with a hidden node. */
.split__body > [hidden] + *{ margin-top:0; }

/* --- U6. Field label sits down on its rule -------------------------
   MUST STAY BELOW U1. This overrides .p-enquiry .form-row from U1 at
   identical specificity — a media query adds none — so it only wins by
   being later in the sheet. Move it above U1 and the 2px silently
   becomes 6px again with nothing to show for it.

   The "line" is the input's own bottom border, so the gap the client
   is measuring is the label gap PLUS the input's padding-block: 6 + 48
   = 54px, most of it the empty interior of a field nobody has typed in.

   Pointer widths only. Below 641px these stay untouched on purpose:
   the input is 48px tall there, which is the touch-target floor, and
   buying 15px of tidiness with a target a thumb misses is a bad trade.
   [type=file] is excluded — its padding is around a UA button, not
   around text, and squeezing it clips the control.

   .fsel__btn is in this list because it IS one of these fields — the
   listbox trigger from U9 stands in the same row as the inputs beside
   it, so anything that moves their box has to move its box or the two
   underlines stop lining up. It was left out when U9 was written and
   the dropdown's rule sat 10px below the phone field's, which is the
   whole failure mode this list exists to prevent. Any future change to
   the field box belongs on all three selectors at once. */
@media (min-width:641px){
  .p-enquiry .form-row{ gap:2px; }
  .p-enquiry .form input:not([type=file]),
  .p-enquiry .form select,
  .p-enquiry .fsel__btn{ padding-top:6px; padding-bottom:6px; }
}

/* --- U7. The map captions carry an address and a number ------------
   .figcap is mono, uppercase, 10.5px, tracked .16em. Right for "Where
   a factory visit starts" and wrong for a Vietnamese street address:
   uppercase flattens the diacritics that distinguish Bình from Binh,
   and 10.5px tracked wide is the worst possible setting for a string
   somebody may be copying into a taxi app. So the caption splits — the
   label keeps the mono treatment, the address is set as text, in its
   own case, at caption size, and the phone takes its own line below.

   No measure cap: the caption already sits in a half-width cell, so a
   second cap only decides where the address breaks, and 44ch broke
   "…District 12, Ho Chi Minh / City" with one word alone on line two.
   text-wrap:balance splits the two lines evenly instead.

   The number is gold AND underlined. On navy, colour by itself is the
   same signal the mono label is already using, so the hairline is what
   says "this is a link" — same treatment as .figcap--prose a, one
   ground down. width:max-content keeps the rule under the digits
   rather than running the width of the cell. */
.wheremaps__addr{
  font-family:var(--f-body); font-weight:400;
  font-size:var(--text-small); line-height:var(--lh-body);
  letter-spacing:0; text-transform:none;
  max-width:none; text-wrap:balance;
}
.wheremaps__addr > b{
  display:block; margin-bottom:4px;
  font-family:var(--f-mono); font-weight:500; font-size:11px;
  letter-spacing:.14em; text-transform:uppercase; color:var(--navy);
}
.on-dark .wheremaps__addr > b{ color:var(--accent); }
.wheremaps__addr > a{
  display:block; margin-top:6px; width:max-content; max-width:100%;
  color:var(--accent); text-decoration:none;
  border-bottom:1px solid var(--line-light);
  transition:border-color .35s var(--ease);
}
.wheremaps__addr > a:hover,
.wheremaps__addr > a:focus-visible{ border-bottom-color:var(--accent); }

/* --- U8. The dropdown keeps the arrowhead, loses the shaft ---------
   "Dấu dropdown chỉ lấy phần mũi tên ở dưới, bỏ line." --arrow-down is
   the site's one arrow glyph rotated 90°: a shaft (M2 10H18) and two
   curved barbs. Dropping the shaft leaves the barbs, which is a
   chevron — and the curve is the SAME curve, so this is still the
   house arrow, not a second glyph borrowed from somewhere else.

   A NEW token rather than an edit to --arrow-down. The FAQ accordion
   marker on capabilities.html masks --arrow-down too and the client is
   looking at a select; changing the shared token would silently
   restyle a control on a page nobody asked about.

   The viewBox is tight around the head instead of the full 20×20 box.
   With the shaft gone the glyph occupies x 5–15, y 11.78–18 after the
   rotation, so keeping 0 0 20 20 would park a chevron in the lower
   third of an otherwise empty square and background-position would be
   aligning the emptiness. 4.1/10.88 with 11.8×8.02 wraps the strokes
   plus half a round cap either side.

   stroke-width drops 1.75 → 1.5 because the box shrank under it: the
   same 1.75 in a box 11.8 units wide instead of 20 renders half again
   as heavy as everywhere else the arrow appears. This keeps the
   rendered weight where the family sits.

   Applies to every .form select — contact and the five careers apply
   forms — because it is one control, not one page. */
:root{
  --arrow-down-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='4.1 10.88 11.8 8.02' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cg transform='rotate(90 10 10)'%3E%3Cpath d='M11.7778 5C12.3309 6.30952 14.3497 9.14286 18.0001 10'/%3E%3Cpath d='M11.7778 15.0001C12.3309 13.6906 14.3497 10.8573 18.0001 10.0001'/%3E%3C/g%3E%3C/svg%3E");
}
/* background-IMAGE, never the shorthand — the shorthand resets
   background-image and deletes the glyph, which is the trap already
   flagged on .form select's background-color a few hundred lines up. */
.form select{ background-image:var(--arrow-down-head); background-size:12px; }

/* --- U9. The dropdown becomes a real listbox ------------------------
   "Thiết kế lại những cụm này cho cùng style thiết kế của web hiện tại."
   The crop was the OPEN state of a native <select> — the macOS popup,
   blue highlight, system font, checkmark. None of that is reachable
   from CSS: <option> is drawn by the operating system, not the page.
   Matching the site means not using it, so site.js builds an APG
   select-only combobox beside each <select> and the native control
   stays in the DOM as the thing the form actually submits.

   THE NATIVE SELECT IS STILL THE SOURCE OF TRUTH. It keeps its name,
   its value, its required attribute and its server-side whitelist in
   enquiry.php / apply.php; the listbox writes back to it and fires
   change + input so the validation block and the ?type= mirror carry
   on working unchanged. With JS off none of this exists and the plain
   <select> renders exactly as before — which is why .fsel__native is
   applied by script and never ships in the markup.

   Everything below is the existing field, restated. The button has to
   reproduce .form select's box exactly (padding 11px 2px, the bottom
   hairline, font:inherit at --text-body) because it stands in the same
   place in the same row; where the two could drift, they are written
   as the same declarations rather than as a shared selector, since the
   underline treatment lives in a layer this one must not edit. */
.fsel{ position:relative; }
/* Not display:none. site.js's submit handler calls firstBad.focus(),
   and a display:none element cannot take focus or fire the focus event
   this component forwards to the button. Transparent and unclickable
   keeps it focusable; tabindex -1 and aria-hidden (set in script) keep
   it out of the tab order and off the accessibility tree. */
.fsel__native{
  position:absolute; left:0; top:0; width:100%; height:100%;
  opacity:0; pointer-events:none;
}
.fsel__btn{
  display:block; width:100%; text-align:left; position:relative;
  font:inherit; font-size:var(--text-body); color:var(--ink);
  background-color:transparent; border:0;
  border-bottom:1px solid var(--line); border-radius:0;
  padding:11px 34px 11px 2px; cursor:pointer;
  transition:border-color .3s var(--ease);
}
.fsel__value{ display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fsel__btn:hover{ border-color:rgba(20,22,26,.38); }
/* Same two declarations .form input:focus-visible carries, including the
   -1px that compensates for the 1px→2px rule moving the baseline. */
.fsel__btn:focus-visible{
  outline:none; border-bottom:2px solid var(--navy); margin-bottom:-1px;
}
.form-row.is-invalid .fsel__btn{ border-bottom:2px solid var(--error); margin-bottom:-1px; }

/* The chevron is a mask here, not a background-image as it is on the
   bare <select>, so it can take currentColor and rotate when the list
   opens. Same --arrow-down-head glyph either way. */
.fsel__btn::after{
  content:""; position:absolute; right:2px; top:50%;
  width:12px; height:9px; margin-top:-4px;
  background-color:currentColor;
  -webkit-mask:var(--arrow-down-head) center/contain no-repeat;
          mask:var(--arrow-down-head) center/contain no-repeat;
  transition:transform .3s var(--ease);
}
.fsel__btn[aria-expanded="true"]::after{ transform:rotate(180deg); }

/* The panel. --field, not --paper: it is the same white the fields are,
   so the open list reads as the field growing rather than as a card
   landing on top of it. One hairline and a low navy-tinted shadow —
   the shadow is the only thing separating white from white, so it is
   doing work here rather than decorating. */
.fsel__list{
  position:absolute; z-index:40; left:0; top:calc(100% + 6px);
  min-width:100%; max-height:min(46vh,330px); overflow-y:auto;
  margin:0; padding:6px 0; list-style:none;
  background:var(--field); border:1px solid var(--line);
  box-shadow:0 18px 44px rgba(1,34,61,.13);
}
/* Belt and braces against the trap that produced the phantom form gap
   (U5): a rule that sets `display` on a [hidden] element beats the UA
   sheet silently. This one does not set display — and says so. */
.fsel__list[hidden]{ display:none; }
.fsel__list--up{ top:auto; bottom:calc(100% + 6px); }

.fsel__opt{
  position:relative; padding:12px 16px 12px 18px;
  font-size:var(--text-body); line-height:var(--lh-tight);
  color:var(--ink-soft); cursor:pointer;
  transition:color .2s var(--ease), background-color .2s var(--ease);
}
/* Hover and keyboard land on the SAME class. An option the arrow keys
   moved to and an option the mouse is over are one state, and giving
   them two appearances is how a list ends up with two highlights. */
.fsel__opt.is-active{ background-color:var(--paper); color:var(--ink); }
/* The chosen one is marked by a gold rule on its edge, not by a filled
   band — the same way the current item is marked everywhere else on
   the site. It survives being the active option at the same time. */
.fsel__opt.is-selected{ color:var(--ink); }
.fsel__opt.is-selected::before{
  content:""; position:absolute; left:0; top:0; bottom:0;
  width:2px; background-color:var(--accent);
}
@media (max-width:640px){
  /* 12px of padding on a 1.5 line box is a 43px row. Under a thumb the
     floor is 44, so the phone gets its two pixels back. */
  .fsel__opt{ padding-top:13px; padding-bottom:13px; }
}

/* ═════════════════════════════════════════════════════════════════════
   LAYER V — THE TABLET BAND (client, 2026-08-14)
   Appended, per the rule at the head of this sheet, because every block
   below beats an earlier declaration at EQUAL specificity and wins on
   source order alone. .page-hero in particular is redefined four times
   across layers G, H, I and K — anything earlier than this would lose.

   Reviewed on iPad Pro 13" PORTRAIT, 1024x1366, which is the width the
   client's screenshots were taken at (confirmed by column counts: the
   capabilities .figures row reads 3-up at 1024 and 4-up at 1366).

   821-1279.98 is deliberately the band. Below 821 the phone layout is
   already stacked and signed off; at 1280 the section rails switch on and
   take over the folio. In between, several blocks still reserve a 120px
   lane for a rail that is not there yet — which is what most of these
   notes are about.
   ═════════════════════════════════════════════════════════════════════ */
@media (min-width:821px) and (max-width:1279.98px){

  /* 1 — THE FOLIO SITS ABOVE THE HEADING, AND LOSES ITS NUMBER.
     .sec-head is minmax(0,120px) minmax(0,1fr) from 605, and the rails-on
     block at 645 only collapses it from 1280 up. So this band was the one
     place the label sat in a narrow column BESIDE the heading, wrapping
     "CAPABILITIES" against a 120px measure. Stacked now: folio, heading,
     lede. The numeral goes because the number belongs to the rail, and the
     rail does not exist below 1280 — leaving "02 /" above a heading prints
     a reference to a device the reader cannot see.

     display:none, not removal: site.js:171 reads the rail numeral out of
     this element with textContent, which is unaffected by display. innerText
     would have returned '' and printed an empty rail at 1280. */
  .sec-head{ grid-template-columns:minmax(0,1fr); gap:var(--g-pair); }
  .sec-head > .idx{ grid-column:1; padding-top:0; }
  .sec-head > div{ grid-column:1; }
  .sec-head .idx__n{ display:none; }

  /* 2 — NO INDENT WHERE THERE IS NO RAIL.
     .band__body's first column is EMPTY here: the block at 2689 puts the
     folio, the heading and the paragraph all in column 2, so the 120px
     track is pure indent reserving the rail lane. #sustainability re-declares
     the same shape at 2700 for this exact band. On the client's screenshot
     the text starts 120px in from a full-bleed photograph that has no rail
     beside it to line up with. */
  .band__body,
  #sustainability .band__body{ grid-template-columns:minmax(0,1fr); }
  .band__body > .idx,
  .band__body .h-sec,
  .band__body p,
  #sustainability .band__body .h-sec,
  #sustainability .band__body p{ grid-column:1; }
  .band__body .idx__n{ display:none; }

  /* 3 — THE ENQUIRY CLUSTER TAKES THE WHOLE ROW.
     Measured at 1024: the grid is 540.672px 340px, and the second track is
     EMPTY — .contact-mark is position:absolute and never enters the grid, so
     340px was being held for a watermark that overlaps regardless. The three
     request cards were sharing 540px because of it. The client's note is
     explicit that type over the mark is fine. */
  .contact-grid{ grid-template-columns:minmax(0,1fr); }

  /* 6 — THE VALUE LIST RUNS THREE-UP.
     auto-fit on a 190px floor gives FOUR columns in 922px of content, so six
     values broke 4 + 2 with a short orphan row. Three is the factor of six
     that fills both rows. Excludes the two modifiers that set their own
     counts — .marks--qa (3, already) and .marks--audit (4) — because they
     match .marks at equal specificity and this block, being later, would
     otherwise silently retype them. */
  .marks:not(.marks--qa):not(.marks--audit){
    grid-template-columns:repeat(3,minmax(0,1fr));
  }

  /* 7 — FIGURES RUN TWO-UP.
     Same shape of problem: auto-fit on a 230px floor gave three columns at
     1024, so a four-figure row broke 3 + 1. Two gives 2x2 and a wider cell,
     which the numeral cap then spends on type. .figures--wall is excluded —
     it carries its own column rule and its own --fig-w, and markets' --wide
     is already two-up here. */
  .figures:not(.figures--wall){
    grid-template-columns:repeat(2,minmax(0,1fr));
  }

  /* 5 — THE BANNER STOPS EATING TWO THIRDS OF A TALL SCREEN.
     66svh is a client figure from 2026-08-06 ("about two thirds of a screen,
     short enough that content shows above the fold"), and it was set against
     a landscape desktop. On 1024x1366 portrait two thirds is 902px — the
     heading occupies the top third and the rest is empty navy, so the band
     reads long and the photograph behind it reads small. The min() keeps the
     client's proportion wherever it still produces that result and only bites
     on a tall screen. The veil's own opacity is NOT touched: .3 + mask is the
     live value the client chose, per TRACKER. */
  .page-hero--type,
  .page-hero--index,
  .page-hero--figure,
  .page-hero--split{ min-height:min(66svh,620px); }
}

/* 4 — THE MENU FILLS THE WIDTH IT HAS.
     Capped at 1080, not 1279.98, because .overlay-inner is a single column
     only up to 1080 (4211); above that the contact card sits beside the nav
     and there is no spare width to grow into. Measured at 1024: the two nav
     columns used 187px + 212px of a 922px box and the links sat at 34.8px,
     which is the clamp FLOOR — min(3.4vw, 7.4vh) resolves to 3.4vw = 34.8
     there, so the type was as small as the scale allows while half the panel
     stood empty. 5.2vw puts it near 53px at 1024 and keeps the 7.4vh term in
     charge on a short landscape screen, where height is the real constraint. */
@media (min-width:821px) and (max-width:1080px){
  /* `> li > a`, matching the base rule at 1530 exactly. Written as
     `.overlay-nav a` first, it lost: 0,1,1 against the base's 0,1,2, so
     being later in the sheet bought nothing and the links stayed on the
     34.8px floor while the gap change beside it visibly took effect. */
  .overlay-nav > li > a{ font-size:clamp(34px, min(5.2vw, 7.4vh), 62px); }
  .overlay-main nav{ gap:0 clamp(56px, calc(18vw - 64px), 200px); }
}

/* ─── V-8. THE CLOUD GRAIN COMES OFF THE SMALL SCREENS ────────────────
   Client, 2026-08-14: navy sections read as soft grey blotches with straight
   seams through them, "on all mobile and tablet-portrait screens", and worse
   on hover (a hover repaint re-rasterises the filter region, so the grid
   jumps and catches the eye).

   This is the bug the note at .tex-navy::after already describes, arriving
   somewhere that note did not expect. Verbatim from it: at 2x "Chrome
   rasterises the 300-unit tile at a scale that stitchTiles cannot stitch",
   and it is "invisible in a 1x headless screenshot". The reason it was
   tolerated on sections is the sentence after: "on a section the tile is
   small against the content and the seams do not read; across an empty
   full-screen panel they are a grid."

   That premise is a DESKTOP premise. At 430px wide the tile is 364px — 85%
   of the viewport — and a stacked mobile section is mostly open navy, so the
   section IS the empty full-screen panel. baseFrequency 0.012 is ~3.6
   wavelengths across the tile, so what repeats is not grain, it is a cloud.

   Removed rather than retuned, which is the precedent this sheet already set
   for .overlay after three client reports. Desktop keeps the texture: there
   the tile is small against wide content and the seams genuinely do not read,
   and that rendering is signed off. body::after is untouched — its noise is
   baseFrequency .85, fine enough that a failed stitch has nothing to show.

   NOT VERIFIED HEADLESSLY, and the note above is why: 1x cannot see it, and
   headless at 2x rasterises SVG filters differently from a real display. This
   needs a look on the client's own device. */
@media (min-resolution:2dppx) and (max-width:1279.98px){
  .tex-navy::after,
  .tex-leather::after,
  .making-step .slot::after{ background-image:none; }
}

/* ═════════════════════════════════════════════════════════════════════
   LAYER W — MOBILE REVISION (client, 2026-08-14)
   Seven notes on the phone layout, reviewed at ~390-430px.
   ═════════════════════════════════════════════════════════════════════ */

/* W-1. THE SCROLL CUE GOES TO THE RIGHT EDGE.
   .banner-meta is a flex column that turns left-aligned at 820 (4434), which
   took the cue left with the facts and left the bottom-right corner of the
   banner empty. The facts stay left — they are read as text — and only the
   cue crosses, so the band closes on the corner a thumb actually reaches. */
@media (max-width:820px){
  .banner-meta .scroll-cue{ align-self:flex-end; }
}

@media (max-width:820px){
  /* W-2. ALL FOUR PRODUCTS VISIBLE, NO RAIL.
     Client: this is an important section and it should not need swiping. The
     rail at 4448 is cancelled back to a 2x2 grid, which is the same shape the
     821-1080 band already uses — so the phone now agrees with the tablet
     instead of inventing a third behaviour.

     EVERY property of the rail is reset, not just the columns. The sheet
     warns about exactly this trap at .prod-grid--two: grid-auto-flow,
     grid-auto-columns, the overflow, the snap type, the negative bleed, its
     compensating padding and the scroll-padding all have to come off, or the
     grid keeps behaving like a scroller that merely cannot scroll. */
  .prod-grid{
    grid-template-columns:repeat(2,minmax(0,1fr));
    grid-auto-flow:row;
    grid-auto-columns:auto;
    overflow-x:visible;
    overscroll-behavior-x:auto;
    scroll-snap-type:none;
    margin-inline:0;
    padding:0;
    scroll-padding-left:0;
  }
  .prod{ scroll-snap-align:none; }
}

/* W-3. ONE "CONTACT" IN THE MENU, NOT TWO.
   The nav's last link is Contact and the panel below it is headed Contact, so
   once the card stacks under the nav (1080, 4211) the word appears twice, one
   line apart — the client read the heading as a second, broken link.

   Hidden visually, NOT removed: it is the contact panel's only heading, and
   the dl below it opens straight into ADDRESS / EMAIL / HOTLINE, which is the
   same information a screen reader would lose. Same technique as .vh (8963).
   Done in CSS because .overlay is a shell region — changing its markup means
   editing index.html and running sync-shell.js across 19 pages. */
@media (max-width:1080px){
  .overlay-contact h2{
    position:absolute; width:1px; height:1px; padding:0; margin:-1px;
    overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0;
  }
}

@media (max-width:560px){
  /* W-4. THE BRAND BLOCK CENTRES.
     .foot-lockup is display:flex, so text-align alone would centre the blurb
     and leave the lockup hard left. */
  .foot-brand{ text-align:center; }
  .foot-lockup{ justify-content:center; }
  .foot-intro{ margin-inline:auto; }

  /* W-5. THE THREE LINK GROUPS STOP LOOKING RAGGED.
     Compliance is NOT inside .foot-link-pair — that holds only Company and
     Products. It is a fourth direct child of .foot-top, so it is already full
     width; what made it look like a broken third column is that its own <ul>
     runs single-file, leaving four short links down the left of a 350px row
     with nothing beside them.

     So the fix is the list, not the column: two-up matches the pair above it
     and the block reads as a footer row rather than a leftover. Written
     against .foot-top's own child, because the first attempt targeted
     .foot-link-pair > .foot-col:nth-child(3) and silently matched nothing. */
  .foot-top > .foot-col:last-child ul{
    grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:22px;
  }

  /* W-6. THE MONA CREDIT GOES BACK ON ONE LINE.
     It was stacked here on purpose — the note above records that the retired
     copyright line dodged the fixed .to-top button with padding-right:58px,
     and that padding knocks a CENTRED line off its axis. Client wants the row
     back, so the clearance is paid in height instead of in side padding: the
     button is 44px tall at bottom:16px, so 76px below the row keeps the
     credit clear of it without moving the row off centre. */
  .mona-sign{ flex-direction:row; gap:13px; }
  /* The clearance is FOOTER-ONLY. There are two .mona-sign blocks — one in
     .overlay-foot and one in the footer — and the base 560 rule stacks both.
     Only the footer's sits under the fixed .to-top button, so putting the
     76px on the shared class would push 76px of dead space into the open
     menu as well. */
  footer .mona-sign{ padding-bottom:76px; }
}

/* ─── W-8. CONTACT: THE MAP TAKES ONE SIDE, THE FACTS THE OTHER ───────
   Client 2026-08-14, "layout map 1 bên thông tin 1 bên". The three parts of
   #where were stacked: heading, then a full-bleed map, then a facts list that
   only ever filled half the row — so the right of the page was empty twice.

   The grid goes on the section's own .container rather than on .wheremaps,
   because the two things being paired are SIBLINGS: .wheremaps holds the map,
   and .wheremaps__facts is a separate <ul> beside it. Scoped to .p-where so
   .container keeps being a plain block everywhere else on the site.

   The figcaption stays with the map, in the left column. It is the map's
   label — "Factory / Head office", the address and the phone — and a caption
   that leaves its figure stops being a caption.

   The ratio changes with the column. 2.4/1 was drawn for a full-width band; in
   a half-width column it would render a 650px map only 270px tall, which is a
   letterbox with no room to read a street layout. 4/3 is the ratio the paired
   maps used when this block had two of them, and the .slot keeps an explicit
   aspect-ratio either way — without one it renders as nothing. */
@media (min-width:900px){
  .p-where > .container{
    display:grid;
    grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);
    column-gap:clamp(28px,4vw,64px);
    align-items:start;
  }
  .p-where > .container > .sec-head{ grid-column:1 / -1; }
  .p-where .wheremaps--one .slot{ aspect-ratio:4/3; }
  /* Top-aligned with the map, so the two columns start on one line. The
     margin was the stack's spacing and there is no stack any more. */
  .p-where .wheremaps__side > :first-child{ margin-top:0; }
}
/* .wheremaps__side is the whole right-hand column — address AND registration
   facts (client, 2026-08-14). One wrapper rather than placing two siblings by
   hand: with three flow items in a two-column grid, auto-placement would have
   put the facts back under the map in column 1, and pinning them with explicit
   grid-row is a sum that breaks the next time a row is added here.

   Below 900 it is a plain block, so the stacked order is unchanged: map, then
   address, then facts — the order the page had before the column existed. */
.wheremaps__side{ min-width:0; }
.wheremaps__side > .wheremaps__facts{ margin-top:var(--g-group); }

/* ═════════════════════════════════════════════════════════════════════
   LAYER X — TABLET LANDSCAPE (client, 2026-08-14), and two regressions
   Layer V introduced. Reviewed at ~1180.
   ═════════════════════════════════════════════════════════════════════ */

/* X-1. .sec-head--beside HAD NO OWNER BETWEEN 901 AND 1279, and Layer V walked
   into the gap. The treatment is a three-track head (folio | headline | lede);
   a max-width:900 block resets it to a stacked pair and a min-width:1280 block
   hands it to the rails — nothing covered the middle, because until Layer V the
   BASE rule was correct there.

   Layer V then set .sec-head to a single track for the whole 821-1279 band.
   Same specificity as .sec-head--beside and later in the file, so it won the
   track list — but it could not touch --beside's `grid-column:2` and
   `grid-column:3`, which then MANUFACTURED the tracks they referred to. The
   two client screenshots are both that:

     certifications @1180  tracks 691px 357px, the headline placed in track 2
                           and sitting hard RIGHT at 763..1121.
     sustainability @1180  tracks 0px 546px 489px. .cert-note carries no
                           grid-column of its own, so it auto-placed into the
                           leftover FIRST track — 19px wide and 711px tall, one
                           word per line.

   Fixed by finishing the collapse Layer V started: one track, and every child
   put back into it. .cert-note is the reason `> *` is used rather than naming
   the three known children — an auto-placed item in a stacked head must land
   in column 1 whatever it is, and the next <p> added here would hit the same
   0px track. */
@media (min-width:901px) and (max-width:1279.98px){
  .sec-head--beside{ grid-template-columns:minmax(0,1fr); row-gap:0; }
  /* The named selectors are not redundant with `> *`: the base rules are
     `.sec-head--beside .h-sec` (0,2,0) and `.sec-head--beside .h-sec + .lede`
     (0,3,0), and a `> *` at (0,1,0) loses to both no matter how late it sits.
     Written first as `> *` alone, this block changed nothing at all and the
     tracks stayed 691px / 357px. `> *` still earns its place — it is what
     catches .cert-note and anything else auto-placed. */
  .sec-head--beside > *,
  .sec-head--beside > div > *{ grid-column:1; }
  .sec-head--beside .h-sec{ grid-column:1; }
  .sec-head--beside .h-sec + .lede{
    grid-column:1; margin-top:var(--g-pair); padding-left:0;
  }
  /* Stacked, so there is no second column to share a baseline with — the same
     correction the 900 block spells out for the same arrangement. */
  .sec-head--beside .h-sec,
  .sec-head--beside .h-sec + .lede{ align-self:start; }
}

/* X-2. .marks COUNTS ITS OWN ITEMS.
   Layer V pinned this to three across for the tablet band, which was read off
   about.html's six values and is wrong for every other length: products.html
   carries FOUR and broke 3 + 1, exactly the shape the client rejected on the
   figure rows. The lists in this band run 2, 3, 4 and 6 items.

   So the count comes from the content: two across by default, three when there
   are exactly three (one clean row) or five and more (six becomes 3x2). :has()
   is already the sheet's tool for this kind of test — see .slot:has(img) and
   .fac-grid .idx:has(.idx__n). The two modifiers that set their own counts are
   still excluded; they tie .marks on specificity and this block, being later,
   would silently retype them. */
@media (min-width:821px) and (max-width:1279.98px){
  .marks:not(.marks--qa):not(.marks--audit){
    grid-template-columns:repeat(2,minmax(0,1fr));
  }
  .marks:not(.marks--qa):not(.marks--audit):has(> li:nth-child(3)):not(:has(> li:nth-child(4))),
  .marks:not(.marks--qa):not(.marks--audit):has(> li:nth-child(5)){
    grid-template-columns:repeat(3,minmax(0,1fr));
  }
}

/* X-3. ONE SIZE FOR THE PROMINENT FIGURES IN THIS BAND.
   Measured at 1180 before this rule: .figures--wall printed 130px on markets
   and sustainability while plain .figures printed 57px on capabilities,
   oem-odm and careers — the SAME figure ("150,000/mo" against "150K/mo")
   rendered at less than half the size one page over. The sheet's own rule is
   one figure size everywhere; two tiers is a desktop luxury that stops reading
   as hierarchy once every row is 2x2 in a ~510px cell, where 57px simply
   under-fills the cell it was given.

   The wall's clamp is the one that survives, because these are the numbers the
   client calls nổi bật. Rows whose numerals are long do not blow out: the
   --fig-w cap added earlier still measures each row against its own cell, so
   capabilities' 4.6-ratio string lands near 111px while the short rows take
   the full 130. Desktop and phone keep their existing two tiers untouched. */
@media (min-width:821px) and (max-width:1279.98px){
  .figures > div > b{
    font-size:min(clamp(64px,11vw,150px), calc(100cqw / var(--fig-w,2.2)));
  }
}

/* ============================================================
   LAYER Y — TABLET PORTRAIT (client, 2026-08-17)
   ============================================================
   Four items from a review on a portrait tablet. The band this layer
   addresses is 721–1080: it covers 768, 820 and the 1024x1366 the client
   actually reviews on, and it stops below 1081 so the landscape band
   (Layer X, 821–1279.98) keeps everything it just set. Where a fix is not
   a tablet-portrait fix at all but a rule that was wrong at every width
   under 1280, it is written at that width instead and says so.
   ------------------------------------------------------------ */

/* Y-1. THE BANNER'S TWO FACTS COME OFF EVERYTHING UNDER 1081.
   "Established 2024" went on 2026-08-14; the client has now named the two
   that survived it, so .banner-facts is empty of anything they want here
   and the element goes rather than its contents. .banner-meta keeps the
   scroll cue, which is its other child and the reason the column exists.

   Tablet first, then mobile the same day (client: "đồng bộ mobile luôn").
   The band is the whole sub-1081 range now, not 721–1080 — the phone was
   the one screen still printing them, which is the reading order they had
   objected to in the first place.

   Hidden, not deleted: the list is still the desktop banner's, and at
   ≥1081 it is untouched. That is why the <ul> stays in the markup. */
@media (max-width:1080px){
  .banner-facts{ display:none; }
}

/* Y-2. THE CERTIFICATION MARKS COME BACK, STACKED.
   They were not missing — .cert .cert__mark was display:none under 1080,
   and the measurement behind that is still correct: the widest pairing
   (the GRS tag at 293px against its 123px mark) needs ~430px of ONE LINE
   and the cell at 1024 gives 427px. What was wrong was the conclusion. The
   mark and the tag were competing for a single row, not for the page, so
   the fix is to stop asking them to share a line: the mark takes a row of
   its own above the name and the copy keeps the full cell width.

   The row is a fixed 56px — the tallest of the four marks (GRS) — with the
   images bottom-aligned in it. Each .cert is its own grid, so four marks of
   four different heights (56/34/34/44) would otherwise start their names at
   four different heights, and in a 2-up row that misalignment is visible
   across the pair. A fixed row plus align-self:end puts every mark on one
   baseline and every name on the next.

   The band is the whole sub-1081 range, tablet AND phone (client, same
   day: "đồng bộ mobile luôn"). It costs nothing on a phone — the cell is
   already the full column there, so the mark simply has a row above the
   name like everywhere else. The 55% cap is what keeps the widest mark
   (amfori, 158px) inside a 280px cell at 320.

   The old ≤1080 display:none has been REMOVED from its own block above
   rather than overridden here: a rule that is always countermanded by a
   later one is a trap for whoever reads that block next. Naming .cert
   still matters, and keeps this off certifications.html, where the same
   images sit in a flex .step__head and have never needed hiding. */
@media (max-width:1080px){
  .cert{ grid-template-rows:56px auto 1fr; }
  .cert .cert__mark{
    display:block;
    grid-column:1; grid-row:1;
    align-self:end; justify-self:start;
    max-width:min(160px,55%); height:auto;
  }
  .cert .cert__id{   grid-column:1; grid-row:2; }
  .cert .cert__body{ grid-column:1; grid-row:3; }
}

/* Y-3. THE FOOTER IS A 2x2: CONTACT | COMPANY / PRODUCTS | COMPLIANCE.
   .foot-top has four DOM children, but they are not the four columns —
   Company and Products are wrapped together in .foot-link-pair. Two tracks
   over four children is what put the whole nav pair in one 394px track at
   1024 while Compliance alone took 481px: the lists were half the width of
   the list beside them.

   display:contents on the wrapper is what makes the four lists four grid
   items. It is safe here precisely because .foot-link-pair carries nothing
   of its own but a grid and a gap — both inert once it stops generating a
   box — so nothing is lost by dissolving it. The brand takes the whole
   first row; the four then fall into source order, which is already the
   order the client asked for. */
@media (min-width:721px) and (max-width:1080px){
  .foot-top{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .foot-link-pair{ display:contents; }
  .foot-brand{ grid-column:1 / -1; }
}

/* Y-4. THE SUSTAINABILITY BAND STOPS SITTING 150px RIGHT OF THE PAGE.
   Measured at 1024 before this rule: the band's heading started at 202px
   while every section head on the page started at 51px. Two different
   mechanisms, one defect —

     821–1279.98  padding-left:calc(--pad-l + 120px + gap), which re-created
                  the desktop rail lane as padding after the grid collapsed.
     721–820      the base grid's own minmax(0,120px) first track, still
                  empty because .idx is placed in column 2.

   Both are the rail's lane. The rail does not exist below 1280 (.sec-rail
   is display:none until then), so under 1280 this is 150px of indent held
   for something that is not drawn. --pad-l on its own is what .container
   uses, and matching it is the whole of the client's request: flush with
   the other left-aligned text.

   The folio comes back with it. It was hidden at 821–1279.98 on the
   reasoning that applies at ≥1280, where the RAIL prints "04 /
   Sustainability" instead — but below 1280 there is no rail to print it,
   so the band was the one section on the page with no folio at all. At
   721–820 it was already visible, which is why the client's screenshot
   shows it; this makes the whole sub-1280 range agree with that.

   !important throughout because the rules being corrected use it. This
   block ties them on specificity ((1,1,0) and (1,2,0)) and wins on order. */
@media (min-width:721px) and (max-width:1279.98px){
  #sustainability .band__body{
    padding-left:var(--pad-l) !important;
    grid-template-columns:minmax(0,1fr) !important;
  }
  #sustainability .band__body > .idx{ display:block !important; }
  #sustainability .band__body > .idx,
  #sustainability .band__body .h-sec,
  #sustainability .band__body p{ grid-column:1 !important; }
}

/* Y-5. THE CONTACT EMAIL STOPS BEING SLICED IN HALF.
   Found by the sweep, not by the client, and in the band they review on:
   from 901 to ~1140 the email address ran past its cell and the page's own
   overflow:clip took the end off it. Worst at 901, where 75px of
   "info@nhatminhfootwear.com" was simply not on the screen. It never
   registered as page overflow because nothing ever grew the document —
   the same class of defect as the footer, and the reason that check exists.

   The cause is .step__facts li's minmax(0,150px) label column. 150px is a
   maximum, not a share, so the label takes it whole and leaves the value
   whatever is left — 181px at 1024, against an email that cannot wrap: no
   spaces, and the default break rules do not break after "@" or ".".

   Fixed on the CELL, not on a width. .step__facts is reused in thirteen
   pages' worth of contexts and its cell width has almost no relation to
   the viewport's — at 1024 it is 646px on capabilities and 347px on
   contact, which is exactly the trap the measure-token note records. So
   the list becomes its own container and the label goes above the value
   when the CELL is too narrow to hold both, wherever that happens. 390px
   is measured: contact's two lists are 347 and 374 there and every other
   list on the site is 455 or wider.

   overflow-wrap on the value as well. The stack is what keeps it looking
   right; this is what guarantees it can never be cut again, at any cell
   width the site has not been measured at yet. `anywhere`, not
   `break-word`, because only `anywhere` also lowers the min-content
   contribution — which is what lets the track shrink instead of forcing
   the overflow one level up. */
.step__facts{ container-type:inline-size; }
.step__facts span{ overflow-wrap:anywhere; }
/* The exclusion is :where(:not(...)), not :not(...), and the difference is
   the whole point. Three blocks re-use .step__facts and already stack their
   own way: .tl-step's timeline cells (gap 4px) sit at (0,2,1) and outrank
   this rule on specificity, so they were never at risk — but .sheet__facts
   (about's record sheet, gap 5px) is (0,1,1), exactly this rule's weight,
   and a later block ties it and wins. A plain :not(.sheet__facts) fixes that
   by climbing to (0,2,1) — and then ties .tl-step and clobbers the timeline
   instead. :where() carries no specificity, so the selector gets narrower
   without getting heavier and both blocks keep their own gap. Caught by an
   A/B of every .step__facts descendant box, 5 pages x 3 widths: it was 3px
   a row on about at 390 and nothing else. */
@container (max-width:390px){
  .step__facts:where(:not(.sheet__facts)) li{
    grid-template-columns:minmax(0,1fr); gap:2px;
  }
}

/* Y-6. THE MENU STOPS LOSING THE END OF "CERTIFICATIONS".
   Also the sweep's, at 1280 and only there. The overlay's two type columns
   plus their gutter wanted 615px of a 603px column, and .overlay clips, so
   the last 12px of Certifications and 6px of Sustainability were cut off
   the right edge with the menu open. It closes again on its own by ~1310:
   .overlay-inner's nav track grows with the viewport faster than the type
   and its gutter do, so the two curves cross just above 1280 and the
   shortfall exists in a ~30px window that happens to contain the single
   most common laptop width on the site's matrix.

   Two changes, and the second is the one that matters.

   The gutter yields first, in that window only. 14vw−80px instead of
   14vw−64px puts 99px between the columns at 1280 rather than 115 and
   leaves 4px of slack. The slope's shape and its 160px cap are the
   client's from 2026-08-06 and are untouched everywhere else — this is
   16px off one span of widths, not a new gutter.

   Then the tracks themselves become able to shrink. max-content tracks
   cannot, which is the whole reason a 12px shortfall became a clip rather
   than a reflow: with minmax(0,max-content) the same shortfall wraps a
   menu item instead. It changes nothing today — a track only shrinks when
   its container cannot afford it, and after the gutter fix none can't —
   but it is what makes the next long label a wrap rather than a defect.

   The min-width:561px on the track rule is NOT decoration. Written
   unconditionally it beat the phone rule at 4623 — `@media (max-width:560px)
   { .overlay-main nav{ grid-template-columns:minmax(0,1fr) } }` — because a
   media query adds NO specificity, so a later unconditional rule of the same
   weight wins inside it. The menu went back to two columns on a phone, ~140px
   each, and .overlay clipped "Certifications" by 68px. The sweep caught it at
   320/360/390 on the first complete run. Any later rule restating a property
   that a media query owns has to re-state the condition too. */
@media (min-width:561px){
  .overlay-main nav{ grid-template-columns:minmax(0,max-content) minmax(0,max-content); }
}
@media (min-width:1280px) and (max-width:1359.98px){
  .overlay-main nav{ column-gap:clamp(48px, calc(14vw - 80px), 160px); }
}

/* Y-7. THE BANNER COPY DROPS TO THE SCROLL CUE'S LINE UNDER 821.
   Client, 2026-08-17: "dời xuống cho nó bằng với bên thanh scroll".

   Measured, and the same at every width from 320 to 820: the copy block
   bottomed out at 693 while the cue bottomed out at 785 — 92px higher, which
   is exactly the meta row (52px) plus the grid's row gap (40px). At 821 and
   above the two are already level at 785, because there .banner-inner is two
   columns and they share a row. So this is not a new alignment, it is the
   desktop one arriving on the small screens: 1024 and 1440 both measure
   785/785 today and are untouched.

   The copy SPANS both columns rather than sitting in column 1. Given a column
   of its own it would lose 65px of cue lane plus the gutter, and at 390 that
   takes the headline from 350px to ~269 and rewraps "under one roof." onto a
   third line. Spanning keeps the full measure and lets the cue sit over the
   bottom-right corner, which is empty — the CTA is left-aligned and 239px
   wide.

   Empty at every width the site is used at, but not at 320: there the copy is
   280px, the button ends at 259 and the cue would start at 235, so the label
   would sit across the button by 24px. The clearance is +16px at 360, +46 at
   390 and +83 at 430, so only the very small end fails.

   The guard is a CONTAINER query, not another rung on the ladder. What
   decides this is whether the copy column can hold a 239px button and a 65px
   label side by side — a fact about the COLUMN, not about the viewport — and
   the ladder should not grow a rung to express something the cell can answer
   itself. Below 340px of copy the cue keeps its bar and drops its word; the
   bar is 3px, so the clearance problem disappears rather than being managed.
   Same principle as .step__facts above. */
@media (max-width:820px){
  .banner-inner{
    grid-template-columns:minmax(0,1fr) auto;
    container-type:inline-size;
  }
  .banner-inner > div{ grid-column:1 / -1; grid-row:1; }
  .banner-meta{ grid-column:2; grid-row:1; align-self:end; justify-self:end; }
}
@container (max-width:340px){
  .scroll-cue > .mono{ display:none; }
}

/* ==========================================================================
   Mobile: the iOS 26 status-bar strip. Theme addition; the prototype above is
   untouched.

   Established by on-device A/B pages (/ios-a.html, /ios-b.html): with
   viewport-fit=cover, an OPAQUE sticky bar at top:0 padded by
   env(safe-area-inset-top) keeps the strip clean even over navy display-size
   content — BOTH test pages were clean on the phone while the real site still
   leaked. So the mechanism is right and something site-specific was defeating
   it. The block below is the exact shape of test page B, plus the one
   structural difference between the test pages and the site: .overlay.

   .overlay is position:fixed; inset:0 — a full-screen layer sitting across the
   strip at all times, "hidden" only by clip-path, which removes its paint but
   not the layer. The test pages have no such layer. While closed it is now
   visibility:hidden as well (delayed so the closing sweep still animates),
   which takes it out of compositing entirely; open, it covers the strip navy —
   with cover, inset:0 genuinely reaches the screen top.
   ========================================================================== */
@media (hover:none), (pointer:coarse), (max-width:820px){
  .header{
    position:sticky; top:0;
    padding-top:calc(22px + env(safe-area-inset-top, 0px));
    background:#FCFDFF;
    color:var(--ink); border-bottom-color:var(--line);
  }
  /* The prototype's .header.is-scrolled (0,2,0) outweighs the rule above and
     would flip the bar back to translucent glass mid-scroll. iOS renders the
     content behind a glass bar up into the strip — opaque in both states. */
  .header.is-scrolled{
    background:#FCFDFF;
    -webkit-backdrop-filter:none;
    backdrop-filter:none;
  }
  .header .logo__mark{ filter:none; }
  .header .logo__sub{ color:var(--navy); }

  /* Closed, the overlay leaves compositing entirely. The 0s visibility flip is
     delayed until the .8s clip-path sweep finishes, so the close animation is
     intact; opening flips visibility first, instantly. */
  .overlay{
    visibility:hidden;
    transition:clip-path .8s var(--ease), visibility 0s .8s;
  }
  .overlay.is-open{
    visibility:visible;
    transition:clip-path .8s var(--ease), visibility 0s 0s;
  }
  /* THE CULPRIT, found by on-device bisection (pages A-F): body::after at
     site.css:483 — the film-grain texture — is a full-screen position:fixed
     layer at z-index 9998, above everything, on every page. iOS 26 renders the
     status-bar strip from the topmost layer at the screen top; a nearly
     transparent full-screen film there reads as see-through, so Safari drew
     the scrolled document into the strip and never sampled the opaque bar
     underneath. Every headerless test page was clean; every real page leaked;
     removing JS changed nothing — all three facts point here and only here.
     No grain on phones; at 5.5% alpha nobody will miss it on a 6" screen. */
  body::after{ display:none; }

  /* The drawer covers the strip too; its first row clears the clock. */
  .overlay-inner{ padding-top:calc(clamp(96px,13vh,150px) + env(safe-area-inset-top, 0px)); }

  /* An in-flow sticky bar eats the top of every anchor target. */
  [id]{ scroll-margin-top:96px; }
}

/* Safari shipped unprefixed backdrop-filter only in 18. Desktop-scoped: these
   sit after the mobile block and would otherwise re-enable the glass there. */
@media (hover:hover) and (pointer:fine) and (min-width:821px){
  .header.is-scrolled{ -webkit-backdrop-filter:blur(14px) saturate(140%); }
  .header.is-menu-open,
  .header.is-menu-open.is-scrolled{ -webkit-backdrop-filter:none; backdrop-filter:none; }
}
