/* Generated by scripts/fetch-fonts.mjs — do not edit by hand.
   Self-hosted so the site makes no external requests. All three families are
   SIL Open Font License 1.1; see public/fonts/OFL.txt. */

@font-face {
  font-family: 'DM Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/dm-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: 'DM Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/dm-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: 'DM Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/dm-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: 'DM Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/dm-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: 'DM Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/dm-sans-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: 'DM Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/dm-sans-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: 'DM Sans';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/dm-sans-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: 'DM Sans';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/dm-sans-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: 'DM Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/dm-sans-600-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: 'DM Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/dm-sans-600-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: 'DM Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/dm-sans-700-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: 'DM Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/dm-sans-700-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: 'Manrope';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/manrope-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: 'Manrope';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/manrope-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: 'Manrope';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/manrope-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: 'Manrope';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../fonts/manrope-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: 'Manrope';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/manrope-600-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: 'Manrope';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/manrope-600-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: 'Manrope';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/manrope-700-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: 'Manrope';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/manrope-700-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: 'Manrope';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/manrope-800-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: 'Manrope';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/manrope-800-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;
}

/* ==========================================================================
   GENERATED — do not edit. Source: data/brands.json -> brandColors,
   derived by scripts/lib/color.mjs, emitted by brandTokensCss() in build.mjs.

   Editing this block by hand is pointless: the next `node build.mjs` will
   overwrite it. To change a brand colour, change data/brands.json.

   Measured against --paper #FFFFFF:
     OnaFit           #8C7257  accent   4.5:1   ink #7C654D  5.48:1   field #8C7257 + #FFFFFF   4.5:1
     Karisma Threads  #91C740  accent  2.01:1   ink #567923  5.05:1   field #91C740 + #16181C  8.84:1
     ScrubAid         #3E90B5  accent  3.58:1   ink #306F8B  5.56:1   field #367E9E + #FFFFFF  4.53:1
     EliteMed         #FFC3B0  accent  1.53:1   ink #BF2E00  5.82:1   field #FFC3B0 + #16181C 11.58:1
     JustScrubs       #C21F68  accent  5.69:1   ink #B91E63  6.12:1   field #C21F68 + #FFFFFF  5.69:1
   ========================================================================== */
:root {
  --brand-onafit: #8C7257;
  --brand-onafit-ink: #7C654D;
  --brand-onafit-line: #8C7257;
  --brand-onafit-field: #8C7257;
  --brand-onafit-field-ink: #FFFFFF;
  --brand-onafit-tint: #F8F6F4;
  --wash-onafit: 214 149 81;
  --wash-onafit-panel: 214 149 81;

  --brand-karisma: #91C740;
  --brand-karisma-ink: #567923;
  --brand-karisma-line: #73A02F;
  --brand-karisma-field: #91C740;
  --brand-karisma-field-ink: #16181C;
  --brand-karisma-tint: #F7FAF2;
  --wash-karisma: 161 214 81;
  --wash-karisma-panel: 161 214 81;

  --brand-scrubaid: #3E90B5;
  --brand-scrubaid-ink: #306F8B;
  --brand-scrubaid-line: #3E90B5;
  --brand-scrubaid-field: #367E9E;
  --brand-scrubaid-field-ink: #FFFFFF;
  --brand-scrubaid-tint: #F2F7FA;
  --wash-scrubaid: 81 173 214;
  --wash-scrubaid-panel: 81 173 214;

  --brand-elitemed: #FFC3B0;
  --brand-elitemed-ink: #BF2E00;
  --brand-elitemed-line: #FF5E2B;
  --brand-elitemed-field: #FFC3B0;
  --brand-elitemed-field-ink: #16181C;
  --brand-elitemed-tint: #FAF4F2;
  --wash-elitemed: 255 92 41;
  --wash-elitemed-panel: 255 92 41;

  --brand-justscrubs: #C21F68;
  --brand-justscrubs-ink: #B91E63;
  --brand-justscrubs-line: #C21F68;
  --brand-justscrubs-field: #C21F68;
  --brand-justscrubs-field-ink: #FFFFFF;
  --brand-justscrubs-tint: #FAF2F6;
  --wash-justscrubs: 255 41 149;
  --wash-justscrubs-panel: 225 70 140;
  --brand-justscrubs-support: #FF82C1;
}

[data-accent="onafit"] {
  --accent: var(--brand-onafit);
  --accent-ink: var(--brand-onafit-ink);
  --accent-line: var(--brand-onafit-line);
  --accent-field: var(--brand-onafit-field);
  --accent-field-ink: var(--brand-onafit-field-ink);
  --accent-tint: var(--brand-onafit-tint);
  --wash-brand: var(--wash-onafit-panel);
}
[data-accent="karisma"] {
  --accent: var(--brand-karisma);
  --accent-ink: var(--brand-karisma-ink);
  --accent-line: var(--brand-karisma-line);
  --accent-field: var(--brand-karisma-field);
  --accent-field-ink: var(--brand-karisma-field-ink);
  --accent-tint: var(--brand-karisma-tint);
  --wash-brand: var(--wash-karisma-panel);
}
[data-accent="scrubaid"] {
  --accent: var(--brand-scrubaid);
  --accent-ink: var(--brand-scrubaid-ink);
  --accent-line: var(--brand-scrubaid-line);
  --accent-field: var(--brand-scrubaid-field);
  --accent-field-ink: var(--brand-scrubaid-field-ink);
  --accent-tint: var(--brand-scrubaid-tint);
  --wash-brand: var(--wash-scrubaid-panel);
}
[data-accent="elitemed"] {
  --accent: var(--brand-elitemed);
  --accent-ink: var(--brand-elitemed-ink);
  --accent-line: var(--brand-elitemed-line);
  --accent-field: var(--brand-elitemed-field);
  --accent-field-ink: var(--brand-elitemed-field-ink);
  --accent-tint: var(--brand-elitemed-tint);
  --wash-brand: var(--wash-elitemed-panel);
}
[data-accent="justscrubs"] {
  --accent: var(--brand-justscrubs);
  --accent-ink: var(--brand-justscrubs-ink);
  --accent-line: var(--brand-justscrubs-line);
  --accent-field: var(--brand-justscrubs-field);
  --accent-field-ink: var(--brand-justscrubs-field-ink);
  --accent-tint: var(--brand-justscrubs-tint);
  --wash-brand: var(--wash-justscrubs-panel);
}

/* ==========================================================================
   Denice Inc — wholesale medical scrubs
   src/site.css — theme rebuild (theme-rebuild branch, Aug 2026)

   Two registers, deliberately alternated. The LOOKBOOK register — home, brand
   pages, about, fabrics — runs full-bleed photography, enormous whitespace,
   image-and-text bands and a dark stat band and footer. The SPEC-SHEET
   register — the catalogue grid and the style pages — keeps the CAD-sheet
   language the site already had: technical flats, mono size ratios, circular
   swatch grids, thin red callout rules. The two share one type scale, one
   header and one footer, so the switch between them reads as intentional.

   Sections
     01 tokens          07 buttons          13 catalogue
     02 reset + base    08 photography      14 order / steps / cta
     03 typography      09 figures (flats)  15 motion
     04 motifs          10 cards            16 responsive
     05 layout          11 data blocks      17 focus + print
     06 chrome          12 lookbook bands

   Rules that must not be broken (see CLAUDE.md):
   - Denice red is punctuation only. There is no --danger token on purpose.
   - The BOX owns every image ratio, never the image.
   - Photographs are never scaled beyond their native width. Where a frame is
     wider than the photo allows, the photo's own studio ground is extended as
     a flat bleed and the image sits off-centre against it. See 08.
   - Only transform and opacity are animated. Hidden initial states exist only
     under prefers-reduced-motion: no-preference and only under .js.
   ========================================================================== */


/* == 01  tokens =========================================================== */

:root {
  /* brand */
  --red: #D0112B;
  --red-deep: #A80D22;

  /* ink — the ramp is monotonic AND every step clears WCAG AA (4.5:1) on
     both grounds it is ever set on: --paper #FFFFFF and the pale accent
     tint #ECEFF7. ink-3 was #7A8089 (3.98 on white, 3.46 on the tint) and
     ink-4 was #9BA1A9 (2.60 on white) — both failed, and both carry real
     buying facts, not ornament: ink-3 sets the fabric blend and the footer,
     ink-4 sets "8 colors" and the size-run size labels. The faint tier is
     visibly darker than it was; that is the cost of the floor. Contrast on
     white now: 17.8 / 8.9 / 5.7 / 5.2. */
  --ink: #16181C;
  --ink-2: #454A52;
  --ink-3: #5F646D;
  --ink-4: #686D76;

  /* ---- grounds: three, and every one of them a decision ----
     There were nine (paper, wash, wash-2, bone, bone-2, and four tints) and
     every one sat within a few percent of white, so none of them read as a
     choice — they read as an accident, which is what "unfinished" means here.
     Worse, wash and rule were BLUE-grey (B > R) sitting under warm garment
     photography, and that mismatch is the specific tell.

     Josmo, the reference, uses exactly two grounds — #FFFFFF and a near-black —
     and lets every scrap of colour arrive through full-bleed imagery. That is
     the mechanism being borrowed: not a richer set of tints, but the removal
     of the unresolved middle so the photographs and the fabric fields are the
     only colour on the page.

     --paper   every surface that holds text. It is also, exactly, the studio
               ground of all ten new landscape photographs, which sample
               #ffffff — so a full-bleed hero now meets the page invisibly.
     --shell   the ONE tinted ground, for a band that has to step back from
               paper. Chosen by the owner (19 Aug 2026) from three luminance-
               matched candidates: it is the studio wall itself — #F4F5F7 is
               the single most common non-white ground in data/photos.json,
               with #EAEEF2 and #E6EAED behind it — held at its own cool hue
               and given just enough chroma to read as a surface. A cool
               ground is the foil the rest of the system wants: the Denice
               red advances hardest off it, and none of the five fabric
               fields shares its hue, so it stays out of every fight. The
               warm beige that preceded it (#F2EFE9) sat too close to the
               fields and argued with the red.
     --ink     the near-black anchor: the top bar, the footer, the stat band. */
  --paper: #FFFFFF;
  --shell: #ECEFF7;

  /* rules — tuned to the shell's own cool cast, so a hairline never reads
     warmer than the ground it sits beside */
  --rule: #E3E6EC;      /* hairline on paper */
  --rule-2: #EFF1F6;
  --rule-shell: #DDE1EA; /* hairline ON shell, where --rule would vanish */

  /* dark surfaces — footer, stat band, hero scrims (added Aug 2026) */
  --slate: #0F1114;
  --slate-2: #1B1E23;
  /* The proof chapter's ground: the same graphite as --slate, one step up, so
     the glows have somewhere to fall to and the band is not pure --slate
     against the footer directly below it. Chosen by the owner 20 Aug 2026
     over a warm-neutral and a studio-shell candidate. */
  --slate-field: #13161A;
  --on-slate: #C9CDD4;
  --on-slate-2: #8C929B;
  --slate-rule: #262A31;


  /* the only status colour on the site */
  --ok: #1F7A46;
  --ok-bg: #E8F5ED;

  /* ---- THE GRADIENT WASH IS REMOVED, EXCEPT ON /brands/ (owner, 27 Aug
     2026, amended the same day) ----
     Every light ground on the site is plain --paper white: sections, heroes,
     the spec header, the brand stack, the proof chapter. The dark closing CTA
     and the footer keep their slate field, which was never part of the wash.

     THE ONE EXCEPTION IS THE /brands/ IDENTITY PANEL (.bfield-copy), which
     the owner kept exactly as it was — wash, flip, stacked variant and the
     bleed under the photograph. Its whole mechanism now lives in that one
     block near the bottom of this file rather than in a shared selector list
     up here, so nothing else can pick the ground up by being added to a
     list. The five brand PAGES are not that component and stay white.

     What remains of the machinery here:
       - the --wash-<slug> "r g b" triples are still GENERATED into the built
         stylesheet by brandTokensCss() (data/brands.json is the source);
         .bfield-copy paints from --wash-brand and the .fprops hairlines tint
         from it.
       - scripts/sample-wash-palette.mjs and data/wash-palette.json stay as
         provenance for those hues.
       - scripts/check-wash-contrast.mjs still evaluates the real gradient
         geometry, for the five brand panels that still have one, and checks
         the flat grounds besides. */

  /* brand accents — GENERATED. The five --tier-* tokens that stood here were a
     second copy of data/brands.json, and the map of this stylesheet found they
     had no consumers at all: five brand hexes maintained for nothing, still
     carrying EliteMed's retired #D9601C. Their replacements are --brand-<slug>
     and the [data-accent] scopes, both emitted by build.mjs.

     --accent stays defined here as the FALLBACK for anything outside a brand
     scope. */
  --accent: var(--ink);

  /* type — three faces, each with one job (19 Aug 2026).
     Archivo did the first two and did them neutrally, which is how a site
     laid out correctly still reads as a template. --display is Manrope:
     semi-rounded geometric humanist, warm at large size, and built to take
     the tracking below. --sans is DM Sans, the sibling of the DM Mono this
     project has always self-hosted, so read text and measured text are now
     family rather than coincidence. Both SIL OFL; see scripts/fetch-fonts.mjs. */
  --display: "Manrope", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  --sans: "DM Sans", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  --mono: "DM Mono", ui-monospace, "SFMono-Regular", "Cascadia Mono", Menlo, monospace;

  /* Display sizes carry their own line-height and tracking, because the two
     move together: the bigger the type the tighter it has to be set, and a
     single global letter-spacing cannot do that. */
  /* 86px, not 104. Manrope is wider per character than Archivo AND the ceiling
     had gone up, and the two compounded: the home H1 wants 2152px on one line
     against an 855px copy column, which is 2.5 columns, so text-wrap: balance
     settled on FOUR even lines and split the sentence — "scrubs. All of /
     them ours." 86px brings it back to three with the break at the full stop,
     and it still reads larger than Archivo did at 92 because Manrope carries
     a taller x-height. */
  --t-hero: clamp(44px, min(7.2vw, 11svh), 86px);
  --t-hero-lh: .98;
  --t-hero-tr: -.028em;
  --t-d1: clamp(36px, 5vw, 72px);
  --t-d1-lh: 1.02;
  --t-d1-tr: -.025em;
  --t-d2: clamp(27px, 3.2vw, 44px);
  --t-d2-lh: 1.08;
  --t-d2-tr: -.02em;
  --t-d3: clamp(20px, 1.7vw, 27px);
  --t-d3-lh: 1.2;
  --t-d3-tr: -.01em;
  --t-lede: clamp(18px, 1.45vw, 22px);
  /* DM Sans sets a shade smaller on the body than Archivo did at the same
     number, so the body size goes up half a point to hold the same measure. */
  --t-body: 17px;
  --t-data: 13px;
  --t-label: 11px;

  /* space — 4px base */
  --s1: 4px;   --s2: 8px;   --s3: 12px;  --s4: 16px;  --s5: 24px;
  --s6: 32px;  --s7: 48px;  --s8: 64px;  --s9: 96px;  --s10: 128px;

  /* ---- MORE AIR (owner, 20 Aug 2026) ----
     The bands were dense: 176px at the top of the scale sounds generous and
     is not, because it is the ONLY gap in the composition -- the head sat
     48px off its content and two bands inside one section sat 176px apart,
     so the eye met a wall of material and one margin around it.

     Every one of those numbers goes up together, and that is the point. Air
     added at the section boundary alone just pushes dense blocks further
     apart; what makes a page read as calm is the same generosity repeated at
     every level of the nesting -- around the section, under the head, between
     the bands, inside the grid. */
  /* ---- THE CAPS COME DOWN (owner, 1 Sep 2026) ----
     224 -> 160 and 140 -> 112. This does not take back the 20 Aug air at the
     widths it was designed for; it stops that air DOUBLING into dead space at
     the widths people actually work at.

     The arithmetic nobody had done: --sect-y is paid by BOTH neighbours, so
     two adjacent sections put 2x it between their content. At 224 that is a
     448px gap. The cap is reached at a 1600px viewport, so every screen from
     1600 up got the maximum -- and the 20 Aug decision was measured at 1440,
     where 14vw is 199.5 and the gap is 399. It was never seen at 448.

     Measured against the rule below (gap vs the smaller neighbour) across
     seven pages at 1440/1920/2400, seventeen gaps failed: the home and About
     stat panels took a 364px gap around 289px of content, /new/ took 332
     against 227, and "Twenty years" -> "Newest arrivals" took a gap exactly
     as tall as the section it separated. At 160/112 the worst of those is
     272 against 289 and every one of them clears.

     A cap alone was not enough and that is why the value moved: five of the
     seventeen failed at 1440, below where the cap even binds. */
  --sect-y: clamp(112px, 14vw, 160px);
  --sect-y-tight: clamp(72px, 9vw, 112px);
  /* the gap under a section head, and the gap between two bands in one
     section -- both were --s7 (48px) and both are their own decision now */
  --head-gap: clamp(48px, 5vw, 80px);
  --band-gap: clamp(88px, 11vw, 176px);
  --wrap: 1320px;
  --wrap-wide: 1560px;
  --gutter: clamp(20px, 4vw, 56px);
  --head-h: 64px;
  /* The black bar above the header. It is NOT sticky — it scrolls away and the
     masthead pins in its place — but it does occupy the first 40px of the
     document, so anything sized to fill the viewport has to subtract it. */
  --bar-h: 40px;

  /* figures — one token governs every flat's slot on the site */
  --fig-ratio: 4 / 5;

  /* ---- depth ----
     The site was hairlines and nothing else, which is a real part of why it
     read flat and therefore cheap. Three lifts, and two rules taken from the
     reference: the shadow is tinted with the ink (Josmo tints its own
     rgba(15,23,42,…)) because a neutral black shadow on a warm ground reads as
     dirt; and each carries a large NEGATIVE spread, which pulls the shadow in
     under the element so it reads as something lifted off the page rather than
     something with a drop shadow behind it.
     lift-1 rest · lift-2 hover · lift-3 the one genuinely floating thing. */
  --lift-1: 0 1px 2px rgba(20,22,26,.04), 0 8px 24px -16px rgba(20,22,26,.18);
  --lift-2: 0 18px 44px -28px rgba(20,22,26,.28);
  --lift-3: 0 24px 60px -20px rgba(20,22,26,.45);

  /* Radii: three, plus the pill. Was 12/18/20/24/8/2 with no system. */
  --r-sm: 12px;
  --r-md: 20px;
  --r-lg: 28px;
  --r-pill: 999px;

  /* motion — slow, confident, never bouncy */
  --ease: cubic-bezier(.16, 1, .3, 1);
  --ease-io: cubic-bezier(.65, 0, .35, 1);
  --dur: 700ms;
  --dur-x: 900ms;
  --stagger: 80ms;
}


/* == 02  reset + base ===================================================== */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}
html.menu-open { overflow: hidden; }

body {
  margin: 0;
  font-family: var(--sans);
  font-size: var(--t-body);
  line-height: 1.6;
  color: var(--ink);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img { max-width: 100%; display: block; }
a { color: inherit; }
button { font: inherit; color: inherit; }

/* Nothing jumps state. Every interactive element eases colour, opacity and
   transform; components that need a different curve set their own. */
a, button, input, summary, label, .card, .sw, .chip {
  transition:
    color 300ms var(--ease), background-color 300ms var(--ease),
    border-color 300ms var(--ease), opacity 300ms var(--ease),
    transform 400ms var(--ease);
}

/* The UA rule for [hidden] is display:none, but any class that sets display
   (.card is flex, .grid is grid) outranks it. Without this, filtered-out
   cards stay on screen. */
[hidden] { display: none !important; }

hr {
  border: 0;
  border-top: 1px solid var(--rule);
  margin: var(--s7) 0;
}

::selection { background: var(--red); color: #fff; }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--ink); color: #fff; padding: 14px 22px;
  font-family: var(--sans); font-size: var(--t-data); text-decoration: none;
}
.skip:focus { left: 0; }

.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}


/* == 03  typography ======================================================= */

/* Headings are the display face. Body, ledes, labels and navigation stay in
   --sans: Manrope is drawn to be seen, DM Sans to be read, and a paragraph set
   in a display face is exactly the sort of thing that reads as a template. */
h1, h2, h3, h4 { font-family: var(--display); margin: 0 0 var(--s4); text-wrap: balance; }

h1 {
  font-size: var(--t-d1);
  font-weight: 800;
  letter-spacing: var(--t-d1-tr);
  line-height: var(--t-d1-lh);
}

h2 {
  font-size: var(--t-d2);
  font-weight: 700;
  letter-spacing: var(--t-d2-tr);
  line-height: var(--t-d2-lh);
}

h3 {
  font-size: var(--t-d3);
  font-weight: 700;
  letter-spacing: var(--t-d3-tr);
  line-height: var(--t-d3-lh);
}

h4 {
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -.005em;
}

p { margin: 0 0 var(--s4); max-width: 68ch; }

.lede {
  font-size: var(--t-lede);
  line-height: 1.5;
  color: var(--ink-2);
  max-width: 58ch;
  letter-spacing: -.008em;
}

.prose p + h3 { margin-top: var(--s7); }
.prose p:last-child { margin-bottom: 0; }

.mono { font-family: var(--mono); }

.micro {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.dim { color: var(--ink-3); }

/* tabular figures wherever a number is meant to be compared */
.tnum { font-variant-numeric: tabular-nums; }

/* ---- a long text page (the privacy policy) ----
   The rest of the site sets short measures against photographs. This is the
   one register where a reader has to hold a line for two thousand words, so
   three things change and nothing else does:

     MEASURE. The page runs inside .wrap-narrow (880px), and the paragraph
     keeps the site's 68ch — the two together land the line around 66-70
     characters, which is where a reader stops losing the return sweep. The
     lede is left at its own 58ch: it is display text, and matching it to the
     body would make the opening read as body copy.

     HIERARCHY. A section heading here is --t-d3, NOT the --t-d2 an h2 takes
     elsewhere. On a lookbook page an h2 opens a band the reader has scrolled
     to and is competing with a photograph; here there are nine of them on one
     white page with nothing else on it, and at 44px they read as nine titles
     rather than as one document with nine parts. The hairline above each is
     what marks the break — the same .section.rule idea at a smaller scale.

     RHYTHM. Space between two sections is larger than space inside one, or
     the heading floats between its own section and the one before it. --s9
     above the rule, --s6 below the heading, --s4 between paragraphs (which is
     the site default, so it is inherited rather than restated).

   Everything is a token. No new colours, no new sizes. */
.legal { max-width: 68ch; }

.legal-sec + .legal-sec {
  margin-top: var(--s9);
  padding-top: var(--s7);
  border-top: 1px solid var(--rule);
}

.legal-sec h2 {
  font-size: var(--t-d3);
  letter-spacing: var(--t-d3-tr);
  line-height: var(--t-d3-lh);
  margin: 0 0 var(--s6);
}

/* Numbers are not printed beside these headings on purpose: a clause number is
   a promise that the clauses are stable and citable, and this notice never
   cites itself. Each section does carry an id, so a paragraph can still be
   linked to — /privacy/#your-rights — and the two languages share those ids,
   so a link into one has a counterpart in the other. */

.legal-sec p { color: var(--ink-2); }

.legal-sec ul {
  margin: 0 0 var(--s4);
  padding: 0;
  list-style: none;
  max-width: 68ch;
}
/* The marker is a hairline dash rather than a disc: the site has no bulleted
   list anywhere else, and a dash sits in the same family as the callout rules
   and the spec-row keys. It is drawn with an ::before so the text of a
   wrapped item still aligns to the text above it. */
.legal-sec li {
  position: relative;
  padding-left: var(--s5);
  margin: 0 0 var(--s3);
  color: var(--ink-2);
}
.legal-sec li::before {
  content: "";
  position: absolute;
  left: 0;
  top: .72em;
  width: 10px;
  height: 1px;
  background: var(--ink-3);
}

/* The letterhead that closes the document. `address` is italic by default in
   every browser and a postal address is not an aside; the contact rows beside
   it are the site's own .pack component, so they carry no rule of their own. */
.legal-sec address {
  font-style: normal;
  line-height: 1.7;
  color: var(--ink-2);
  margin: 0 0 var(--s5);
}
.legal-sec .pack { margin: 0; }

/* There is no dated stamp at the foot of the page: the effective date is in
   the spec-head meta block at the top, where the source document puts it and
   where a reader checking whether a notice is current looks first. Printing it
   twice is two things to update. */

/* The language toggle is the ordinary ghost button in the spec head's action
   slot, so it needs no rule of its own. This is the one thing it does need:
   Spanish and English words for a language are different lengths, and the
   button must not resize the header when the reader moves between the two. */
.spec-head .hero-actions [hreflang] { min-width: 9.5rem; justify-content: center; }


/* == 04  motifs =========================================================== */

/* ---- the section mark ----
   A 32px tinted plate carrying a 16px line icon, and the label set BESIDE it.
   Replaces the red dot and the 72px hairline that opened every section: that
   was one weight of one colour repeated thirty-one times, so it announced
   that a section had started and said nothing about which one.

   The tint is the whole effect and both numbers were found rather than
   picked. Below about 10% the plate disappears against paper and the icon
   floats unsupported; above about 18% it reads as a filled button, which is
   a control, and none of these is clickable. 12% ground and a 22% ring sit
   in the gap: enough to read as a surface, not enough to read as a target.

   The ring is an inset box-shadow, not a border, so the plate is exactly
   32px whatever the ring does and the label's baseline never shifts.

   --eb is the accent and defaults to the house red. It must always be an
   accentInk() value on a brand page: the LABEL takes the accent too, and at
   11.5px that is small text, which owes 4.5:1 -- exactly what accentInk()
   solves for. */
.eyebrow {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 var(--s5);
  font-family: var(--sans);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--eb, var(--red));
  max-width: none;
}
.eyebrow .eb-t { display: block; }
.eb-i {
  display: grid;
  place-items: center;
  flex: 0 0 32px;
  width: 32px;
  height: 32px;
  border-radius: 10px;
  color: var(--eb, var(--red));
  background: color-mix(in srgb, var(--eb, var(--red)) 12%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--eb, var(--red)) 22%, transparent);
}
.eb-i svg { display: block; width: 16px; height: 16px; }

/* On the dark field the label goes white -- the lifted red is legible but it
   is carrying the icon, and a coloured label as well would put two reds in a
   three-word line. The plate keeps the lifted red and its tints come up,
   because 12% of anything over a dark ground is nothing. */
.eyebrow.on-ink { color: #fff; }
.eyebrow.on-ink .eb-i {
  color: var(--hl, #F25F72);
  background: color-mix(in srgb, var(--hl, #F25F72) 18%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hl, #F25F72) 34%, transparent);
}
.eyebrow.quiet { color: var(--ink-3); }
.eyebrow.quiet .eb-i {
  color: var(--ink-3);
  background: color-mix(in srgb, var(--ink-3) 10%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink-3) 20%, transparent);
}

/* Plate number — 01 / 05 */
.plate {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .06em;
  color: var(--red);
}
.plate i { font-style: normal; color: var(--ink-4); }

/* Sheet mark — foot of a spec plate */
.sheet-mark {
  font-family: var(--sans);
  font-size: 11.5px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-4);
  margin: var(--s4) 0 0;
}


/* == 05  layout =========================================================== */

.wrap {
  max-width: var(--wrap);
  margin: 0 auto;
  padding: 0 var(--gutter);
}
.wrap-wide { max-width: var(--wrap-wide); }
.wrap-narrow { max-width: 880px; }

.section {
  padding: var(--sect-y) 0;
}
.section.rule { border-top: 1px solid var(--rule); }
/* WHEN A SECTION TAKES `tight` (31 Aug 2026; the test corrected 1 Sep 2026).
   The air was added deliberately (owner, 20 Aug 2026) and none of it is being
   taken back here — what changed is how it is measured.

   ---- THE RULE ----
   THE GAP BETWEEN TWO SECTIONS IS THE SUM OF BOTH PADDINGS, AND IT MUST NOT
   EXCEED THE SHORTER OF THE TWO THINGS IT SEPARATES.

   Both halves of that were wrong before. The rule used to read "total padding
   must not exceed content height", which compares ONE section's padding to its
   OWN content — but nobody sees a section's padding in isolation. What reads
   as dead space is `padding-bottom` of the one above plus `padding-top` of the
   one below, and that sum is what has to be judged. A section can pass on its
   own content and still sit in a gap larger than its neighbour.

   And it was "measured at 1440" — one width, and not one anybody works at.
   --sect-y is viewport-relative, so it is 12% larger from 1600px up, where its
   clamp caps. A rule checked only at 1440 says nothing about the screen the
   owner is actually reading the site on. MEASURE AT 1440, 1920 AND 2400.

   Run that way, seventeen gaps failed across seven pages — five of them at
   1440, so this was never only a big-screen fault. The caps came down to
   160/112 in response (see the note beside the tokens) and all seventeen
   clear. The sections that had already been given `tight` keep it: the three
   `.editorial` blocks, the trade-access sign-in split, and the brand-page
   Colors section in its SIGNED-OUT state only (338px against 1212px signed
   in).

   `node build.mjs` guards the tokens themselves — it recomputes both clamps at
   the three widths and fails if the worst-case gap grows past its ceiling. It
   cannot see content heights (there is no layout engine in the build), so the
   content half of this rule is still read by a person. Measure it in a browser
   before giving a thin section --sect-y. */
.section.tight { padding: var(--sect-y-tight) 0; }
.section.no-rule { border-top: 0; }

/* WHEN A WHITE SECTION IS THE LAST THING ON THE PAGE (owner, 31 Aug 2026;
   found on the privacy notice, applied site-wide the same day).

   121 of 140 pages end on the dark closing CTA, whose own .wrap pays for the
   air above the footer — so on those the seam was designed, and it is the only
   one anybody had ever looked at. Thirteen pages end on PAPER, and there the
   section's bottom padding is doing a job nothing asked it to do: SECTION
   PADDING IS SYMMETRICAL BY CONSTRUCTION, so the bottom was simply whatever
   the TOP needed. It was sized against the section above it and never against
   the footer below it — the same defect as the Colors section, in a different
   place.

   MEASURED AT 1440 (1425 inside the scrollbar), before:
       .section          199.5px    /contact/, /new/, /signin/, /signup/,
                                    /signup/received/, /signin/check-email/,
                                    /account/, /404
       .section.tight    128.25px   /trade-access/, /catalog/, /saved/,
                                    /privacy/, /privacy/es/
   after: 96px on all thirteen, at every width.

   THE VALUE IS DERIVED, NOT PICKED, and two independent readings agree on it:
     - The privacy notice states its own air before a horizontal edge nine
       times — --s9 (96px) from the last line of a clause to the hairline that
       opens the next (.legal-sec + .legal-sec). The footer's top edge is the
       last and hardest such edge, so it takes the same 96px.
     - .cta-close .wrap pays clamp(52px, 6vw, 92px) below its content, which
       is 86.4px at 1440. That is the seam the site already ships on 121
       pages, and 96px lands within 10px of it — so a reader moving from a
       page that ends dark to one that ends on paper meets the same air.
   199.5px was TWICE the shipped dark seam, which is what read as a gap.

   Fixed rather than clamped on purpose: the clause rhythm it matches is fixed
   at every width, so this is too, and the two cannot drift apart at one
   breakpoint. It changes the bottom only — the top still answers to whatever
   sits above it, which is why /contact/ keeps its inline padding-top:0.

   WHICH PAGES GET IT IS NOT MAINTAINED BY HAND. build.mjs walks each built
   page to its last top-level element and fails the build if a page ends on
   paper without this class. See "THE PRE-FOOTER SEAM" there. */
.section.pre-foot { padding-bottom: var(--s9); }


/* ---- plain paper (owner, 27 Aug 2026) ----
   Every section sits on flat --paper. The wash field that used to be painted
   here — three radial glows over every light ground, per-band hue triads and
   intensities, the edge fade — is removed by the owner's instruction. What
   separates sections now is the spacing scale, the eyebrow plates,
   .section.rule hairlines and the dark closing chapter. The .bone step-back
   band went with it: the two sections that carried it are .section.rule now,
   a hairline instead of a tinted base, and --shell-bone is gone. */
.section {
  position: relative;
  background-color: var(--paper);
}

.section-head {
  display: flex;
  align-items: baseline;
  gap: var(--s5);
  flex-wrap: wrap;
  margin-bottom: var(--head-gap);
}
.section-head h2 { margin: 0; }
.section-head .count {
  font-family: var(--sans);
  font-size: 14px;
  color: var(--ink-3);
  margin-left: auto;
}

/* head with a plate number sitting above the rule */
.plate-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s5);
  padding-bottom: var(--s4);
  margin-bottom: var(--s7);
  border-bottom: 1px solid var(--rule);
  flex-wrap: wrap;
}

/* text-only page opener (spec-sheet register) */
.hero {
  padding: clamp(64px, 9vw, 148px) 0 clamp(56px, 7vw, 108px);
  position: relative;
  background-color: var(--paper);
}
.hero h1 { max-width: 20ch; }
.hero-actions,
.actions {
  display: flex;
  gap: var(--s3);
  flex-wrap: wrap;
  margin: var(--s6) 0 0;
  max-width: none;
}

/* style page head */
.style-head {
  display: grid;
  grid-template-columns: 1fr 440px;
  gap: clamp(32px, 5vw, 80px);
  align-items: start;
  padding: clamp(32px, 4vw, 64px) 0 clamp(28px, 3vw, 48px);
}
/* Number and brand mark on one line, the way a CAD title block sets them. */
.style-title {
  display: flex;
  align-items: center;
  gap: clamp(16px, 2.5vw, 28px);
  flex-wrap: wrap;
  margin: 0 0 var(--s3);
}
.style-brandmark {
  display: inline-block;
  line-height: 0;
  opacity: .92;
  transition: opacity 240ms var(--ease);
  padding-left: clamp(16px, 2.5vw, 28px);
  border-left: 1px solid var(--rule);
}
.style-brandmark:hover { opacity: 1; }
.style-brandmark:focus-visible { outline: 2px solid var(--red); outline-offset: 4px; }
@media (max-width: 520px) {
  .style-brandmark { padding-left: 0; border-left: 0; }
}

.style-no-big {
  font-family: var(--mono);
  font-size: clamp(44px, 7vw, 84px);
  font-weight: 500;
  letter-spacing: -.04em;
  line-height: 1;
  margin: 0;
}

/* The ScrubAid / EliteMed label-exclusivity callout used to live here. It is
   gone (owner, 21 Aug 2026): the two are separate brands and the callout's
   whole argument was that they were one fabric under two labels. Its markup
   was removed from build.mjs, so these rules had no element left to style. */

/* 7/5 editorial split */
.editorial {
  display: grid;
  grid-template-columns: 7fr 5fr;
  gap: clamp(32px, 6vw, 96px);
  align-items: start;
}

/* content beside a data block */
.split {
  display: grid;
  grid-template-columns: 1fr 380px;
  gap: clamp(32px, 5vw, 72px);
  align-items: start;
}

/* generic plate grid on a hairline background */
.plate-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--s4);
}
.plate-grid > * { border: 1px solid var(--rule); border-radius: var(--r-md); }
.plate-grid > .card.static { padding: clamp(24px, 3vw, 36px); }

.stack > * + * { margin-top: var(--s5); }


/* == 06  chrome =========================================================== */

/* Slim sticky header. Paper at 92% with a blur so photography scrolls under
   it; the shadow class is added by the script once the page has scrolled. */
/* Two states, cross-faded at 300ms. At the top of the page the header is
   transparent and sits over the hero; once the page has scrolled under it,
   a white plate with a soft shadow fades in. The plate is a pseudo-element
   whose OPACITY animates — so the fade is transform/opacity only, and the
   sticky header never changes size. The photo heroes pull up under it by
   the header's height (see .phero). */
/* ---- the black bar ----
   Restored 19 Aug 2026 from `main`, where it lived before the theme rebuild.
   The original also carried the street address; the owner asked for the trade
   statement and the number only, so the address stays in the footer where a
   buyer looks for it. "Wholesale only" is written in sentence case and set in
   caps, rather than typed in caps, so a screen reader says it as a phrase. */
/* --slate, not --ink. --ink is the colour of TEXT; --slate is the colour of a
   dark SURFACE, and the footer and the stat band are already using it. Two
   near-blacks a few units apart doing the same job is precisely the kind of
   undecided value this pass is removing. */
.topbar {
  background: var(--slate);
  color: var(--on-slate);
  font-size: 12.5px;
  letter-spacing: .02em;
}
.topbar .wrap {
  display: flex;
  align-items: center;
  gap: var(--s6);
  min-height: var(--bar-h);
  flex-wrap: wrap;
}
/* Sentence case and bold, as the bar has always been set — the address
   sits beside it and an all-caps label would shout over it. */
.tb-label { color: #fff; font-weight: 600; }
.tb-addr { white-space: nowrap; }
.topbar .spacer { margin-left: auto; }
.topbar a { color: inherit; text-decoration: none; transition: color 300ms var(--ease); }
.topbar a:hover { color: #fff; }

.masthead {
  position: sticky;
  top: 0;
  z-index: 40;
  height: var(--head-h);
  background: transparent;
}
.masthead::before {
  content: "";
  position: absolute;
  inset: 0;
  /* .86, not .96 — enough translucency that content scrolling beneath reads
     as frost through the blur instead of vanishing behind a white plate */
  background: rgba(255,255,255,.86);
  -webkit-backdrop-filter: saturate(180%) blur(16px);
  backdrop-filter: saturate(180%) blur(16px);
  box-shadow: 0 1px 0 rgba(22,24,28,.06), 0 8px 28px rgba(22,24,28,.06);
  opacity: 0;
  transition: opacity 300ms var(--ease-io);
  pointer-events: none;
}
.masthead.scrolled::before { opacity: 1; }
/* pages that do not open on a photograph get the plate straight away */
.masthead.solid::before { opacity: 1; }
.masthead > .wrap {
  position: relative;
  height: 100%;
  display: flex;
  align-items: center;
  gap: var(--s6);
}

.logo {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  font-size: 19px;
  font-weight: 800;
  letter-spacing: -.02em;
  text-decoration: none;
  line-height: 1;
  white-space: nowrap;
}
.logo span { color: var(--red); }
/* The parent company's mark, in the masthead and the footer. The <a> is a
   baseline flex row built for type; an image has no baseline worth aligning
   to, so the mark centres instead and the tagline beside it keeps its own
   baseline via the small's own alignment. */
.logo:has(.housemark) { align-items: center; }
/* No `width: auto` here. The delivered SVG carries a viewBox and no width or
   height of its own, so with `width: auto` the browser sizes it by the
   default-sizing algorithm and ignores the width/height attributes entirely —
   which put a 197px mark in a 64px masthead. The attributes size it;
   max-width lets it shrink; height: auto keeps the ratio while it does. */
.housemark { display: block; max-width: 100%; height: auto; }
/* The typed lockup, kept as the fallback for a missing logo file. */
.logo-type span { color: var(--red); }

nav.main {
  margin-left: auto;
  display: flex;
  gap: clamp(18px, 2.2vw, 30px);
  align-items: center;
}
nav.main a {
  position: relative;
  text-decoration: none;
  font-size: 13.5px;
  font-weight: 500;
  letter-spacing: -.005em;
  color: var(--ink-2);
  padding: 6px 0;
  transition: color 300ms var(--ease);
}
nav.main a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1.5px;
  background: var(--red);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 400ms var(--ease);
}
nav.main a:hover { color: var(--ink); }
nav.main a:hover::after,
nav.main a[aria-current]::after { transform: scaleX(1); }
nav.main a[aria-current] { color: var(--ink); }

.masthead-cta { flex: none; }

/* Mobile menu — a <details>, so it opens without JS. The script only adds
   the scroll lock. Hidden on desktop; the desktop nav is hidden on mobile, so
   only one set of links is in the accessibility tree at a time. */
.menu { display: none; margin-left: auto; }
.menu > summary {
  list-style: none;
  cursor: pointer;
  width: 40px; height: 40px;
  display: grid; place-items: center;
  margin-right: -8px;
}
.menu > summary::-webkit-details-marker { display: none; }
.menu .burger,
.menu .burger::before,
.menu .burger::after {
  display: block;
  width: 22px; height: 1.5px;
  background: var(--ink);
  transition: transform 400ms var(--ease), opacity 300ms var(--ease);
}
.menu .burger { position: relative; }
.menu .burger::before,
.menu .burger::after { content: ""; position: absolute; left: 0; }
.menu .burger::before { top: -7px; }
.menu .burger::after { top: 7px; }
.menu[open] .burger { background: transparent; }
.menu[open] .burger::before { transform: translateY(7px) rotate(45deg); }
.menu[open] .burger::after { transform: translateY(-7px) rotate(-45deg); }
@media (prefers-reduced-motion: no-preference) {
  .menu[open] .menu-panel { animation: drawer-in 360ms var(--ease); }
  @keyframes drawer-in {
    from { opacity: 0; transform: translateY(-10px); }
  }
}
/* The drawer starts at the masthead's REAL bottom edge. --head-h is the
   masthead alone (64px); the topbar sits above it and is not sticky, so at
   scroll-top the header actually ends at --bar-h + --head-h and a panel
   pinned to --head-h opened straight across the middle of the logo. The
   script writes the measured edge into --menu-top when the panel opens,
   which is exact at any scroll position; the fallback is the scroll-top
   figure, so a no-JS <details> — which is how this menu is built to open —
   still clears the logo. */
.menu-panel {
  position: fixed;
  left: 0; right: 0;
  top: var(--menu-top, calc(var(--bar-h) + var(--head-h)));
  bottom: 0;
  background: var(--paper);
}
/* the drawer frosts where the platform can afford it; paper where it cannot */
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .menu-panel {
    background: rgba(255,255,255,.86);
    -webkit-backdrop-filter: saturate(180%) blur(24px);
    backdrop-filter: saturate(180%) blur(24px);
  }
}
.menu-panel {
  padding: var(--s6) var(--gutter) var(--s9);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}
/* `> a:not(.btn)`, not `a`. The old selector was plain `.menu-panel a`, so
   it also matched the Order wholesale <a class="btn">, and at 0,1,1 it beat
   .btn's own 0,1,0 — handing the button 28px display type and `padding:
   12px 0` with NO horizontal padding. That is why it read as spanning the
   panel: it was a display-sized line of text, not a button. The foot links
   sit inside <p class="menu-foot">, so the child combinator leaves them to
   their own rule.

   19px at 375 and 390, not 28: five nav items at display scale is a
   shout, and the panel is a list of five places to go, not a headline.
   14px of vertical padding keeps each row at ~51px, clear of the 44px
   touch target. */
.menu-panel > a:not(.btn) {
  text-decoration: none;
  font-size: clamp(19px, 5vw, 23px);
  font-weight: 600;
  letter-spacing: -.02em;
  line-height: 1.2;
  padding: 14px 0;
  border-bottom: 1px solid var(--rule);
}
.menu-panel a[aria-current] { color: var(--red); }
.menu-panel .menu-foot {
  margin-top: auto;
  padding-top: var(--s6);
  font-size: 14px;
  color: var(--ink-3);
}
.menu-panel .menu-foot a { font-size: 14px; font-weight: 500; border: 0; padding: 0; color: var(--ink); }
/* Shrink-wrapped and pill-shaped again, a touch larger than the base 14px
   so it still reads as the one action in the panel. */
.menu-panel .btn {
  margin-top: var(--s5);
  align-self: flex-start;
  font-size: 15px;
  padding: 16px 26px;
}

.crumb {
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink-3);
  padding: var(--s5) 0 0;
  margin: 0;
  max-width: none;
}
.crumb a { text-decoration: none; }
.crumb a:hover { color: var(--red); }

/* Footer — slate, three columns, brand marks in a row beneath. */
footer.site {
  background-color: var(--slate);
  background-image: linear-gradient(180deg, #121419, #0B0D10);
  color: var(--on-slate);
  font-size: 14.5px;
  padding: clamp(64px, 8vw, 112px) 0 var(--s7);
}
footer.site .foot-grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr 1.2fr;
  gap: var(--s7) var(--s6);
  padding-bottom: clamp(48px, 6vw, 88px);
  border-bottom: 1px solid var(--slate-rule);
}
footer.site .foot-brand .logo { color: #fff; font-size: 22px; }
/* The footer is `site light` — bone, not slate — so this is the colour cut,
   the same one as the masthead. */
footer.site .foot-brand .housemark { margin-bottom: var(--s2); }
/* About: the company signing the section that is about the company. */
.house-sig { margin-bottom: var(--s5); }
footer.site .foot-brand p { color: var(--on-slate-2); font-size: 14px; max-width: 30ch; margin: var(--s4) 0 0; }
footer.site .fhead {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--on-slate-2);
  margin: 0 0 var(--s4);
}
footer.site ul { list-style: none; margin: 0; padding: 0; }
footer.site li { margin: 0 0 var(--s2); }
footer.site a {
  color: var(--on-slate);
  text-decoration: none;
  transition: color 300ms var(--ease);
}
footer.site a:hover { color: #fff; }
footer.site .nolink { color: var(--on-slate-2); }
footer.site address { font-style: normal; line-height: 1.7; }
.foot-logos {
  display: flex;
  align-items: center;
  gap: clamp(28px, 4vw, 64px);
  flex-wrap: wrap;
  padding-top: clamp(32px, 4vw, 56px);
  padding-bottom: clamp(32px, 4vw, 56px);
  border-bottom: 1px solid var(--slate-rule);
}
.foot-logos a { line-height: 0; opacity: .78; transition: opacity 300ms var(--ease); }
.foot-logos a:hover { opacity: 1; }
/* EliteMed has no white lockup yet (see the open-items list); its mark on
   dark ground is a wordmark set in bone, matched to the weight of the
   others. Applied everywhere a brand row sits on slate. */
.wordmark {
  display: inline-block;
  font-family: var(--sans);
  font-weight: 400;
  font-size: 22px;
  letter-spacing: -.01em;
  color: var(--shell);
  line-height: 1;
}
.wordmark small {
  display: block;
  font-size: 9.5px;
  letter-spacing: .3em;
  text-transform: uppercase;
  margin-top: 4px;
  color: var(--on-slate-2);
  font-weight: 500;
}
.foot-legal {
  display: flex;
  justify-content: space-between;
  gap: var(--s4);
  flex-wrap: wrap;
  padding-top: var(--s5);
  font-family: var(--sans);
  font-size: 12.5px;
  color: var(--on-slate-2);
  letter-spacing: 0;
}


/* == 07  buttons ========================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s3);
  background: var(--red);
  color: #fff;
  text-decoration: none;
  font-weight: 600;
  font-size: 14px;
  letter-spacing: 0;
  padding: 16px 28px;
  border: 0;
  cursor: pointer;
  border-radius: var(--r-pill);
  /* touch-action kills the legacy double-tap-zoom wait on the tap path.
     The transform duration here is the RELEASE; the press itself overrides it
     to 100ms below, because an expo-out over 400ms reads as lag when it is the
     thing answering your finger. */
  touch-action: manipulation;
  /* tonal, one hue: the same red lit from above. background-color stays the
     fallback for anything that cannot draw the gradient. */
  background-image: linear-gradient(180deg, #DA1F38, #C30E27);
  box-shadow: 0 1px 2px rgba(120, 8, 22, .22), 0 8px 20px -10px rgba(120, 8, 22, .5);
  transition: background 300ms var(--ease), background-image 300ms var(--ease),
              box-shadow 300ms var(--ease), transform 260ms var(--ease);
}
.btn:hover {
  background: var(--red-deep);
  background-image: linear-gradient(180deg, #C51229, #A80D22);
  box-shadow: 0 1px 2px rgba(120, 8, 22, .25), 0 12px 26px -10px rgba(120, 8, 22, .55);
  transform: translateY(-1px);
}
.btn .arw { transition: transform 400ms var(--ease); }
.btn.ghost, .btn.on-ink, .btn.ghost.on-ink { background-image: none; box-shadow: none; }
.btn:hover .arw { transform: translateX(4px); }

.btn.ghost {
  background: transparent;
  color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--ink);
}
.btn.ghost:hover { background: var(--ink); color: #fff; }

.btn.on-ink { background: #fff; color: var(--ink); }
.btn.on-ink:hover { background: var(--shell); }
.btn.ghost.on-ink { background: transparent; color: #fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,.55); }
.btn.ghost.on-ink:hover { background: #fff; color: var(--ink); }

.btn.lg { padding: 20px 36px; font-size: 15px; }
.btn.sm { padding: 10px 18px; font-size: 13px; }

/* quiet text link with a rule under it */
.tlink {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-family: var(--sans);
  font-weight: 500;
  font-size: 14px;
  text-decoration: none;
  border-bottom: 1px solid var(--rule);
  padding-bottom: 4px;
  transition: color 300ms var(--ease), border-color 300ms var(--ease);
}
.tlink:hover { border-color: var(--red); color: var(--red); }
.tlink .arw { transition: transform 400ms var(--ease); }
.tlink:hover .arw { transform: translateX(3px); }
.tlink.on-ink { color: #fff; border-color: var(--slate-rule); }
.tlink.on-ink:hover { color: #fff; border-color: #fff; }


/* ---- the press state ----
   Hover is a desktop-only conversation. Everything above answered the mouse and
   nothing answered a finger, so on a phone the whole order funnel had only the
   browser's default grey tap flash to show for a press — and `.btn` and `.card`
   were each declaring a transform transition they never used, promising an
   animation that did not exist.

   The rule (Apple, Designing Fluid Interfaces): feedback belongs on pointer
   DOWN, not on release, and it has to be immediate. So the press is 100ms in
   and eases back out over the longer duration each element already declares —
   fast to acknowledge, unhurried to let go.

   Scale is kept small deliberately. These are wide surfaces; 3% on a button
   reads as a press, and the same 3% on a catalog card would read as the card
   flinching, so the card gets 0.5% and leans on its background shift instead. */
.btn:active,
.filter-toggle:active,
.thumb:active,
.slide-dots button:active {
  transform: scale(.97);
  transition-duration: 100ms;
}
.btn.lg:active { transform: scale(.98); }   /* bigger target, same apparent travel */
.card:active {
  transform: scale(.995);
  background: var(--shell);
  transition-duration: 100ms;
}
.card.static:active { transform: none; }    /* not a link — nothing to press */
.tlink:active { color: var(--red); border-color: var(--red); transition-duration: 100ms; }
.tlink.on-ink:active { color: #fff; border-color: #fff; }
.style-brandmark:active,
.foot-logos a:active,
.logo-row a:active { opacity: 1; transform: scale(.97); transition-duration: 100ms; }
/* Filter rows are labels, so the press has to be felt on the row, not the box. */
.opt:active, .sw-opt:active { transform: scale(.99); transition-duration: 100ms; }
/* the two card-anchors that had a hover lift but no press (found by the
   opportunity sweep, 19 Aug 2026) — same values as .card:active above */
.brand-card:active { transform: scale(.985); transition-duration: 100ms; }
.bplate:active { transform: scale(.995); transition-duration: 100ms; }

/* Reduced motion: keep the acknowledgement, drop the movement. The global
   reduce block already flattens every duration, so what is left to do here is
   remove the scale and let the colour shift carry the press on its own. */
@media (prefers-reduced-motion: reduce) {
  .btn:active,
  .btn.lg:active,
  .filter-toggle:active,
  .thumb:active,
  .slide-dots button:active,
  .card:active,
  .style-brandmark:active,
  .foot-logos a:active,
  .logo-row a:active,
  .opt:active,
  .sw-opt:active { transform: none; }
  .btn:active { background: var(--red-deep); }
  .filter-toggle:active { opacity: .82; }
}


/* == 08  photography ======================================================
   Model and fabric photography, WebP, from public/photos/. Every <img> here
   carries width/height and a srcset; the box owns the ratio.

   THE SHARPNESS RULE. The largest model file is 848 x 1264. A photograph is
   never displayed wider than its native width: the media column of a hero is
   min(50%, native px), rail cards are capped at 430px, band figures live in
   the wrap. Where a frame is wider than the photo, the frame is filled with
   the photo's own studio ground (sampled at import, --hp-bg) as a flat bleed,
   the photo sits off-centre against it, and its inner edge is feathered into
   the ground so the wall runs on and the floor fades rather than stopping at
   a hard line. Sharp and letterboxed beats full-bleed and blurry. */

.ph { width: 100%; height: 100%; object-fit: cover; display: block; }

/* ---- photo hero ----
   Full height: 100svh on desktop, 88svh on mobile. The header is transparent
   over it and the hero pulls up under the header by its height, so the
   photograph runs from the very top of the page.

   Desktop: two columns on the flat ground — copy left, photograph right. The
   media column is min(50%, native width, the width the photo would have at
   full height): a full-length shot therefore stands whole, feet in, and a
   three-quarter shot is cropped no lower than the hip. It is never scaled
   past its native pixels; the frame beyond it is the photo's own studio
   ground, and the photo's left edge is feathered a long way into it so
   there is no visible seam.

   Mobile (≤ 900px): the photograph fills the whole 88svh box (cover, no
   upscale — a 375-wide slot needs far fewer pixels than we have) with the
   copy set over its lower third on a scrim, Josmo-style.

   THE FACE RULE (owner, 17 Aug 2026). Every model is photographed with the
   head near the top of the frame, so the photograph starts one header-height
   DOWN and the strip above it is the photo's own studio ground. The
   transparent header therefore sits on quiet wall rather than on a face, and
   the media column is sized against (100svh - header) so the frame still
   matches the photograph's own aspect exactly and crops nothing. This
   replaces the 160px ground wash that used to be painted over the top of the
   picture: at 90% opacity across 22% of the frame it was rubbing out the
   very faces it was drawn to protect. */
.phero {
  position: relative;
  background: var(--hp-bg, var(--paper));
  color: var(--ink);
  display: grid;
  grid-template-columns: 1fr min(50%, calc(var(--hp-w, 848) * 1px), calc((100svh - var(--bar-h) - var(--head-h)) * var(--hp-w, 848) / var(--hp-h, 1264)));
  height: calc(100svh - var(--bar-h));
  min-height: 560px;
  margin-top: calc(-1 * var(--head-h));
  overflow: hidden;
}
/* (the compact .phero.short variant is gone — the catalog and how-to-order
   pages open with .spec-head now, and nothing else used it) */
.phero-copy {
  align-self: center;
  padding: calc(var(--head-h) + var(--s7)) var(--gutter) var(--s7);
  padding-left: max(var(--gutter), calc((100vw - var(--wrap-wide)) / 2 + var(--gutter)));
  max-width: 100%;
  min-width: 0;
}
.hl-s { display: block; }
/* One phrase of a headline in the house red (20 Aug 2026). The brand accents
   were tried here and are muted at display size — see hl() in build.mjs. */
/* ---- the coloured phrase in a headline ----
   One phrase per heading, never a whole line, and the colour is INHERITED
   rather than passed: --hl defaults to the house red, and a scope that owns
   a colour sets it once. A brand page sets --hl to its own accentInk on the
   wrapper that already carries --accent, so every heading inside it reads in
   that brand without a single call site changing.

   --hl-ink is the lifted red for the dark field. Denice red on #13161A is
   3.1:1, which large text technically clears and which reads as a dull
   maroon against a lit ground. This is the same red -- hue 352, saturation
   .85 -- with only its lightness raised to .66, the same operation
   accentInk() runs for white paper. It measures 5.8:1 on the field. */
.hl-c { color: var(--hl, var(--red)); }
.darkfield, .cta-close { --hl: #F25F72; }

/* ---- the line-shadow phrase: ONE element on the site (owner, 8 Sep 2026) ----
   A diagonally striped duplicate of the word, offset behind it — 21st.dev's
   LineShadowText translated to plain CSS. No library, no motion runtime, no
   request: the stripes are a repeating gradient clipped to the text of a
   ::before that carries the word again through content: attr(data-text).

   `isolation: isolate` IS LOAD-BEARING, not tidiness. The shadow has to paint
   BEHIND the word, which means z-index: -1 — and a negative-z-index child
   escapes to the nearest ancestor stacking context, which on this page is the
   transformed .hero-step, so it would slide behind the hero's own ground and
   vanish. Making the phrase its own stacking context pins it between the
   span's (transparent) background and the span's text, which is exactly where
   a shadow belongs.

   background-image, NEVER the background shorthand. The shorthand resets
   background-clip to border-box and the stripes would paint as a solid
   rectangle over the word instead of inside its letterforms — the identical
   trap the gradient-cut stat numerals are commented for. */
.line-shadow {
  position: relative;
  display: inline-block;
  isolation: isolate;
}
.line-shadow::before {
  content: attr(data-text);
  position: absolute;
  left: .055em;
  top: .055em;
  z-index: -1;
  color: transparent;
  background-image: repeating-linear-gradient(
    45deg,
    var(--hl, var(--red)) 0 1px,
    transparent 1px 5px
  );
  background-size: 200% 200%;
  background-position: 0% 0%;
  -webkit-background-clip: text;
  background-clip: text;
  pointer-events: none;
}
/* THE SWEEP'S DISTANCE IS A WHOLE NUMBER OF STRIPES (owner, 8 Sep 2026).
   The stripes repeat every 5px ALONG THE 45-degree gradient axis, so one
   period costs 5 / cos(45deg) = 5 x 1.41421356 of HORIZONTAL background
   travel. Moving an exact multiple of that lands the pattern in a phase
   identical to where it started — which is also the resting position the
   static shadow has, and the one a reduced-motion reader sees. So the sweep
   does not stop "mid-stripe" anywhere: it comes to rest on the design's own
   still frame.

   Twenty periods over ten seconds — a mean of two stripes a second, against
   roughly sixteen a second in the 2200ms version this replaces. */
:root { --ls-stripe: 5px; --ls-travel: calc(var(--ls-stripe) * 1.41421356 * 20); }
@keyframes hl-sweep {
  from { background-position: 0 0; }
  to { background-position: var(--ls-travel) 0; }
}
/* THE ANIMATION IS NOT DECLARED AT ALL UNDER REDUCED MOTION — it is not
   overridden with `animation: none`, which is the mistake the mobile hero
   made once: a running animation beats a normal declaration whatever its
   specificity, so switching one off after the fact does not work. Under
   `reduce` the browser never sees an animation property here at all, and the
   striped shadow simply sits still. That is the whole reduced-motion
   behaviour: the effect is a texture, and only the sweep across it is
   motion.

   The sweep runs ONCE. There is no infinite loop anywhere: this is the
   first thing every visitor sees, it would repaint a composited layer for as
   long as the tab is open, and every other piece of motion on this site is a
   one-shot reveal that ends. */
@media (prefers-reduced-motion: no-preference) {
  /* ONE PASS, ON LOAD, TEN SECONDS, THEN STOPPED FOR GOOD (owner, 8 Sep
     2026). A CSS animation on a rendered element starts by itself, so this
     needs no script, no `ready` class and no button: load or refresh the
     page and it goes. `both` holds the final frame, and there is no second
     iteration to hold it against.

     THE CURVE WAS CHOSEN BY MEASUREMENT, not by feel. var(--ease) —
     cubic-bezier(.16,1,.3,1), which everything else here uses — puts 49% of
     the travel into the first second and is effectively finished by four:
     over a 2200ms transition that reads as a flick, and over ten seconds it
     reads as a stall. Evaluated numerically over the ten seconds, this curve
     travels 1.2 stripes in the first second, holds about 2.7 a second
     through the middle (2.5s to 7.5s), and covers 0.11 of a stripe in the
     last — under a pixel of movement in the final second, so it is visibly
     at rest before it is actually done. That, plus landing on a whole number
     of stripes, is what makes the ending deliberate rather than frozen. */
  .line-shadow[data-sweep="once"]::before {
    animation: hl-sweep 10s cubic-bezier(.4, .15, .25, 1) both;
  }
  /* THE 'hover' CANDIDATE WAS DELETED HERE ON 9 SEP 2026 (owner), with
     /lab/sweep-hover/ and the same reasoning as /lab/gallery-3/: it was the
     one not chosen, and a rejected candidate left in the stylesheet is a
     rule nothing renders and nobody dares remove. It was a hover-only pass
     of the same keyframes, gated on (hover: hover) and (pointer: fine)
     because "hover" fires on tap and would have made the headline feel like
     a control. Nothing emits data-sweep="hover" any more. */
}
.phero-copy h1 {
  font-size: var(--t-hero);
  font-weight: 800;
  letter-spacing: var(--t-hero-tr);
  line-height: var(--t-hero-lh);
  max-width: 15ch;
  margin-bottom: var(--s5);
  /* never a single word on a line: balance the rag, keep two words minimum
     per line via the width and size above; text-wrap: pretty as the fallback
     that avoids an orphan on the last line */
  text-wrap: balance;
}
.phero-copy .lede { margin-bottom: var(--s2); max-width: 44ch; color: var(--ink-2); }
.phero-copy .brand-h1 { line-height: 0; margin: 0 0 var(--s5); }
.phero-copy .brand-h1 img { max-width: min(100%, 420px); height: auto; }
.phero-label {
  position: absolute;
  top: calc(var(--head-h) + var(--s5));
  left: max(var(--gutter), calc((100vw - var(--wrap-wide)) / 2 + var(--gutter)));
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-3);
  z-index: 2;
  margin: 0;
}
.phero-media {
  position: relative;
  overflow: hidden;
  height: 100%;
}
.phero-media img {
  position: absolute;
  top: var(--head-h);
  left: 0;
  width: 100%;
  height: calc(100% - var(--head-h));
  object-fit: cover;
  object-position: var(--hp-pos, 50% 0%);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 40%);
          mask-image: linear-gradient(to right, transparent, #000 40%);
}

/* the mobile scrim — only painted in the stacked layout (see 16) */
.phero-media::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(15,17,20,.72) 0%, rgba(15,17,20,.28) 42%, rgba(15,17,20,0) 68%);
  opacity: 0;
  pointer-events: none;
}

/* ---- the spec header — the title block on /catalog/ and /order/ ----
   The spec-sheet register speaking for itself: the thin red callout rule
   drawn under the H1, and the page's hard facts set as a mono title block the
   way a CAD sheet carries them, with a red registration tick on its corner.

   On PAPER, not slate (owner, 20 Aug 2026: the near-black field was heavy
   and matched nothing else). The owner offered white or the homepage brand
   sections' treatment — which are themselves on paper, so the two options
   converge. White is also where this register already lives: the style pages
   set the same red rules and mono blocks on white, and an actual CAD sheet
   is a white document. A bottom hairline ends the header, because sections
   below do not draw their own top rule. */
.spec-head {
  position: relative;
  overflow: hidden;
  /* The spec title block is on paper (owner, 20 Aug 2026) and stays there. */
  background-color: var(--paper);
  color: var(--ink);
  padding: clamp(56px, 8vw, 116px) 0 clamp(48px, 6vw, 92px);
  border-bottom: 1px solid var(--rule);
}
.spec-head .wrap {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: clamp(32px, 6vw, 96px);
  /* CENTERED against the heading block (owner, 20 Aug 2026) — align-items:
     end hung the title block off the baseline of whatever the copy column
     happened to end with, which read as a panel that had slipped. */
  align-items: center;
}
.spec-head h1 {
  font-size: var(--t-d1);
  font-weight: 800;
  letter-spacing: var(--t-d1-tr);
  line-height: var(--t-d1-lh);
  color: var(--ink);
  max-width: 18ch;
  text-wrap: balance;
}
/* the register's signature: a thin red callout rule under the title. It
   DRAWS itself once on load — the page's one authored moment, transform-only
   and visible by default, so no-JS and reduced-motion readers just see it. */
.spec-head h1::after {
  content: "";
  display: block;
  width: 64px;
  height: 3px;
  background: var(--red);
  margin-top: clamp(16px, 2vw, 26px);
  transform-origin: 0 50%;
}
@media (prefers-reduced-motion: no-preference) {
  .js .spec-head h1::after { transform: scaleX(0); }
  .js.ready .spec-head h1::after {
    transform: scaleX(1);
    transition: transform 700ms var(--ease) 350ms;
  }
}
.spec-head .lede { color: var(--ink-2); max-width: 54ch; margin-top: var(--s5); }
.spec-head .hero-actions { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s6); }
/* The title block on paper: the shell — the studio-wall tint, the site's one
   tinted ground — gives the panel a surface without a heavy frame, and the
   hairlines inside are the shell's own rule weight. The red registration
   tick carries over unchanged; red on white is this register's native ink. */
.spec-head-meta {
  position: relative;
  margin: 0;
  min-width: min(300px, 100%);
  padding: clamp(18px, 2vw, 26px) clamp(20px, 2.2vw, 30px);
  border: 1px solid var(--rule-shell);
  border-radius: var(--r-sm);
  background: var(--shell);
}
/* the registration tick */
.spec-head-meta::before {
  content: "";
  position: absolute;
  top: -1px;
  left: -1px;
  width: 14px;
  height: 14px;
  border-top: 2px solid var(--red);
  border-left: 2px solid var(--red);
  border-top-left-radius: var(--r-sm);
}
.spec-head-meta div {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s6);
  padding: 10px 0;
  border-top: 1px solid var(--rule-shell);
}
.spec-head-meta div:first-child { border-top: 0; padding-top: 0; }
.spec-head-meta div:last-child { padding-bottom: 0; }
.spec-head-meta dt {
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.spec-head-meta dd { margin: 0; font-family: var(--mono); font-size: 13px; color: var(--ink); white-space: nowrap; }
@media (max-width: 900px) {
  .spec-head .wrap { grid-template-columns: 1fr; align-items: start; }
  .spec-head-meta { justify-self: start; min-width: min(340px, 100%); }
}

/* ---- image-and-text bands ----
   Josmo's rhythm: image seven columns, text five, sides swapping down the
   page. The image is a framed plate at 4:5, sized by the grid, so it is
   always narrower than the wrap and never wider than the photo. */
.band {
  display: grid;
  grid-template-columns: 7fr 5fr;
  gap: clamp(48px, 8vw, 144px);
  align-items: center;
}
.band.flip { grid-template-columns: 5fr 7fr; }
.band.flip > .band-copy { order: -1; }
.band-media {
  position: relative;
  aspect-ratio: var(--band-ratio, 4 / 5);
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--hp-bg, var(--paper));
}
.band-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: var(--hp-pos, 50% 0%); }
.band-media.wide { --band-ratio: 3 / 2; }
.band-copy h2 { font-size: var(--t-d2); margin-bottom: var(--s5); }
.band-copy .lede { margin-bottom: var(--s5); }
.band-copy .prose { color: var(--ink-2); }
.band + .band { margin-top: var(--band-gap); }

/* ---- the brand gallery (owner, 7 Sep 2026) ----
   THE HOME PAGE'S BRAND SHOWCASE, replacing the brand stack of 4 Sep 2026
   (which replaced the rail). Five cards in one flex row, all visible at
   once, the open one at flex-grow 4 and the rest at 1, 700ms on
   cubic-bezier(.25, 1, .5, 1); hover opens a card on a fine pointer, a tap
   on a coarse one, and a second tap follows the link. Chosen by the owner
   from four lab candidates built side by side (see the LAB block in
   build.mjs; /lab/gallery/, /lab/gallery-3/ and /lab/gallery-4/ are still
   built under --lab for comparison, and the two lab-only additions — a
   rising wash and the /brands/ panel — live in src/lab-gallery.css).

   THE STUDIO VARIANT is what ships: the models cut out of the delivery's
   cover frames (scripts/cutout-covers.mjs) on a pale ground built from
   light, every tone of which comes from build.mjs (GALLERY_GEO and
   galleryGround()) as a custom property on the card. The type is ink; the
   headline's accent phrase is the brand's OWN HEX by the owner's decision,
   under the floor for four brands and recorded as the one exception
   (GALLERY_HEADLINE_ACCEPTED). Gallery 1 rules (.gal-media, .gal-scrim,
   .gal-plate, .line, .fabric) stay for the /lab/gallery/ comparison page.
   ========================================================================== */
.brands-gallery {
  position: relative;
  padding: var(--sect-y) 0 var(--sect-y-tight);
  background: var(--paper);
  --gal-h: clamp(440px, 74svh, 720px);
  --gal-gap: 12px;
  --gal-pad: clamp(22px, 2.6vw, 40px);
  --gal-ease: cubic-bezier(.25, 1, .5, 1);
  --gal-dur: 700ms;
}
.brands-gallery .section-head { margin-bottom: clamp(28px, 4vw, 52px); }
.gal-row {
  display: flex;
  gap: var(--gal-gap);
  height: var(--gal-h);
  /* a container, so a card's copy can be sized against the ROW (100cqw)
     rather than against the card whose width is mid-transition — see the
     studio copy column below */
  container-type: inline-size;
}
.gal-card {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  min-height: 0;
  display: block;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--hp-bg, var(--shell));
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
  box-shadow: var(--lift-1);
  /* gallery 1: the cards not in hand step back, darker and a touch greyer */
  filter: brightness(.5) saturate(.85);
  transition: flex-grow var(--gal-dur) var(--gal-ease), filter var(--gal-dur) var(--gal-ease);
}
.gal-card.is-active { flex-grow: 4; filter: brightness(1) saturate(1); }
.gal-card:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) {
  .gal-card:not(.is-active):hover { filter: brightness(.66) saturate(.9); }
  .gal-card.is-active:hover .gal-go .arw { transform: translateX(4px); }
}

/* ---- the photograph (gallery 1) ---- */
.gal-media { position: absolute; inset: 0; overflow: hidden; }
.gal-media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* the crop comes off the feet and off the sides, never off a head */
  object-position: 50% 0%;
  transform-origin: 50% 0%;
  transform: scale(1.1);
  transition: transform 1000ms var(--gal-ease);
}
.gal-card.is-active .gal-media img { transform: scale(1); }
/* The copy sits on the photograph's OWN studio wall, extended: the scrim is
   the ground colour convert-photos.mjs sampled off that frame (--hp-bg),
   opaque at the foot of the card and gone by its middle. */
.gal-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 500ms var(--gal-ease);
  background: linear-gradient(
    to top,
    var(--hp-bg) 0%,
    color-mix(in srgb, var(--hp-bg) 88%, transparent) 30%,
    color-mix(in srgb, var(--hp-bg) 0%, transparent) 64%
  );
}
.gal-card.is-active .gal-scrim { opacity: 1; }

/* ---- the copy ----
   Arrives on a stagger: one element every 90ms after the card has had
   200ms to grow. One thing at a time is what reads as composed.

   LEAVES FAST (owner, 7 Sep 2026: "text flickers on hover"). Measured
   frame by frame: the card's 700ms curve is 60% closed by 140ms, while the
   copy used to fade over the same 520ms it arrives in, so at 126ms the
   outgoing card was 405px of 580 and its copy still at 49% — and, sized as
   a share of the card, that copy had already re-wrapped from four lines to
   five, jumping 70px up. The ghost in the owner's screenshot was OnaFit's
   headline, re-wrapped narrow, at half opacity, over a card three-quarters
   closed. Two rules: the exit fades in 150ms (2% left at 100ms, which is
   when the card's edge reaches the copy's), and the studio copy column is
   sized against the ROW, not the card, so it never re-wraps in either
   direction (see .gal-studio .gal-copy). The transform keeps its long
   duration on the way out: it sinks unseen behind the fade rather than
   flicking 28px in a tenth of a second. */
.gal-copy {
  position: absolute;
  left: var(--gal-pad);
  right: var(--gal-pad);
  bottom: var(--gal-pad);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s4);
}
.gal-copy > * {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 150ms var(--gal-ease), transform 520ms var(--gal-ease);
}
.gal-card.is-active .gal-copy > * { opacity: 1; transform: none; transition-duration: 520ms; }
.gal-card.is-active .gal-copy > :nth-child(1) { transition-delay: 200ms; }
.gal-card.is-active .gal-copy > :nth-child(2) { transition-delay: 290ms; }
.gal-card.is-active .gal-copy > :nth-child(3) { transition-delay: 380ms; }
.gal-card.is-active .gal-copy > :nth-child(4) { transition-delay: 470ms; }
.gal-copy .brandmark, .gal-mark .brandmark { display: block; max-width: min(100%, 220px); height: auto; }
.gal-copy .brandmark { margin-bottom: var(--s2); }
/* gallery 1's line; the studio cards use .benefit below */
.gal-copy .line {
  font-family: var(--display);
  font-size: var(--t-d2);
  font-weight: 700;
  letter-spacing: var(--t-d2-tr);
  line-height: var(--t-d2-lh);
  color: var(--ink);
  margin: 0;
  max-width: 13ch;
  text-wrap: balance;
}
.gal-copy .fabric {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin: 0;
}
/* THE ARROW'S DISC is the brand's own hex on the studio cards (see the
   rule on .arw below); the label and the disc are two flex items, so the
   gap sits between the whole label and the disc and never inside the words. */
.gal-go {
  display: inline-flex;
  align-items: center;
  gap: var(--s3);
  margin-top: var(--s2);
  font-family: var(--sans);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--ink);
}
.gal-go .arw {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-pill);
  /* the brand's OWN hex (owner, 7 Sep 2026) — --g-disc from build.mjs, not
     --accent-field, which darkens a mid accent so white can sit on it; the
     glyph is ink or white, whichever reads better on the hex, asserted at
     3:1 there. Gallery 1 has no --g-disc and keeps the field. */
  background: var(--g-disc, var(--accent-field, var(--ink)));
  color: var(--g-disc-ink, var(--accent-field-ink, #fff));
  font-size: 18px;
  line-height: 1;
  transition: transform 400ms var(--ease);
}
/* the index, gallery 1 only, on the open card */
.gal-plate {
  position: absolute;
  top: var(--s5);
  right: var(--gal-pad);
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: .08em;
  color: var(--ink-2);
  opacity: 0;
  transition: opacity 400ms var(--gal-ease);
}
.gal-card.is-active .gal-plate { opacity: 1; transition-delay: 300ms; }

/* ---- the closed card's label: the brand's name, set vertically ----
   Top-left, reading down like a spine, in the band above the figure's
   crown — which the studio cards guarantee is empty (see .gal-fig). The
   display face, heavy and tracked, in ink. */
.gal-vert {
  position: absolute;
  left: var(--gal-pad);
  top: var(--gal-pad);
  writing-mode: vertical-rl;
  transform-origin: 0 0;
  font-family: var(--display);
  font-weight: 800;
  font-size: 15px;
  letter-spacing: .14em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  opacity: 1;
  transition: opacity 320ms var(--gal-ease) 360ms, transform 320ms var(--gal-ease) 360ms;
}
.gal-card.is-active .gal-vert { opacity: 0; transform: translateY(-10px); transition-delay: 0ms; }

/* ==========================================================================
   THE STUDIO VARIANTS (galleries 2 and 3) — the models cut out, on light.
   Every value below comes from GALLERY_GEO in build.mjs through the card's
   custom properties: the wall (--g-mid, 94.5%), the floor fade (--g-floor,
   a whole gradient: the wall down to the brand's raw hex on one smooth
   power curve, sampled by galleryFloor() — no stop in it is typed here),
   the key light (--g-hi), the vignette (--g-deep-v). The copy block stands
   on the wall where the fade is still faint; the figure stands on the
   floor.
   ========================================================================== */
.gal-studio .gal-card {
  background: var(--g-mid);
  filter: saturate(.72) brightness(.9);
}
.gal-studio .gal-card.is-active { filter: saturate(1) brightness(1); }
@media (hover: hover) and (pointer: fine) {
  .gal-studio .gal-card:not(.is-active):hover { filter: saturate(.86) brightness(.95); }
}
.gal-ground {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    /* the key light — large, off-axis, on the wall where the copy sits */
    radial-gradient(70% 66% at 20% 46%, #fff 0%, rgba(255, 255, 255, 0) 70%),
    /* the fill, right, softer, behind the figure's far side */
    radial-gradient(52% 46% at 82% 34%, var(--g-hi) 0%, var(--g-hi-0) 72%),
    /* the wall and the floor: ONE curve, from build.mjs. The 89/94/100
       three-stop version put a slope change where the colour began and the
       eye read it as a seam (owner, 7 Sep 2026); a version that held the
       hex for the last 6% read as a stripe. The power curve has no start
       to see and reaches the hex only at the edge. */
    var(--g-floor);
}
/* fall-off into the corners: the hue at 30%, evaluated over the copy
   region by galleryGround() so the contrast numbers include it */
.gal-ground::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(118% 96% at 46% 44%, var(--g-deep-0) 58%, var(--g-deep-v) 100%);
}
/* the lights-down key: on the wall ABOVE the head on a closed card (the
   crown is at 40%), crossfading out. No z-index: with one it left the
   ground's stacking order and painted over the figure's face. */
.gal-ground::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(64% 40% at 50% 16%, #fff 0%, rgba(255, 255, 255, 0) 70%);
  opacity: 1;
  transition: opacity var(--gal-dur) var(--gal-ease);
}
.gal-card.is-active .gal-ground::before { opacity: 0; }
/* the lockup sits at the TOP-left, in the key light, and only fades — out
   in 150ms with the copy (the card's edge reaches the mark's at ~200ms,
   and at 120ms a 200ms exit still showed as a ghost), in over 520 */
.gal-mark {
  position: absolute;
  top: var(--gal-pad);
  left: var(--gal-pad);
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity 150ms var(--gal-ease), transform 520ms var(--gal-ease);
}
.gal-card.is-active .gal-mark { opacity: 1; transform: none; transition-duration: 520ms; transition-delay: 240ms; }
/* the spine label: ink, sized so the longest name ends above the crown
   even on the shortest card (440px: 12px x 15 characters = 148px + the pad
   is under the 40% band the closed figure leaves clear) */
.gal-studio .gal-vert { color: var(--ink); font-size: clamp(12px, 2svh, 15px); letter-spacing: .12em; }
/* ---- the copy column, /brands/' in miniature ----
   Its bottom sits 6% above the pad. The floor fade now begins above it, so
   the block stands on a faintly tinted wall — galleryGround() measures each
   element against the fade at the height that element can reach. */
.gal-studio .gal-copy { right: 40%; bottom: calc(var(--gal-pad) + 6%); gap: var(--s4); }
/* THE COLUMN IS SIZED AGAINST THE ROW, NOT THE CARD. `right: 40%` is a
   share of a card whose width is changing for 700ms, so the text re-wrapped
   every frame — the flicker. The width below is exactly what `right: 40%`
   resolves to on the OPEN card — 60% of (the row less its gaps) × 4/(n+3),
   less the pad — but it is a constant, so the block is laid out once and
   the card's overflow clips it while the card is narrower. --gal-n is the
   card count, from build.mjs. Where cqw is not supported the share stands. */
@supports (width: 1cqw) {
  .gal-studio .gal-copy {
    right: auto;
    width: calc((100cqw - (var(--gal-n) - 1) * var(--gal-gap)) * 4 / (var(--gal-n) + 3) * .6 - var(--gal-pad));
  }
}
/* The six property marks need the card to be tall: with them the block is
   ~420px, and on a 666px card (74svh of a 900px viewport) the headline's
   top landed 4px under the lockup. Under a 920px viewport (a 680px card)
   they stay on the brand page. */
@media (max-height: 920px) {
  .gal-studio .gal-copy .fprops { display: none; }
}
.gal-copy .benefit {
  font-family: var(--display);
  font-size: var(--t-d2);
  font-weight: 700;
  letter-spacing: var(--t-d2-tr);
  line-height: var(--t-d2-lh);
  margin: 0;
  max-width: 16ch;
  color: var(--ink);
  text-wrap: balance;
}
.gal-copy .fabric-name { font-size: 15px; line-height: 1.5; color: var(--ink-2); margin: 0; max-width: 48ch; }
.gal-copy .fabric-name b { color: var(--ink); font-weight: 600; }
/* .fprops is site.css's rule set — the grid, the hairlines from
   --wash-brand, the glyphs in --detail or --eb — with only its outer
   margins reset for this column */
.gal-copy .fprops { margin: 0; padding: var(--s3) 0; width: 100%; max-width: none; }
.gal-copy .fprops li { font-size: 13px; }
/* ---- the figure ----
   The cutout keeps the frame's full 848x1264, transparent where the wall
   was; the box (--fig-x0/x1/y1, from data/cutouts.json) says where the
   model is in it. The box is --fig-h (108%) of the card tall and its top is
   --fig-top, solved per card so the ankle line meets the card's bottom edge
   — the shoes are out of frame. Closed: centred, scaled to --fig-s (.62)
   about the ankle line, so she stands on the same floor, smaller, with her
   crown at 40-43% of the card. Open: full size, her right edge on the
   card's right pad. The move takes 820ms, longer than the card's growth,
   so she settles after the frame does. */
.gal-fig {
  position: absolute;
  top: var(--fig-top);
  height: var(--fig-h);
  aspect-ratio: var(--fig-ar);
  left: 50%;
  transform-origin: 50% var(--fig-ankle);
  transform: translateX(-50%) scale(var(--fig-s));
  transition: left 820ms var(--gal-ease), transform 820ms var(--gal-ease);
  isolation: isolate;
}
.gal-card.is-active .gal-fig {
  left: calc(100% - var(--gal-pad) * .5);
  transform: translateX(calc(-1 * var(--fig-x1) * 100%)) scale(1);
}
.gal-fig img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: 50% 100%;
}
/* HOW SHE STANDS: the contact shadow is pooled at the ankle line, which
   is the card's bottom edge — a tight dark core the width of her stance
   and a broader spill — so the part of it above the edge reads as the
   floor taking her weight, with the cove and the floor band behind. */
.gal-fig::before {
  content: "";
  position: absolute;
  left: calc(var(--fig-x0) * 100% + 6%);
  right: calc((1 - var(--fig-x1)) * 100% + 6%);
  top: calc(var(--fig-ankle) - 1.6%);
  height: 2.4%;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, rgba(22, 24, 28, .5), rgba(22, 24, 28, 0) 74%);
  filter: blur(2px);
  z-index: -1;
}
.gal-fig::after {
  content: "";
  position: absolute;
  left: calc(var(--fig-x0) * 100% - 10%);
  right: calc((1 - var(--fig-x1)) * 100% - 10%);
  top: calc(var(--fig-ankle) - 4%);
  height: 7%;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, rgba(22, 24, 28, .3), rgba(22, 24, 28, 0) 72%);
  filter: blur(6px);
  z-index: -2;
}

/* ==========================================================================
   under 720px: a column, same expand/collapse
   ========================================================================== */
@media (max-width: 720px) {
  .brands-gallery { --gal-pad: 16px; --gal-gap: 8px; }
  /* 82svh, measured at 360x640: the open card comes out 246px tall, and its
     copy block (up to 129px with a three-line tagline) then starts at 101px,
     under the chin line at 83px. At 76svh it started at 77px and the
     lockup's corner touched the jaw — THE FACE RULE, by 6px. */
  .gal-row {
    flex-direction: column;
    height: min(82svh, 680px);
    min-height: 500px;
  }
  .gal-card { filter: brightness(.6) saturate(.85); }
  /* the photograph leaves a closed row rather than being cropped to a strip
     — a 60px row cannot hold a head */
  .gal-media img { opacity: 0; transition: opacity 500ms var(--gal-ease), transform 1000ms var(--gal-ease); }
  .gal-card.is-active .gal-media img { opacity: 1; }
  /* a closed row is labelled across, not up */
  .gal-vert {
    writing-mode: horizontal-tb;
    left: var(--gal-pad);
    right: var(--gal-pad);
    top: 50%;
    bottom: auto;
    transform: translateY(-50%);
    transform-origin: 0 50%;
    font-size: 14px;
    letter-spacing: .1em;
  }
  .gal-card.is-active .gal-vert { transform: translateY(-50%) scale(.8); }
  .gal-copy { gap: var(--s1); right: var(--gal-pad); }
  .gal-copy .brandmark { max-width: 130px; margin-bottom: var(--s1); }
  .gal-copy .line, .gal-copy .benefit { font-size: 20px; line-height: 1.15; max-width: none; }
  .gal-copy .fabric { font-size: 11px; }
  .gal-copy .fabric-name { font-size: 13px; }
  /* six property marks do not fit a 246px card; they are on the brand page */
  .gal-copy .fprops { display: none; }
  .gal-go { margin-top: var(--s1); font-size: 12px; white-space: nowrap; }
  .gal-go .arw { width: auto; height: auto; background: none; color: var(--ink); font-size: 16px; }
  /* "Browse Karisma Threads" wraps in a 187px column; the lockup is 60px
     above it, so the name is not needed twice */
  .gal-studio .gal-go-b { display: none; }
  .gal-plate { top: var(--s3); right: var(--gal-pad); font-size: 11px; }

  /* ---- the studio variants on a phone ----
     THE SAME STUDIO, NOT A FALLBACK, and not a row of coloured bars. A
     closed row carries the brand's LOCKUP — the colour cut, in the key
     light, which is white where the mark sits — on its own pale ground,
     with the floor deepening along the row's bottom edge. The open card is
     the desktop card: the mark stays where it was, the figure comes in
     from the right, the copy rises from the floor. */
  .gal-studio .gal-card { filter: none; }
  .gal-studio .gal-ground {
    background:
      radial-gradient(46% 140% at 14% 50%, #fff 0%, rgba(255, 255, 255, 0) 72%),
      var(--g-floor);
  }
  .gal-studio .gal-ground::before { display: none; }
  .gal-studio .gal-ground::after { background: radial-gradient(120% 140% at 40% 40%, var(--g-deep-0) 60%, var(--g-deep-v) 100%); }
  .gal-card.is-active .gal-ground {
    background:
      radial-gradient(70% 50% at 24% 40%, #fff 0%, rgba(255, 255, 255, 0) 70%),
      var(--g-floor);
  }
  .gal-studio .gal-vert { display: none; }
  /* a fixed box for the mark: a percentage max-width inside a shrink-to-fit
     absolute box resolves to nothing and the image rendered at 0x0. 118px
     wide, so ScrubAid's squarer mark (2.43:1) comes out 49px tall and still
     centres in a 62px row; the wide ones are 21–24px. Centred on a closed
     row, top-left on the open card. */
  .gal-mark { top: 50%; left: var(--gal-pad); width: 118px; opacity: 1; transform: translateY(-50%); transition: top 500ms var(--gal-ease), transform 500ms var(--gal-ease); }
  .gal-card.is-active .gal-mark { top: var(--gal-pad); transform: none; transition-delay: 0ms; }
  .gal-mark .brandmark { width: 100%; max-width: none; height: auto; }
  /* the column's width does not change on a phone (the rows change HEIGHT),
     so the share is right here and the row-sized width above is not */
  .gal-studio .gal-copy { right: 38%; width: auto; bottom: calc(var(--gal-pad) + 6%); gap: var(--s2); }
  .gal-fig { opacity: 0; transform: translateX(-50%) scale(1); transition: opacity 400ms var(--gal-ease), left 820ms var(--gal-ease), transform 820ms var(--gal-ease); }
  .gal-card.is-active .gal-fig { opacity: 1; left: calc(100% - 8px); transform: translateX(calc(-1 * var(--fig-x1) * 100%)) scale(1); transition-delay: 100ms; }
}

/* ==========================================================================
   reduced motion: the states remain; nothing glides between them
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
  .gal-card, .gal-media img, .gal-scrim, .gal-copy > *, .gal-vert, .gal-plate,
  .gal-mark, .gal-fig, .gal-ground::before, .gal-go .arw {
    transition: none;
  }
}

/* ---- the dark field ----
   The ground the glass actually needs. Frosted glass over flat black has
   nothing behind it to blur, so it reads as a bordered dark card — the
   failure that shipped twice. What is under the glass:

     1. three very large overlapping radial glows on slate, each falling off
        to zero alpha, so brightness varies across the whole width rather
        than sitting flat;
     2. two larger soft glows that drift, so the ground under the glass is
        not even static.

   All CSS. The obvious ground was a fabric macro, and the smallest usable
   file is 277KB of decoration on the home page; light carries the same idea
   for nothing.

   Two things were tried here and removed, both by the owner on 20 Aug 2026:
   a deep blue-green base (it belonged to nothing else on the site — slate,
   the site's own dark surface, replaced it), and a 45-degree twill over the
   whole band (it read as a pattern printed on a surface; the band is meant
   to read as depth). Do not reinstate either. */
.darkfield {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  color: #fff;
  /* SLATE (owner, 20 Aug 2026, chosen from three candidates). The field is the
     site's own dark surface — the same graphite as the top bar and the footer
     — rather than a hue that belonged to nothing else on the page. Because it
     is near-neutral, all five brand accents are legible against it, which the
     blue field could not manage: it swallowed ScrubAid's blue entirely.

     ATMOSPHERE, NOT TEXTURE (owner, same date). The 45-degree twill that used
     to sit over this is gone. What replaces it is light: three very large,
     very soft radial glows that fall off to nothing, so the band reads as
     depth rather than as a surface with a pattern printed on it. */
  background-color: var(--slate-field, #13161A);
  /* Three notes on why these are written the way they are.

     1. Each ramp ends at the SAME colour with zero alpha, never at the keyword
        `transparent`. It is the constant-hue trick: a ramp that only changes
        opacity has no chroma shift to quantise, so the steps it can produce
        are both fewer and less visible than a ramp that also travels in hue.
     2. There is no linear-gradient across the band any more. A single linear
        ramp over 900px of a near-neutral was the one layer most likely to
        band, because its steps are long, straight and parallel — exactly what
        the eye picks out. Three overlapping radials put their steps at
        different angles and different spacings, so no two coincide.
     3. The radii are deliberately larger than the band. A glow that finishes
        inside the frame draws an edge; these are cut off by the frame instead,
        so there is no visible boundary anywhere. */
  background-image:
    radial-gradient(120% 96% at 22% 6%,  rgba(149, 161, 181, .22), rgba(149, 161, 181, 0) 66%),
    radial-gradient(104% 84% at 88% 46%, rgba(101, 112, 131, .18), rgba(101, 112, 131, 0) 64%),
    radial-gradient(150% 108% at 46% 118%, rgba(74, 83, 98, .26),  rgba(74, 83, 98, 0) 68%);
}
.df-bg { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.df-bg .orb {
  position: absolute;
  border-radius: 50%;
  filter: blur(80px);
  will-change: transform;
}
/* The two drifting glows, on slate. Wider than before and lower in alpha, so
   they read as air moving rather than as two lamps. The blur is doing double
   duty: it is what makes them atmosphere, and a blurred layer is also the
   cheapest dither on the page — it smears any step in its own ramp across
   80px, and it smears the ramps UNDERNEATH it too where they overlap. */
.df-bg .orb.a {
  width: clamp(460px, 58vw, 940px);
  height: clamp(460px, 58vw, 940px);
  left: 4%;
  top: -12%;
  background: radial-gradient(circle, rgba(166, 176, 194, .26), rgba(166, 176, 194, 0) 70%);
}
.df-bg .orb.b {
  width: clamp(380px, 46vw, 760px);
  height: clamp(380px, 46vw, 760px);
  right: -6%;
  bottom: -16%;
  background: radial-gradient(circle, rgba(208, 213, 222, .14), rgba(208, 213, 222, 0) 70%);
}
@media (prefers-reduced-motion: no-preference) {
  .js .df-bg .orb.a { animation: orb-drift 26s var(--ease-io) infinite alternate; }
  .js .df-bg .orb.b { animation: orb-drift 34s var(--ease-io) infinite alternate-reverse; }
}
@keyframes orb-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(9%, 14%, 0); }
}
.darkfield > .wrap { position: relative; z-index: 1; }
.lightfield > .wrap { position: relative; z-index: 1; }

/* ================= the light proof chapter ==============================
   THE DARK BAND GOES PALE (owner, 20 Aug 2026), AND THE WASH UNDER IT GOES
   WHITE (owner, 27 Aug 2026). The proof chapter sits on plain paper like
   every other light band now. The glass went with the wash, and had to: the
   standing rule was that the ground comes before the glass, and a flat white
   ground gives a pane nothing to blur — a backdrop-filter over white is an
   invisible white box. So the reduced-transparency fallback is the ONLY
   state: a solid near-white gradient pane, a hairline, the lit top edge and
   the cast shadow. It reads as a pane by being a step denser than the paper
   around it, which was always what the fallback was for. The drifting orbs
   are gone with the ground they lit.

   Still one slab, not four cards; the gradient-cut numerals (one hue, two
   luminances, owner-requested) and the accentInk() per-figure underlines are
   unchanged. Retailer plates keep their hairline and the ink-tinted --lift
   shadows — a neutral black shadow on a warm ground reads as dirt. */
.lightfield {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  color: var(--ink);
  background-color: var(--paper);
}
/* RETUNED 31 Aug 2026, and it is the fallout of a content change rather than
   a taste change. This padding was set for a chapter of TWO halves — the stat
   pane and ten retailer cards under their own heading, around 900px of
   content. The retailer row came off on 31 Aug 2026 and the pane is 276px on
   its own, so 180 + 148 = 328px of padding was wrapping 276px of content: the
   frame was larger than the picture, which is the one thing padding may never
   be. The scale is unchanged, the section is one step down it. */
.lightfield.proof { padding: var(--sect-y-tight) 0 clamp(64px, 8vw, 116px); }

/* ---- the pane ----
   Solid, not glass (owner, 27 Aug 2026) — the former prefers-reduced-
   transparency fallback, promoted to the only state when the wash under the
   band went white. Mixed a step BELOW the ground around it: on a light field
   a pane reads as a pane by being slightly denser than what it sits on. */
.lightfield .stats-panel {
  background: linear-gradient(164deg, #FFFFFF 0%, #F7F9FC 58%, #FFFFFF 100%);
  /* The base .stats-panel (the slate glass, kept for the dark variant) still
     declares a backdrop-filter; behind this opaque pane it can paint nothing,
     but it is switched off rather than left to compositing luck. */
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
  border: 1px solid rgba(20, 22, 26, .10);
  box-shadow:
    0 44px 92px -48px rgba(20, 22, 26, .40),
    0 2px 6px rgba(20, 22, 26, .04),
    inset 0 1px 0 rgba(255,255,255,.95);   /* the lit top edge, kept */
}
.lightfield .stat-cell { border-left: 1px solid rgba(20, 22, 26, .10); }
.lightfield .stat-cell:first-child { border-left: 0; }
/* One hue, two luminances -- in ink now.
   background-IMAGE, not the background shorthand. The shorthand resets
   background-clip to border-box, which un-clips the gradient from the glyphs
   and paints it as a solid rectangle over each figure. */
.lightfield .stat-cell .v {
  background-image: linear-gradient(174deg, var(--ink) 26%, #5B626C);
}
.lightfield .stat-cell .k { color: var(--ink); }
.lightfield .stat-cell .sub { color: var(--ink-3); }

/* Selection inside the light chapter goes back to the house pair; the dark
   chapters keep their own, set further down. */
.lightfield ::selection { background: var(--red); color: #fff; }

.darkfield.proof { padding: clamp(80px, 10vw, 148px) 0 clamp(64px, 8vw, 116px); }

/* ---- the stat panel ----
   ONE slab of glass, not four cards. You can see the field continue past all
   four of its edges and shift in luminance behind it, which is what makes a
   pane read as a pane; four separate cards each had ground on every side and
   read as four dark tiles. The figures are cells inside it, divided by
   hairlines.

   On slate the saturate() came down from 185% to 108%. It was there to pull a
   blue field up through the glass, and a near-neutral ground has almost no
   chroma to pull — at 185% it was amplifying the little that remained and
   tinting the pane against the band around it. Brightness does the work
   instead, which is what separates glass from its ground on a grey field. */
.stats-panel {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  border-radius: var(--r-lg);
  background: linear-gradient(164deg, rgba(255,255,255,.15), rgba(255,255,255,.045) 58%, rgba(255,255,255,.13));
  -webkit-backdrop-filter: blur(26px) saturate(108%) brightness(1.14);
          backdrop-filter: blur(26px) saturate(108%) brightness(1.14);
  border: 1px solid rgba(255,255,255,.155);
  box-shadow:
    0 54px 104px -44px rgba(0, 12, 22, .9),
    inset 0 1px 0 rgba(255,255,255,.34);   /* the lit top edge */
  overflow: hidden;
}
.stat-cell {
  padding: clamp(28px, 3vw, 46px) clamp(22px, 2.4vw, 36px);
  border-left: 1px solid rgba(255,255,255,.13);
}
.stat-cell:first-child { border-left: 0; }
.stat-cell .v {
  display: block;
  font-family: var(--display);
  font-size: clamp(42px, 5.4vw, 84px);
  font-weight: 800;
  letter-spacing: -.03em;
  line-height: .92;
  font-variant-numeric: tabular-nums;
  /* one hue, two luminances — a light gradient, not a color gradient */
  background: linear-gradient(174deg, #FFFFFF 26%, rgba(255,255,255,.52));
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
}
/* the per-figure accent underline — one accent per figure */
.stat-cell .v::after {
  content: "";
  display: block;
  width: 40px;
  height: 3px;
  border-radius: 2px;
  margin: clamp(14px, 1.6vw, 22px) 0 var(--s4);
  background: var(--stat-a, var(--red));
}
.stat-cell .k {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #E7E9ED;
}
.stat-cell .sub { display: block; font-size: 14px; line-height: 1.5; color: #C3C8D0; margin-top: var(--s2); }
/* ---- reduced transparency ----
   The platform asked for less see-through, so the pane goes solid and the
   blur is dropped. It does NOT go neutral charcoal, which is what the first
   version did: on a lit blue field a grey slab is the dark rectangle this
   section exists to stop being. The solid is the field's own blue, mixed to
   sit a step ABOVE the ground behind it, and it keeps the lit top edge and
   the border — so the composition still reads as a lit pane on a lit field,
   just without the see-through. The drifting glows stop, the static field
   stays: colour is not the thing being reduced here. */
@media (prefers-reduced-transparency: reduce) {
  .df-bg { display: none; }
  .stats-panel {
    /* The solid is the field's own graphite, mixed a step ABOVE the ground
       behind it, so the pane still reads as a lit pane on a lit field even
       with the see-through gone. */
    background: linear-gradient(164deg, #33383F 0%, #23272D 58%, #2E333A 100%);
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    border-color: rgba(255,255,255,.20);
  }
}

/* ---- brand grid — white cards, one per brand, closing on a dark tile ---- */
.brand-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--s4);
}
.brand-card {
  position: relative;
  display: grid;
  place-items: center;
  min-height: clamp(180px, 20vw, 260px);
  padding: clamp(28px, 4vw, 56px);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-1);
  text-decoration: none;
  color: inherit;
  transition: transform 500ms var(--ease), box-shadow 500ms var(--ease);
}
.brand-card::after {
  content: "";
  position: absolute; inset: 0;
  border-radius: var(--r-lg);
  box-shadow: var(--lift-2);
  opacity: 0;
  transition: opacity 500ms var(--ease);
  pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
  .brand-card:hover { transform: translateY(-6px); }
  .brand-card:hover::after { opacity: 1; }
}
.brand-card .brandmark { max-width: 100%; height: auto; }
.brand-card.dark {
  background: var(--slate);
  color: #fff;
  border-color: var(--slate);
  text-align: center;
}
.brand-card.dark span { font-family: var(--display); font-size: var(--t-d3); font-weight: 700; letter-spacing: var(--t-d3-tr); }
.brand-card.dark .arw { display: block; margin-top: var(--s3); font-size: 22px; transition: transform 400ms var(--ease); }
.brand-card.dark:hover .arw { transform: translateX(6px); }

/* ---- brand logo row on paper (home, brands landing) ---- */
.logo-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s6);
  flex-wrap: wrap;
  padding: var(--s6) 0;
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
}
.logo-row a { line-height: 0; opacity: .85; transition: opacity 300ms var(--ease), transform 500ms var(--ease); }
.logo-row a:hover { opacity: 1; transform: translateY(-2px); }

/* ---- fabric plates (fabrics page) ---- */
.fab {
  display: grid;
  grid-template-columns: 7fr 5fr;
  gap: clamp(32px, 6vw, 104px);
  align-items: center;
  padding: clamp(48px, 6vw, 96px) 0;
  border-top: 1px solid var(--rule);
}
.fab:first-of-type { border-top: 0; }
.fab.flip { grid-template-columns: 5fr 7fr; }
.fab.flip > .fab-media { order: 2; }
.fab-media {
  position: relative;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--shell);
}
.fab-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fab-media.plate {
  background: var(--accent);
  display: grid;
  place-items: center;
  padding: var(--s7);
}
.fab-media.plate img { position: static; width: min(64%, 300px); height: auto; object-fit: contain; }
.fab-media.plate .plate-line { position: absolute; left: var(--s6); right: var(--s6); bottom: var(--s6); color: var(--shell); font-family: var(--sans); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; margin: 0; max-width: none; }
.fab-copy .names { display: flex; align-items: center; gap: var(--s4); flex-wrap: wrap; margin: 0 0 var(--s5); }
.fab-copy .names em { font-style: normal; color: var(--ink-4); font-weight: 400; font-size: 20px; line-height: 1; }
.fab-copy .fabric-name {
  font-family: var(--sans);
  font-weight: 500;
  font-size: 14px;
  color: var(--accent);
  letter-spacing: 0;
  margin: 0 0 var(--s4);
}
.fab-copy .fabric-name span { color: var(--ink-4); }
.fab-copy .benefit { font-family: var(--display); font-size: var(--t-d2); font-weight: 700; letter-spacing: var(--t-d2-tr); line-height: var(--t-d2-lh); margin: 0 0 var(--s5); max-width: 18ch; }
.fab-copy .detail { color: var(--ink-2); max-width: 48ch; }
.fab-copy .links { display: flex; gap: var(--s5); flex-wrap: wrap; margin: var(--s5) 0 0; }


/* == 09  figures — technical flats ======================================== */
/* The BOX owns the aspect ratio, never the image. A placeholder and a real
   sketch occupy pixel-identical space, so dropping files into
   public/sketches/<style>/ cannot shift or break any layout. */

.fig {
  position: relative;
  aspect-ratio: var(--fig-ratio);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  overflow: hidden;
  display: grid;
  place-items: center;
}

/* Brand logos. Vector, fitted into a bounding box at build time and carrying
   exact width/height, so the only job left here is to stop a narrow column
   squashing one. */
.brandmark {
  display: inline-block;
  max-width: 100%;
  height: auto;
  vertical-align: middle;
}
.brand-h1 { line-height: 0; margin-bottom: var(--s4); }

/* A fabric blend is one fact, so it is one line. Without this it breaks at
   the slash and "spandex" drops onto a line of its own — on the accent
   fields of /brands/ that second line collided with the copy under it
   (owner, 17 Aug 2026). Nowrap moves the break to the separator BEFORE the
   blend instead; at 21 characters the blend still fits on one line inside a
   360px screen, so no width needs a second rule. */
.blend { white-space: nowrap; }

/* No registration ticks on the frames (owner, Aug 2026) — the grid needs
   calm. The hairline and the paper ground are the whole frame. */

.fig img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: var(--s5);
  transition: transform 700ms var(--ease);
}

.fig-ph {
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.7;
  color: var(--ink-4);
  text-align: center;
  padding: var(--s5);
  margin: 0;
  max-width: none;
  word-break: break-word;
}
.fig-ph b {
  display: block;
  color: var(--ink-3);
  font-weight: 500;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-size: 9.5px;
  margin-bottom: var(--s2);
}

.fig.wash { background: var(--shell); }
.fig.tall { --fig-ratio: 3 / 4; }

/* Magnifier over the main sketch. A background layer rather than a transform
   on the img, so it is clipped by .fig and can never overflow the page. */
.fig-zoom {
  position: absolute;
  inset: 0;
  z-index: 1;
  opacity: 0;
  background-repeat: no-repeat;
  background-size: 250%;
  background-position: var(--zx, 50%) var(--zy, 50%);
  background-color: var(--paper);
  transition: opacity 240ms var(--ease);
  pointer-events: none;
}
.fig[data-zoomed] .fig-zoom { opacity: 1; }
.js .figmain[data-zoom-root] { cursor: zoom-in; }
.js .figmain[data-zoomed] { cursor: zoom-out; }
/* Only while magnified does the sketch claim the gesture, so an ordinary page
   scroll that happens to start on the sketch still scrolls. This replaces a
   non-passive touchmove listener calling preventDefault: with Pointer Events
   the browser needs to know BEFORE the gesture starts who owns it, and
   touch-action is how it is told. Declaring it up front also means the pan
   never fights a scroll that has already begun. */
.js .figmain[data-zoomed] { touch-action: none; }

/* == pack options =========================================================
   How a style can be bought, and the size run that comes with each way. */
.packs { border-top: 1px solid var(--rule); }
.packs-head {
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3);
  font-weight: 500;
  margin: var(--s4) 0 var(--s3);
}
.pack-opt { padding: var(--s4) 0; border-top: 1px solid var(--rule-2); }
.pack-opt:first-of-type { border-top: 0; }
.pack-opt-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
}
/* Heading LEVEL is document structure; heading SIZE is design. These two
   are set apart on purpose: the style page's sections were h3/h4 under an
   h1, which skipped h2 entirely on 117 pages, while a later peer section
   was already h2. The levels are now h2 > h3, and these rules hold the
   type exactly where it was — promoting the tag alone would have inflated
   every style page from the d3 scale to the d2 scale. */
.pack-opt-head h3.pack-opt-title {
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -.005em;
  line-height: normal;
  margin: 0;
}
/* A section heading that is an h2 by structure but sits on the d3 scale. */
.head-sm {
  font-size: var(--t-d3);
  font-weight: 700;
  letter-spacing: var(--t-d3-tr);
  line-height: var(--t-d3-lh);
}
.pack-size {
  font-family: var(--mono);
  font-size: var(--t-data);
  color: var(--ink);
  white-space: nowrap;
}
.pack-note { margin: 2px 0 var(--s3); font-size: 13.5px; color: var(--ink-3); }
.pack-scale { padding: var(--s3) 0 0; }
.pack-range {
  margin: 0 0 var(--s2);
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: .02em;
  color: var(--ink-3);
}

/* ---- the size run ----
   What is actually in the case, per size. The QUANTITY is the headline and the
   size is its caption; the ratio underneath is the proportion it was worked
   out from, kept so the figure stays checkable against the CAD sheet. */
.sizerun-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
}
.sizerun-list li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: var(--s3) 4px;
  background: var(--paper);
}
.sizerun-list b {
  font-family: var(--mono);
  font-size: clamp(17px, 2vw, 21px);
  font-weight: 500;
  letter-spacing: -.02em;
  line-height: 1;
}
.sizerun-list span {
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .08em;
  color: var(--ink-4);
}
.sizerun-total {
  margin: var(--s3) 0 0;
  font-family: var(--mono);
  font-size: var(--t-data);
  color: var(--ink-2);
}
.sizerun-total b { font-weight: 500; color: var(--ink); }
.sizerun-ratio {
  margin: 2px 0 0;
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .04em;
  color: var(--ink-4);
}
.sizerun-ratio span { color: var(--accent, var(--ink-3)); }
.card .sizerun { margin-top: var(--s3); }
@media (max-width: 520px) {
  .sizerun-list li { padding: var(--s3) 2px; }
  .sizerun-list b { font-size: 16px; }
  /* 11px, not 10px: this span is the SIZE in "8 S · 8 M · 16 L" — which
     size the quantity belongs to is the whole point of the breakdown, so
     it does not go under the legibility floor to save 1px of width. */
  .sizerun-list span { font-size: 11px; letter-spacing: .04em; }
}
.pack-colors {
  margin: var(--s3) 0 0;
  font-size: 13px;
  color: var(--ink-2);
  line-height: 1.65;
  max-width: none;
}
.pack-colors.dim { color: var(--ink-4); font-style: italic; }

@media (min-width: 720px) {
  .packs { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--s6); }
  .packs-head { grid-column: 1 / -1; }
  .pack-opt:nth-of-type(2) { border-top: 0; }
}

/* Four views, then a plus that reveals the rest (owner, 31 Aug 2026). Every
   view delivered used to render at once -- 42 boxes on the worst style, a
   contact sheet where a buyer wanted a garment. */
.fig-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
  gap: var(--s1);
  margin-top: var(--s2);
}
.thumb {
  position: relative;
  aspect-ratio: 1 / 1;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  background: var(--paper);
  cursor: pointer;
  overflow: hidden;
  transition: border-color 300ms var(--ease);
}
.thumb img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 5px;
  display: block;
}
.thumb:hover { border-color: var(--ink-3); }
.thumb[aria-pressed="true"] {
  border-color: var(--ink);
  box-shadow: inset 0 0 0 2px var(--ink);
}
.thumb:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 2px;
}

/* ---- the plus, and the views behind it ----
   THE COLLAPSE ONLY EXISTS UNDER `.js`, and that is the whole safety of it.
   The markup ships every thumbnail as a real, focusable button with a real
   image; these two rules are what hide the surplus and show the plus, and
   neither can apply before the script has run. No JS, and the strip is the
   full sheet it has always been, with no dead plus sitting at the end of it.
   Same construction as every other hidden initial state on this site.

   The plus IS a .thumb -- it inherits the grid track, the 1:1 box, the
   hairline, the radius, the hover and the focus ring, so it lines up with the
   row because it is one of the row. Nothing here is a new component. */
.thumb-plus { display: none; }
.js .fig-strip[data-collapsed] .thumb-more { display: none; }
.js .fig-strip[data-collapsed] .thumb-plus {
  display: grid;
  place-items: center;
  align-content: center;
  gap: 1px;
  background: var(--shell);
}
.thumb-plus .plus-mark {
  font-family: var(--display);
  font-size: 20px;
  font-weight: 500;
  line-height: 1;
  color: var(--ink-2);
}
/* How many are behind it. Mono, because it is a count and every other figure
   on this page that is a count is mono. */
.thumb-plus .plus-n {
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1;
  color: var(--ink-4);
}
.thumb-plus:hover .plus-mark { color: var(--ink); }


/* == 10  cards ============================================================ */

/* The catalogue grid stays a hairline table -- that is the spec register and
   it is correct there. On the LOOKBOOK pages the same grid was the densest
   thing on the page: eight cards edge to edge with a 1px rule between them,
   which reads as a spreadsheet dropped into a lookbook. There it opens up
   into separate cards with real space around them. */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
}
body[data-register="lookbook"] .grid {
  gap: clamp(16px, 1.6vw, 24px);
  background: none;
  border: 0;
}
body[data-register="lookbook"] .grid .card {
  border-radius: var(--r-md);
  box-shadow: inset 0 0 0 1px var(--rule), var(--lift-1);
  overflow: hidden;
}

.card {
  position: relative;
  background: var(--paper);
  padding: var(--s5);
  text-decoration: none;
  color: inherit;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  touch-action: manipulation;
  transition: background 500ms var(--ease), transform 320ms var(--ease);
}
.card:hover { background: var(--shell); }
.card:hover .fig img { transform: scale(1.03); }
/* The style number goes to the brand's colour on hover. --detail first where
   a brand has one: the card is WHITE, and an accent light enough to work on a
   brand panel can be invisible here. EliteMed's #FFC3B0 measures 1.3:1 on a
   white card; its detail #CC7452 measures 3.28:1. Karisma's accent measures
   2.09:1; its detail #517B28 measures 4.98:1. Brands without a detail keep the
   derived ink, which is already solved for legibility. */
.card:hover .style-no { color: var(--detail, var(--accent-ink, var(--accent))); }

.card .fig { margin-bottom: var(--s2); }

.card-top {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--s3);
  /* The badge may carry a qualifier ("In stock — by color only") and is
     nowrap, so on a narrow card it drops to its own line under the number
     rather than squeezing the number or overflowing the card. */
  flex-wrap: wrap;
}
.card .style-no {
  font-family: var(--mono);
  font-size: 21px;
  font-weight: 500;
  letter-spacing: -.02em;
  line-height: 1.1;
  transition: color 400ms var(--ease);
}
.card .brand {
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--accent-ink, var(--accent));
  margin-top: 5px;
}
.card .garment {
  font-size: 14.5px;
  color: var(--ink-2);
  letter-spacing: -.005em;
}
.card .ratio {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--ink-4);
  letter-spacing: .02em;
}
.card .more {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--ink-4);
  align-self: center;
}
.card .foot {
  margin-top: auto;
  padding-top: var(--s3);
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}

/* soft shadow that fades in on hover — an opacity change on a layer, so the
   card itself only ever moves by transform */
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  box-shadow: var(--lift-2);
  opacity: 0;
  transition: opacity 500ms var(--ease);
  pointer-events: none;
}
.card:hover { z-index: 2; }
.card:hover::before { opacity: 1; }

/* accent hairline that draws in on hover */
.card::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 2px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 500ms var(--ease);
}
.card:hover::after { transform: scaleX(1); }

/* plain content card, no link affordance */
.card.static { cursor: default; }
.card.static:hover { background: var(--paper); }
.card.static::after { display: none; }
.card.static h3 { margin-bottom: var(--s2); }
.card.static p { font-size: 15px; color: var(--ink-2); margin: 0; }


/* == 11  data blocks ====================================================== */

.spec-block, .pack {
  border-top: 2px solid var(--ink);
  margin: 0;
}
.spec-row, .pack-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s4);
  padding: 13px 0;
  border-bottom: 1px solid var(--rule);
}
.spec-row dt, .pack-row .k {
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3);
  flex: none;
}
.spec-row dd, .pack-row .v {
  margin: 0;
  font-weight: 600;
  text-align: right;
  font-size: 14.5px;
}
.spec-row dd { font-family: var(--sans); font-weight: 600; font-size: 15px; }
.pack-row .v .mono {
  display: block;
  font-family: var(--sans);
  font-weight: 400;
  font-size: 12.5px;
  color: var(--ink-4);
  letter-spacing: .02em;
  margin-top: 3px;
}
/* on slate */
.spec-block.on-ink { border-top-color: #fff; }
.spec-block.on-ink .spec-row { border-bottom-color: var(--slate-rule); }
.spec-block.on-ink dt { color: var(--on-slate-2); }
.spec-block.on-ink dd { color: #fff; }

/* measured ratio line — a dimension callout */
.ratio-strip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s3);
  font-family: var(--mono);
  font-size: clamp(17px, 2.2vw, 26px);
  font-weight: 500;
  letter-spacing: -.01em;
  padding: var(--s5) 0;
  border-top: 1px solid var(--ink);
  border-bottom: 1px solid var(--rule);
  margin: 0 0 var(--s3);
}
.ratio-strip b { font-weight: 500; }
.ratio-strip .seg { display: inline-flex; align-items: center; gap: var(--s3); }
.ratio-strip .seg::after {
  content: "";
  width: 18px; height: 1px;
  background: var(--rule);
}
.ratio-strip .seg:last-child::after { display: none; }
.ratio-strip .sz { color: var(--ink-4); }
.ratio-note {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--ink-4);
  margin: 0;
  letter-spacing: .02em;
}

/* swatches. The hover grows a RING drawn by a pseudo-element and scaled with
   transform — never a box-shadow transition — so the dot itself never moves
   and only transform is animated. */
.swatches { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.sw {
  position: relative;
  width: 18px; height: 18px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.16);
}
.sw::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 1px solid var(--ink-3);
  opacity: 0;
  transform: scale(.7);
  transition: transform 400ms var(--ease), opacity 400ms var(--ease);
}
.sw:hover::after { opacity: 1; transform: none; }
.sw.lg { width: 26px; height: 26px; }

.colorgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: var(--s5) var(--s4);
}
.color { display: flex; gap: var(--s3); align-items: flex-start; }
.color.solo { align-items: center; }
.color .chip {
  position: relative;
  width: 42px; height: 42px;
  border-radius: 50%;
  flex: none;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.16);
}
.color .chip::after {
  content: "";
  position: absolute; inset: -4px;
  border-radius: 50%;
  border: 1px solid var(--ink-3);
  opacity: 0; transform: scale(.8);
  transition: transform 400ms var(--ease), opacity 400ms var(--ease);
}
.color:hover .chip::after { opacity: 1; transform: none; }
.color .cname {
  font-size: 14.5px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -.008em;
}
.color .cav {
  font-family: var(--sans);
  font-size: 12px;
  color: var(--ink-4);
  letter-spacing: 0;
}
.color .cav.in { color: var(--ok); }
/* "Coming soon" beside a colour name on a brand page. Deliberately the SAME
   weight and ink as "New" rather than a colour of its own: the words carry
   the difference, and the one thing this must never do is read as an
   unavailable state. There is no --danger token on this site for exactly
   that reason, and this is not the place to start wanting one. */
.color .cav.soon { color: var(--ink-4); }

/* == colour comparison ==================================================== */
.color-compare {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s6) clamp(28px, 5vw, 80px);
  margin-top: var(--s6);
  align-items: start;
}
/* One column, because there is no assorted case to set beside it — a T style,
   or one whose case is not in production. The list keeps the same measure it
   has in the two-column layout rather than stretching to the full width, so a
   page with no assortment does not read as a different page. */
.color-compare.cc-single {
  grid-template-columns: minmax(0, 1fr);
  max-width: 46ch;
}
.cc-col { border-top: 2px solid var(--ink); padding-top: var(--s4); }
.cc-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-bottom: var(--s4);
}
.cc-head h3 { margin: 0; }
.cc-n {
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0;
  color: var(--ink-3);
  white-space: nowrap;
}
.cc-note { margin: 0 0 var(--s4); font-size: 14px; color: var(--ink-3); max-width: none; }
.cc-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s4) var(--s4);
}
.cc-list.asst {
  grid-template-rows: repeat(4, auto);
  grid-auto-flow: column;
}
.cc-item { display: flex; gap: var(--s3); align-items: flex-start; }
.cc-item.solo { align-items: center; }
.cc-item .chip {
  width: 38px; height: 38px;
  border-radius: 50%;
  flex: none;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.16);
}
.cc-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cc-item .cname { font-size: 14.5px; font-weight: 600; line-height: 1.3; letter-spacing: -.008em; }
.cc-item .cav { font-family: var(--sans); font-size: 12px; color: var(--ink-4); letter-spacing: 0; }
.cc-item .cav.in { color: var(--ok); }
.cc-mark {
  font-family: var(--sans);
  font-size: 11.5px;
  letter-spacing: 0;
  color: var(--ink-3);
  padding-left: 8px;
  border-left: 2px solid var(--accent, var(--ink));
  line-height: 1.4;
}
.cc-empty { margin: 0; font-size: 14.5px; color: var(--ink-3); max-width: 38ch; }
.cc-foot {
  margin: var(--s5) 0 0;
  font-family: var(--sans);
  font-size: 12.5px;
  color: var(--ink-3);
  max-width: none;
}
@media (max-width: 760px) {
  .color-compare { grid-template-columns: 1fr; }
}
@media (max-width: 430px) {
  .cc-list { grid-template-columns: 1fr; }
  .cc-list.asst { grid-template-rows: none; grid-auto-flow: row; }
}

/* parent <-> variant link in the style head */
.variant-link { margin: -8px 0 16px; }
.variant-link a.mono {
  color: var(--ink);
  font-weight: 500;
  text-decoration: none;
  border-bottom: 1px solid var(--red);
  padding-bottom: 1px;
}
.variant-link a.mono:hover { color: var(--red); }

/* colour family group */
.fam { margin-bottom: var(--s7); }
.fam:last-child { margin-bottom: 0; }
.fam-head {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  padding-bottom: var(--s3);
  margin-bottom: var(--s4);
  border-bottom: 1px solid var(--rule);
}
.fam-head h3 {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin: 0;
}
.fam-head .n {
  font-family: var(--sans);
  font-size: 12px;
  color: var(--ink-4);
  margin-left: auto;
}

/* the only status indicator on the site */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--ok-bg);
  color: var(--ok);
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  padding: 5px 10px;
  border-radius: var(--r-pill);
  white-space: nowrap;
}
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }

.tag {
  display: inline-flex;
  align-items: center;
  font-family: var(--sans);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3);
  padding: 5px 10px;
  border-radius: var(--r-pill);
  box-shadow: inset 0 0 0 1px var(--rule);
}

/* Full bleed. No 100vw maths — sections are already full width, so the bleed
   is simply a wrap that stops constraining. */
.bleed > .wrap {
  max-width: none;
  padding-left: 0;
  padding-right: 0;
}
.bleed .bleed-head { padding: 0 var(--gutter); }

/* Palette wall — the colours as an edge-to-edge band. */
.wall {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
}
.wall .c {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  align-items: flex-end;
  padding: 12px;
  transition: transform 500ms var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .wall .c:hover { transform: scale(1.06); z-index: 2; }
}
.wall .c .nm {
  font-family: var(--sans);
  font-size: 11px;
  line-height: 1.3;
  letter-spacing: 0;
}
.wall .c.on-light .nm { color: rgba(0,0,0,.72); }
.wall .c.on-dark .nm { color: rgba(255,255,255,.86); }
.wall .c .new {
  position: absolute;
  top: 10px; right: 10px;
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--red);
}
/* COMING SOON: the same disc, OPEN (owner, 8 Sep 2026). Filled means the
   colour has landed, open means it has not — one shape, one difference, and
   no second hue. A colour is not an emphasis, so it does not take the red
   (punctuation only), and it is not a stock condition, so it does not take
   the green. It inherits the swatch's own on-light / on-dark ink, exactly as
   the name beside it does, because it has to sit on all 51 grounds.

   7px against the dot's 5px: a 5px ring is 5px of border with nothing inside
   it and reads as a slightly soft dot at any zoom. The extra two pixels are
   what make the hole visible. */
.wall .c .soon {
  position: absolute;
  top: 9px; right: 9px;
  width: 7px; height: 7px;
  border-radius: 50%;
  border: 1.5px solid currentColor;
}
.wall .c.on-light .soon { color: rgba(0,0,0,.72); }
.wall .c.on-dark .soon { color: rgba(255,255,255,.86); }
/* the same ring again, inline in the palette legend */
.soon-key {
  display: inline-block;
  width: 7px; height: 7px;
  border-radius: 50%;
  border: 1.5px solid currentColor;
  vertical-align: baseline;
}
@media (max-width: 720px) {
  /* 88px cells, not 84: the colour NAME is the content here — a buyer
     reading the wall is reading which colour is which — so the label holds
     the 11px floor and the cell widens to carry it rather than the label
     shrinking to 9px to fit the cell. */
  .wall { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); }
  .wall .c { padding: 8px; }
  .wall .c .nm { font-size: 11px; }
}

/* A wall with no names on it, which is the signed-out teaser. Caught in a
   screenshot rather than in the markup: White is in Solid Core, and on the
   named wall you know it is there because "White" is printed across it in
   dark type. Take the label away and the cell is paper on paper — a hole in
   the row, exactly where a buyer is being asked to believe there is a range.
   The hairline is the same --rule every card edge on the site uses, and it
   goes on every light chip rather than on white alone, so the row has one
   treatment instead of one exception. */
.wall.bare .c.on-light { box-shadow: inset 0 0 0 1px var(--rule); }

/* ---- the signed-out palette gate ----
   Two of five groups are rendered on the signed-out landing page; the second
   dissolves into the paper and the prompt sits in the clear space under it.
   See paletteTeaser in build.mjs for why the other three groups are not in the
   document at all rather than covered by this.

   A FADE, FOUR DAYS AFTER THE WASHES CAME OFF, AND ON PURPOSE. The wash was a
   tint on a ground that had no edge; this marks an edge that is real, runs to
   --paper and to nothing else, and appears once on one page. It is doing work,
   which is the difference.

   IT IS ALL ONE STATE. There is no script here and nothing to reveal: the
   build renders the teaser into the public tree and the full wall into the
   private one, so this cannot flash the wrong thing and works identically
   with JavaScript off. */
.pgate { position: relative; padding-bottom: clamp(104px, 11vw, 148px); }

/* rgba(255,255,255,0) rather than `transparent`: Safari resolves the keyword
   to rgba(0,0,0,0) and interpolates through grey, which puts a dirty band
   across the middle of the fade. --paper IS #FFFFFF by standing rule, so the
   two ends agree. */
.pgate-veil {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0) 12%,
    rgba(255, 255, 255, .74) 46%,
    var(--paper) 72%
  );
  pointer-events: none;
}
/* Sits in the padding the veil has already taken to solid paper, so it is over
   the faded area and never over a colour it would have to fight. */
.pgate-prompt {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  display: grid;
  justify-items: center;
  gap: var(--s2);
  padding: 0 var(--gutter);
  text-align: center;
}
.pgate-prompt .micro { margin: 0; }

/* Forced colours throw the gradient away, so the fade stops existing and the
   prompt would sit on top of live swatches. Give the block real space instead
   — the row still ends, it just ends with an edge rather than a dissolve. */
@media (forced-colors: active) {
  .pgate { padding-bottom: clamp(140px, 14vw, 190px); }
}


/* == 12  lookbook bands — brand plates on the home page ================== */

.brand-plates {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: var(--s4);
}
.bplate { border: 1px solid var(--rule); border-radius: var(--r-md); overflow: hidden; }
.bplate {
  position: relative;
  background: var(--paper);
  padding: clamp(24px, 3vw, 40px);
  text-decoration: none;
  color: inherit;
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  transition: background 500ms var(--ease);
}
.bplate:hover { background: var(--shell); }
.bplate::before {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 6px;
  background: var(--accent);
}
.bplate .bnum { font-family: var(--sans); font-size: 12px; letter-spacing: .06em; color: var(--ink-4); }
.bplate .bname { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; margin: 0; line-height: 0; }
.bplate .bname em { font-style: normal; color: var(--ink-4); font-weight: 400; font-size: 18px; line-height: 1; }
.bplate .bfabric { font-family: var(--sans);
  font-weight: 500; font-size: 13.5px; color: var(--accent-ink, var(--accent)); letter-spacing: .02em; }
.bplate .bfabric span { display: block; color: var(--ink-4); margin-top: 3px; }
.bplate .bline { font-size: 15px; color: var(--ink-2); margin: 0; max-width: 42ch; }
.bplate .bline b { color: var(--ink); font-weight: 700; }
.bplate .bfoot {
  margin-top: auto;
  padding-top: var(--s4);
  border-top: 1px solid var(--rule);
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}
.bplate .bcount { font-family: var(--sans); font-size: 13px; color: var(--ink-3); margin-left: auto; }


/* == 13  catalogue ======================================================== */

.catalog {
  display: grid;
  /* minmax(0, 1fr), not 1fr. A 1fr track carries an automatic MIN-CONTENT
     floor, and the card grid inside reports a min-content of two 190px
     tracks plus their gap — 415px. Given a column narrower than that the
     track refused to shrink and pushed the whole page sideways. */
  grid-template-columns: 272px minmax(0, 1fr);
  gap: clamp(28px, 4vw, 64px);
  align-items: start;
}

.side {
  position: sticky;
  top: calc(var(--head-h) + var(--s5));
  max-height: calc(100vh - var(--head-h) - var(--s7));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: var(--s3);
}
.side::-webkit-scrollbar { width: 4px; }
.side::-webkit-scrollbar-thumb { background: var(--rule); }

.fgroup { border-bottom: 1px solid var(--rule); }
.fgroup > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  padding: var(--s4) 0;
  cursor: pointer;
  list-style: none;
  font-family: var(--sans);
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink);
}
.fgroup > summary::-webkit-details-marker { display: none; }
.fgroup > summary::after {
  content: "";
  width: 9px; height: 9px;
  border-right: 1px solid var(--ink-3);
  border-bottom: 1px solid var(--ink-3);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 400ms var(--ease);
  flex: none;
}
.fgroup[open] > summary::after { transform: rotate(225deg) translate(-2px, -2px); }
.fgroup > summary:hover { color: var(--red); }
.fgroup .fbody { padding: 0 0 var(--s5); }

.opt {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: 7px 0;
  cursor: pointer;
  font-size: 14px;
  color: var(--ink-2);
  line-height: 1.35;
  min-height: 32px;
}
.opt:hover { color: var(--ink); }
.opt input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.opt .box {
  width: 15px; height: 15px;
  flex: none;
  box-shadow: inset 0 0 0 1px var(--ink-3);
  background: var(--paper);
  position: relative;
  transition: background 240ms var(--ease), box-shadow 240ms var(--ease);
}
.opt .box::after {
  content: "";
  position: absolute;
  left: 50%; top: 47%;
  width: 4px; height: 8px;
  margin: -5px 0 0 -2px;
  border-right: 1.5px solid #fff;
  border-bottom: 1.5px solid #fff;
  transform: rotate(45deg) scale(0);
  transform-origin: center;
  transition: transform 240ms var(--ease);
}
.opt input:checked ~ .box { background: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.opt input:checked ~ .box::after { transform: rotate(45deg) scale(1); }
.opt input:focus-visible ~ .box { outline: 2px solid var(--red); outline-offset: 2px; }
.opt .n { margin-left: auto; font-family: var(--sans); font-size: 12px; color: var(--ink-4); }
.opt input:checked ~ .lbl { color: var(--ink); font-weight: 600; }
/* a brand option carries the brand's logo rather than its name in our type */
/* The logo is sized explicitly. An <img> with max-width:100% inside a
   shrink-to-fit inline-flex label resolves cyclically and collapses to 0px —
   which is exactly how the five brand filters shipped with no labels. */
.opt .lbl.logo-lbl { flex: none; line-height: 0; display: inline-flex; align-items: center; min-height: 22px; }
.opt .lbl.logo-lbl img { height: 22px; width: auto; max-width: none; }
.opt input:checked ~ .lbl.logo-lbl { font-weight: inherit; }

.opt.radio .box { border-radius: 50%; }
.opt.radio .box::after {
  left: 50%; top: 50%;
  width: 7px; height: 7px;
  margin: 0;
  border: 0;
  border-radius: 50%;
  background: #fff;
  transform: translate(-50%, -50%) scale(0);
}
.opt.radio input:checked ~ .box::after { transform: translate(-50%, -50%) scale(1); }

/* colour swatch options */
.swatch-opts {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
  gap: var(--s2);
}
.sw-opt {
  position: relative;
  display: grid;
  place-items: center;
  cursor: pointer;
  padding: 3px;
}
.sw-opt input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.sw-opt .dot {
  position: relative;
  width: 24px; height: 24px;
  border-radius: 50%;
  background: var(--c);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.18);
}
.sw-opt .dot::after {
  content: "";
  position: absolute; inset: -4px;
  border-radius: 50%;
  border: 1.5px solid var(--ink);
  opacity: 0; transform: scale(.7);
  transition: transform 400ms var(--ease), opacity 400ms var(--ease);
}
.sw-opt:hover .dot::after { opacity: .5; transform: none; }
.sw-opt input:checked ~ .dot::after { opacity: 1; transform: none; }
.sw-opt input:focus-visible ~ .dot::after { opacity: 1; transform: none; border-color: var(--red); }

/* search — first control in the sidebar */
.search { position: relative; margin-bottom: var(--s4); }
.search input {
  width: 100%;
  font-family: var(--sans);
  font-size: 15px;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  padding: 13px 38px 13px 16px;
  -webkit-appearance: none;
  appearance: none;
  transition: border-color 300ms var(--ease);
}
.search input::placeholder { font-family: var(--sans); font-size: 14px; color: var(--ink-4); letter-spacing: 0; }
.search input:hover { border-color: var(--ink-4); }
.search input:focus { border-color: var(--ink); outline: none; }
.search input:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.search input::-webkit-search-cancel-button { display: none; }
.search-clear {
  position: absolute;
  top: 50%; right: 6px;
  transform: translateY(-50%);
  width: 28px; height: 28px;
  display: grid; place-items: center;
  background: none; border: 0; cursor: pointer;
  font-size: 19px; line-height: 1;
  color: var(--ink-3);
}
.search-clear:hover { color: var(--red); }

/* the trade gate */
.gate { max-width: 62ch; }
.gate h2 { margin-bottom: var(--s4); }
.gate .gate-note {
  font-size: 15px;
  color: var(--ink-2);
  padding-left: var(--s5);
  border-left: 2px solid var(--red);
  margin: var(--s5) 0 0;
}
.gate-actions { display: flex; gap: var(--s3); flex-wrap: wrap; margin: var(--s7) 0 var(--s7); }
.gate-spec { max-width: 420px; }

/* ---- the trade application form (/signup/) ----
   The controls take the search field's language — same border, radius,
   padding and focus behaviour — so the one form on the site reads as part of
   the same instrument set as the catalog's search. Labels are the spec
   register's micro-caps. */
.form { max-width: 560px; }
.form .field { margin-bottom: var(--s5); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4); }
@media (max-width: 480px) { .form-row { grid-template-columns: 1fr; } }
.form label {
  display: block;
  font-family: var(--sans);
  font-size: 12.5px;
  letter-spacing: .06em;
  text-transform: uppercase;
  font-weight: 600;
  color: var(--ink);
  margin-bottom: 8px;
}
.form label .opt {
  text-transform: none;
  letter-spacing: 0;
  font-weight: 400;
  color: var(--ink-3);
  margin-left: 6px;
}
.form input,
.form select {
  width: 100%;
  font-family: var(--sans);
  font-size: 15px;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  padding: 13px 16px;
  -webkit-appearance: none;
  appearance: none;
  transition: border-color 300ms var(--ease);
}
.form select {
  padding-right: 40px;
  /* The chevron is two 6px gradient squares meeting in a caret — not an SVG
     data URI, because the SVG namespace URL contains a four-digit year that
     is also a real style number, and the gated-value scan reads CSS strings
     (and comments — do not write the number here either). A drawing with no
     string cannot trip a scan that reads strings. */
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ink-4) 50%),
    linear-gradient(135deg, var(--ink-4) 50%, transparent 50%);
  background-position: calc(100% - 21px) calc(50% - 1px), calc(100% - 15px) calc(50% - 1px);
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
}
.form input:hover, .form select:hover { border-color: var(--ink-4); }
.form input:focus, .form select:focus { border-color: var(--ink); outline: none; }
.form input:focus-visible, .form select:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
/* The region field: three of them exist, one per region country. Without JS
   all three render, each labelled with its country, and the server reads only
   the one matching the chosen country. With JS only the match is shown — the
   hidden ones are also DISABLED by the script, so nothing hidden ever
   submits. Under .js only, so a no-JS visitor never faces a hidden control. */
.js .region-field { display: none; }
.js .region-field.on { display: block; }
.form-alert {
  font-size: 15px;
  color: var(--ink-2);
  padding-left: var(--s5);
  border-left: 2px solid var(--red);
  margin: 0 0 var(--s5);
}
.form .btn { margin-top: var(--s2); }
.form-note { font-size: 13.5px; color: var(--ink-3); margin-top: var(--s4); }
.form-done { max-width: 56ch; }
.form-done:focus { outline: none; }
.form-done h3 { margin-bottom: var(--s3); }
.form-done p { font-size: 16px; color: var(--ink-2); }

/* ---- the two doors on /signin/ (owner, 7 Sep 2026) ----
   Two jobs on one page: let a customer back in, and let a new one ask for an
   account. THEY ARE NOT TWO EQUAL CARDS. Logging in is the common case by a
   wide margin, so it takes the wide column and the only panel on the page,
   and the sign-up half is quieter by construction — two matching boxes would
   put a decision in front of every returning customer on every visit.

   NOTHING HERE TOUCHES THE GRID. The columns are .split's own 1fr/380px, so
   the collapse to one column at 900px is the same rule every other split on
   the site obeys, and the order below that is DOM order. See the note over
   the markup in build.mjs. */
.door .section-head { margin-top: var(--s3); margin-bottom: 0; }
.door .lede { margin-top: var(--s4); max-width: 40ch; font-size: 16px; }
.door .form { margin-top: var(--s5); max-width: 100%; }
.door-help { margin-top: var(--s5); }

/* The panel is the light stat pane's treatment at a smaller size: on a white
   ground a pane reads as a pane by being a step DENSER than the paper rather
   than by floating over it. Same gradient, same lit top edge, a much softer
   cast — this one holds a form, and a form under a hero's shadow reads as a
   modal that something ought to close. */
.door-in {
  background: linear-gradient(168deg, #FFFFFF 0%, #F7F9FC 62%, #FFFFFF 100%);
  border: 1px solid var(--rule);
  border-radius: 14px;
  padding: var(--s6) var(--s6) var(--s5);
  box-shadow: var(--lift-1), inset 0 1px 0 rgba(255, 255, 255, .95);
}
.door-new { padding: var(--s2) 0 0 var(--s6); }

.door-list { list-style: none; margin: var(--s5) 0 0; padding: 0; }
.door-list li {
  position: relative;
  margin: 0;
  padding: 10px 0 10px 22px;
  font-size: 14.5px;
  color: var(--ink-2);
  border-top: 1px solid var(--rule);
}
.door-list li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 18px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--red);
}
.door-cta { margin-top: var(--s5); }

/* The explainer below the doors, held to a reading measure rather than the
   full wrap — the steps are hairline rules and a 1200px one is a table. */
.signin-how { max-width: 640px; }

@media (max-width: 900px) {
  /* ---- THE FIELD CLEARS THE FOLD AT 360x780, MEASURED (owner, 7 Sep 2026) --
     Three positions on the way, all read off the rendered page rather than
     estimated: 969px with the section merely re-ordered, 885 without the
     panel's own heading, 710 without the hero's meta rows as well. The
     viewport is 780, so only the last one clears.

     Both cuts are things the page says twice. The H1 above already reads
     "Log in to your account", so the heading inside the panel was saying it
     again three inches lower; Access / Method / Link lasts is decoration on
     a page whose whole content is one field.

     IT IS NOT A NICETY: iOS Safari ignores autofocus, so on an iPhone this
     number is exactly where the customer lands. On desktop the browser
     scrolls the field into view and the position matters less — which is
     precisely how the old page came to drop people into a bare input with
     nothing around it. */
  .door-in .section-head { display: none; }
  .spec-head-lean .spec-head-meta { display: none; }

  .door-in { padding: var(--s5); }
  /* The seam between the two doors moves from a left edge to a top edge, the
     same move .band.flip and the /brands/ panel make when they stack. */
  .door-new { padding: var(--s5) 0 0; border-top: 1px solid var(--rule); }
}

.empty-actions { display: flex; gap: var(--s3); justify-content: center; flex-wrap: wrap; }

/* ---- /account/ ----
   Four blocks down one measure, separated by the same hairline the spec rows
   use. Everything here is the existing vocabulary — .form, .spec-block,
   .section-head, the eyebrow plates — so the page inherits the site rather
   than describing itself. What is genuinely new is only the state handling:
   a page that cannot know its own content until a request comes back.

   AIR AT EVERY LEVEL, not just around the section: --band-gap between two
   blocks inside one section is the rule the rebuild set, and four stacked
   blocks is exactly the case it was written for. */
.acct-block {
  max-width: 640px;
  padding-bottom: var(--band-gap);
  margin-bottom: var(--band-gap);
  border-bottom: 1px solid var(--rule);
}
.acct-block-last { padding-bottom: 0; margin-bottom: 0; border-bottom: 0; }
.acct-block .section-head { margin-top: var(--s3); }
.acct-block .lede { margin-top: var(--s4); max-width: 52ch; }

/* The loading line exists only where script does. Without it the <noscript>
   block is the whole page, and a "Loading…" that will never finish would be
   the cruellest thing to leave on screen. */
.acct-wait { display: none; }
.js .acct-wait { display: block; color: var(--ink-3); }
.acct-guard, .acct-fail { max-width: 56ch; }
.acct-guard p, .acct-fail p { color: var(--ink-2); margin-top: var(--s4); }
.acct-guard .btn { margin-top: var(--s5); }

/* A confirmation, not an error — .form-alert's geometry with the rule in ink.
   Red is punctuation on this site and never a status colour, and "your details
   are saved" is the one sentence here that is unambiguously good news. */
.acct-note { border-left-color: var(--ink); }

.mono-v { font-family: var(--mono); font-weight: 400; font-size: 14px; }

.side-actions {
  display: flex;
  align-items: center;
  gap: var(--s4);
  padding: var(--s4) 0;
  border-bottom: 2px solid var(--ink);
  margin-bottom: var(--s2);
}
.side-actions .ttl {
  font-family: var(--sans);
  font-size: 12.5px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink);
  font-weight: 600;
}
.clear {
  margin-left: auto;
  background: none;
  border: 0;
  padding: 4px 0;
  cursor: pointer;
  font-family: var(--sans);
  font-size: 12.5px;
  letter-spacing: 0;
  color: var(--ink-3);
  border-bottom: 1px solid var(--rule);
}
.clear:hover { color: var(--red); border-color: var(--red); }

.resultbar {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
  padding-bottom: var(--s4);
  margin-bottom: var(--s5);
  border-bottom: 1px solid var(--rule);
}
.resultbar .rcount { font-family: var(--sans); font-size: 14px; color: var(--ink); letter-spacing: 0; }
.resultbar .rcount b { font-weight: 500; color: var(--red); }

.active-filters { display: flex; gap: var(--s2); flex-wrap: wrap; }
.afchip {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  background: var(--ink);
  color: #fff;
  border: 0;
  cursor: pointer;
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0;
  padding: 6px 10px;
  border-radius: var(--r-pill);
}
.afchip:hover { background: var(--red); }
.afchip::after { content: "\00d7"; font-size: 13px; line-height: 1; }

.filter-toggle { display: none; }

.empty {
  padding: var(--s9) var(--s5);
  text-align: center;
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  background: var(--shell);
  min-height: 60vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.empty p { margin: 0 auto var(--s4); color: var(--ink-3); max-width: 42ch; }


/* == 14  order / steps / cta ============================================= */

.step {
  display: flex;
  gap: var(--s4);
  padding: var(--s4) 0;
  border-bottom: 1px solid var(--slate-rule);
}
.step:first-child { padding-top: 0; }
.step .n {
  font-family: var(--sans);
  font-weight: 600;
  font-size: 12px;
  letter-spacing: .06em;
  color: var(--red);
  flex: none;
  padding-top: 4px;
  min-width: 34px;
}
.step p { margin: 0; color: var(--on-slate); font-size: 15px; }
.step b { color: #fff; font-weight: 600; }
.step.light { border-color: var(--rule); }
.step.light p { color: var(--ink-2); }
.step.light b { color: var(--ink); }

/* ---- the closing CTA ----
   Cut down hard (owner, 20 Aug 2026). It was a full screen of centred text on
   flat black — 176px of padding top and bottom, an 80px heading and a 52ch
   paragraph, all to say four words — and it appears at the foot of every
   page, so the cost was paid seven times over.

   Now it is a BAND, not a screen: half the padding, the heading down a step,
   and the copy and the actions laid across one row on desktop so the height
   is set by the taller of two columns instead of by four stacked blocks. It
   carries the same dark field as the proof chapter (it is .darkfield too), so
   the page closes on the same material it opened its dark chapter with
   rather than on a black rectangle. */
.cta-close {
  color: #fff;
}
.cta-close .wrap {
  position: relative;
  z-index: 1;
  padding-top: clamp(52px, 6vw, 92px);
  padding-bottom: clamp(52px, 6vw, 92px);
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) auto;
  gap: clamp(28px, 5vw, 88px);
  align-items: center;
}
.cta-close h2 {
  color: #fff;
  font-size: clamp(30px, 3.6vw, 54px);
  font-weight: 700;
  letter-spacing: -.03em;
  margin: var(--s3) 0 var(--s3);
  max-width: 18ch;
}
.cta-close p { color: var(--on-slate-2); margin: 0; max-width: 46ch; font-size: 15px; }
.cta-close .eyebrow { display: inline-flex; text-align: left; }
/* the actions become the second column — stacked, so two full-width buttons
   read as one decisive block rather than a centred pair */
.cta-close .actions {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s3);
  margin: 0;
  min-width: clamp(220px, 22vw, 280px);
}
.cta-close .actions .btn { justify-content: center; }
@media (max-width: 860px) {
  .cta-close .wrap { grid-template-columns: 1fr; gap: var(--s6); }
  .cta-close .actions { min-width: 0; }
}


/* address block on the contact page */
.contact-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(32px, 6vw, 96px);
  align-items: start;
}


/* == 14b  color system ==================================================
   Color comes from the garments, the fabric photos, the swatches and ONE
   brand accent at a time — never several blended together (owner, Aug 2026;
   the multi-stop spectrum was tried and removed). What carries color:
   rotating section grounds, the per-accent fields on /brands/, per-brand
   hover borders, per-figure stat underlines, Denice red on the primary
   button and the eyebrow rule. The data pages (body[data-register="spec"])
   stay calm. */

.section.slate { background: var(--slate); color: #fff; }
.section.slate h2, .section.slate h3 { color: #fff; }
.section.slate .lede, .section.slate p { color: var(--on-slate); }

/* the "5 Brands" heading — solid ink, the numeral in Denice red */
h2 .num { color: var(--red); }

/* plain hairline dividers between homepage bands */
hr.divider {
  border: 0;
  height: 1px;
  margin: 0;
  background: var(--rule);
}

/* (the per-figure stat underline now lives with the stat band itself —
   see .stat-cell .v::after in section 12) */

/* brand grid: the sixth tile is solid slate; a card's hover border picks up
   THAT brand's own accent (set as --accent on the card) */
.brand-card.dark { background: var(--slate); border-color: var(--slate); }
.brand-card:not(.dark):hover { border-color: var(--accent, var(--ink)); }

/* larger swatches — the most colorful thing we own */
.sw { width: 28px; height: 28px; }
.sw.lg { width: 36px; height: 36px; }
.swatches { gap: 8px; }
.color .chip { width: 56px; height: 56px; }
.cc-item .chip { width: 52px; height: 52px; }
.sw-opt .dot { width: 30px; height: 30px; }
.swatch-opts { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); }
.wall { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.colorgrid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }

/* ---- /brands/ — one section per brand, a bold field of its accent ----
   Full-bleed row: the fabric photograph large on one side, the accent field
   on the other carrying the white lockup, fabric name, blend and the benefit
   line in white. The one short paragraph sits BELOW on light ground — body
   text stays black on light, always. */
.bfield {
  display: grid;
  grid-template-columns: 1fr 1fr;
  min-height: clamp(420px, 56vh, 640px);
}
.bfield.flip .bfield-media { order: 2; }
.bfield {
  /* The colour the panel's wash is at where it meets the photograph, which is
     what the bleed under the photograph's inner edge has to end at. It cannot
     be read off the radial, so it is mixed to the same recipe: the brand hue
     at roughly the wash's leading alpha, over paper. */
  --bfield-edge: color-mix(in srgb, rgb(var(--wash-brand)) 22%, var(--paper));
}
.bfield-media {
  position: relative;
  overflow: hidden;
  background: var(--shell);
}
/* The field bleeds into the cloth.
   The panel and the photograph used to meet at a hard 50/50 seam, which reads
   as two rectangles side by side. Carrying the field colour a little way UNDER
   the photograph's inner edge, on a soft gradient, makes the colour look like
   it is coming out of the weave — which is the entire claim this page makes,
   since the colour is literally sampled from that weave.

   It sits on the media rather than the copy so it is clipped by the
   photograph's own overflow, and it follows the flip: the gradient always runs
   from transparent at the outside to solid at the edge the copy is on. */
.bfield-media::after {
  content: "";
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: clamp(40px, 5vw, 80px);
  background: linear-gradient(to right, transparent, var(--bfield-edge));
  pointer-events: none;
}
.bfield.flip .bfield-media::after {
  right: auto; left: 0;
  background: linear-gradient(to left, transparent, var(--bfield-edge));
}
.bfield-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.bfield-media.two { display: grid; grid-template-rows: 1fr 1fr; gap: 4px; }
.bfield-media.two > div { position: relative; overflow: hidden; }
.bfield-media.two img { position: absolute; }
/* ---- the brand panel: a wash, not a block (owner, 20 Aug 2026) ----
   This was five solid --accent-field slabs with white copy on them, and
   against the light direction it was the loudest thing on the site: five
   full-saturation rectangles alternating with five fabric macros, which is
   the opposite of calm. The instruction was that nothing is a flat block of
   colour, and this was the largest flat block there was.

   THE BRAND'S COLOUR DOES NOT LEAVE THE PAGE, it just stops being the
   panel. The fabric macro is still full-bleed in the other half of the
   section at full saturation -- and that is the standing preference anyway:
   colour comes from the garments, the fabric photos and the swatches. The
   cloth was always making this page's argument; the panel was repeating it
   at ten times the area.

   The wash is the same brand hue the macro was sampled for -- --wash-brand,
   set per section from data/wash-palette.json -- run deeper here than
   anywhere else on the site, because this panel is half a section and has to
   read as a surface rather than as a tint.

   /brands/ WAS EXPLICITLY EXCLUDED FROM THE 27 AUG 2026 WASH REMOVAL
   (owner). Every other light ground on the site is plain white; this panel
   keeps its wash, its flip, its stacked variant and its bleed exactly as
   they were. That makes .bfield-copy the ONLY washed surface left, so the
   whole mechanism lives here rather than in a shared selector list at the
   top of the file -- the geometry, the edge fade and the ramps are declared
   on this element and its variants and nowhere else. Nothing can inherit
   this ground by being added to a list, which is how it used to spread.

   THE .bfield* COMPONENTS ARE USED BY /brands/ AND BY NOTHING ELSE. The five
   brand pages (/brand/<slug>/) are built from .section, .hero and band(),
   all of which are white -- so this block cannot drag them back, and the
   build asserts exactly that (see the bfield-scope check in build.mjs).

   Copy goes to ink. --accent-field, the darkened cut that existed ONLY so
   white body copy could clear 4.5:1 on the solid, is no longer used here --
   it stays defined because .bfield-media::after still bleeds toward it. */
.bfield-copy {
  position: relative;
  background-color: var(--paper);
  /* The house geometry, declared HERE because this is now the only element
     that paints a wash. Same three ellipses, same stops, same alphas as the
     panel has always had. */
  --wash-geo-a: 128% 112% at 6% -14%;
  --wash-geo-b: 106% 96% at 98% 2%;
  --wash-geo-c: 118% 106% at 56% 116%;
  --wash-fade: clamp(56px, 8%, 152px);
  /* The edge fade: the panel arrives at paper at its own top and bottom, so
     it meets the white sections above and below it with no seam. Painted as
     two linear ramps at the TOP of the stack, over the radials, rather than
     as a mask -- a mask would need its own pseudo-element and a stacking
     context to go with it.

     Safe as a custom property containing var() ONLY because --paper and
     --wash-fade are constants at this point: the trap (var() in a custom
     property resolves where the property is DECLARED) bit the old
     --wash-stack token, which carried the per-element hues. The hues below
     are written straight into background-image for that reason. */
  --wash-edge:
    linear-gradient(to bottom, var(--paper), rgb(255 255 255 / 0) var(--wash-fade)),
    linear-gradient(to top,    var(--paper), rgb(255 255 255 / 0) var(--wash-fade));
  --wash-a: var(--wash-brand); --wash-b: var(--wash-brand); --wash-c: var(--wash-brand);
  --w-a: .26; --w-b: .14; --w-c: .19;
  background-image:
    var(--wash-edge),
    radial-gradient(var(--wash-geo-a), rgb(var(--wash-a) / var(--w-a)), rgb(var(--wash-a) / 0) 64%),
    radial-gradient(var(--wash-geo-b), rgb(var(--wash-b) / var(--w-b)), rgb(var(--wash-b) / 0) 62%),
    radial-gradient(var(--wash-geo-c), rgb(var(--wash-c) / var(--w-c)), rgb(var(--wash-c) / 0) 66%);
  color: var(--ink);
  padding: clamp(48px, 7vw, 112px) clamp(32px, 5.5vw, 96px);
  display: flex;
  flex-direction: column;
  justify-content: center;
}
/* The plate number, which used to head the white block underneath. */
/* --ink-2, not --ink-4, and that is a contrast decision rather than a
   styling one. The four ink tiers were solved against the two grounds they
   were ever set on -- #FFFFFF and the pale tint #ECEFF7 -- where they measure
   17.8 / 8.9 / 5.7 / 5.2. This panel is deliberately DEEPER than #ECEFF7: at
   its darkest point it reaches rgb(244 214 214) under JustScrubs, and --ink-4
   measures 3.83 there, under the 4.5 floor for small text. --ink-3 is 4.38 on
   the same ground and also fails.

   The right answer is a darker tier on a darker ground, not a thinner ground.
   Flattening the wash until --ink-4 cleared would have taken most of the
   colour out of the panel to protect one plate number. --ink-2 measures 6.57
   at the same worst point. */
.bfield-copy .bfield-idx {
  display: block;
  color: var(--ink-2);
  margin-bottom: var(--s5);
}
.bfield-copy .brandmark { max-width: min(100%, 320px); height: auto; margin-bottom: var(--s6); }
.bfield-copy .benefit {
  font-family: var(--display);
  font-size: var(--t-d2);
  font-weight: 700;
  letter-spacing: var(--t-d2-tr);
  line-height: var(--t-d2-lh);
  margin: 0 0 var(--s4);
  max-width: 16ch;
  color: var(--ink);
}
/* 34ch was narrower than the longest fabric name set beside its blend, and
   broke the blend across two lines; with .blend held on one line the
   measure only has to be wide enough for the fabric name (owner, 17 Aug
   2026). An explicit line-height stops the two lines touching where the
   field is narrow. */
.bfield-copy .fabric-name { font-size: 15px; line-height: 1.5; color: var(--ink-2); margin: 0 0 var(--s5); max-width: 48ch; }
.bfield-copy .fabric-name b { color: var(--ink); font-weight: 600; }

/* ---- fabric properties — the hangtag facts, under the blend line ----
   Two columns where the field is wide enough for the longest label to stay on
   one line, one column where it is not. The measure was set against a
   19-character label that came off the site on 31 Aug 2026 (see FABRIC_PROPS
   in build.mjs); the width stays where it is because it is a ceiling, and the
   next long label wants the same room.
   A hairline above and below sets the list apart from the paragraph beneath
   without adding a box: this panel is one surface and should stay one. */
.fprops {
  list-style: none;
  margin: 0 0 var(--s5);
  padding: var(--s4) 0;
  /* A hairline mixed from the brand hue rather than a flat grey, so the rule
     belongs to the panel it is drawn on.

     --detail overrides it where a brand sets one (owner, 20 Aug 2026). A wash
     tint is right when the accent is mid-toned, and invisible when the accent
     is a pale peach — so EliteMed pins these to its own darker cut instead.
     Brands without a `detail` keep the tint exactly as before. */
  border-top: 1px solid var(--detail, rgb(var(--wash-brand) / .35));
  border-bottom: 1px solid var(--detail, rgb(var(--wash-brand) / .35));
  display: grid;
  /* min(170px, 100%), not a bare 170px. Under min-content sizing auto-fit
     lays one track per item, so a hard 170px floor made this list report
     3 x 170 + gaps = 558px of min-content. That floored the 1fr column of
     the .bfield it sits in and put 18px of sideways scroll on /brands/ and
     /fabrics/ at 600px. Wrapped in min() the track may fall below 170 when
     the container is narrower, which is exactly what a narrow phone needs. */
  grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr));
  gap: var(--s3) var(--s5);
  max-width: 48ch;
}
.fprops li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.25;
  letter-spacing: -.005em;
  color: var(--ink);
}
/* The icon takes its colour from the text, which is the whole point: one set
   of marks, every accent ground. `flex: none` so a two-word label wrapping
   never squeezes the glyph out of square. */
/* The property glyphs take the BRAND accent -- one accent per panel, and the
   only colour in the copy column besides the headline phrase. --eb is set on
   the section to accentInk(), which is the value that clears 4.5:1. */
.fprops .picon {
  flex: none;
  width: 19px; height: 19px;
  /* --detail first, then the derived eyebrow ink, then a neutral. A brand
     that sets no --detail is unchanged. */
  color: var(--detail, var(--eb, var(--ink-3)));
}
/* The paragraph that used to sit in a white block below the panel. */
.bfield-copy .bfield-detail {
  font-size: 15px;
  line-height: 1.65;
  color: var(--ink-2);
  margin: 0 0 var(--s6);
  max-width: 52ch;
}
.bfield-copy .btn { align-self: flex-start; }
/* Mirrored, so the deep end of the wash always meets the photograph rather
   than always sitting on the left. Same three glows, X flipped. */
.bfield.flip .bfield-copy {
  --wash-geo-a: 128% 112% at 94% -14%;
  --wash-geo-b: 106% 96% at 2% 2%;
  --wash-geo-c: 118% 106% at 44% 116%;
}
@media (max-width: 900px) {
  /* minmax(0, 1fr) so no single child's min-content can widen the stacked
     column past the viewport, the way .fprops did. */
  .bfield, .bfield.flip { grid-template-columns: minmax(0, 1fr); min-height: 0; }
  .bfield.flip .bfield-media { order: 0; }
  .bfield-media { aspect-ratio: 3 / 2; }
  .bfield-media.two { aspect-ratio: 3 / 2; grid-template-rows: none; grid-template-columns: 1fr 1fr; }
  /* Stacked, so the copy is underneath and the bleed turns with it. */
  .bfield-media::after,
  .bfield.flip .bfield-media::after {
    top: auto; left: 0; right: 0; bottom: 0;
    width: auto;
    height: clamp(36px, 9vw, 72px);
    background: linear-gradient(to bottom, transparent, var(--bfield-edge));
  }
  /* Stacked, the copy is underneath, so the deep end of the wash moves to the
     TOP edge -- the seam with the photograph -- rather than to a side that no
     longer touches it. */
  .bfield-copy, .bfield.flip .bfield-copy {
    --wash-geo-a: 150% 90% at 50% -18%;
    --wash-geo-b: 120% 80% at 6% 40%;
    --wash-geo-c: 130% 90% at 88% 116%;
    --w-a: .26; --w-b: .13; --w-c: .16;
    /* BOTTOM EDGE ONLY. Stacked, this panel's top edge is not a boundary with
       another ground — it is the seam with its own photograph, and the photo
       bleeds DOWN into it via .bfield-media::after, which ramps to
       --bfield-edge. Fading the panel to paper there put a white line between
       the two: the bleed arrived at #FEEBCE and the panel started at #FFFFFF,
       a 49/255 step, which was the single worst seam left on the site after
       the edge fade went in. Left un-faded the two meet within 9/255. */
    --wash-edge: linear-gradient(to top, var(--paper), rgb(255 255 255 / 0) var(--wash-fade));
  }
}
@media (max-width: 420px) {
  .fprops { grid-template-columns: 1fr; }
}

/* ---- the home slideshow hero ----
   The photo hero with several layers. Each layer carries its own ground and
   photograph and cross-fades at 1200ms; the copy sits on top. Dots control
   it; hovering or focusing the hero pauses the timer; reduced motion shows
   the first slide only (see 15). */
.phero.slides { grid-template-columns: 1fr; }
.phero.slides .phero-copy { grid-area: 1 / 1; position: relative; z-index: 3; width: 60%; }
.phero.slides .phero-media { grid-area: 1 / 1; height: 100%; }
.phero.slides .slide {
  position: absolute; inset: 0;
  background: var(--hp-bg, var(--paper));
  opacity: 0;
  transition: opacity 1200ms var(--ease-io);
}
.phero.slides .slide.on { opacity: 1; }
.phero.slides .slide img {
  position: absolute; top: var(--head-h); right: 0; bottom: 0;
  height: calc(100% - var(--head-h));
  width: min(50%, calc(var(--hp-w, 848) * 1px), calc((100svh - var(--head-h)) * var(--hp-w, 848) / var(--hp-h, 1264)));
  left: auto;
  object-fit: cover;
  object-position: var(--hp-pos, 50% 0%);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 40%);
          mask-image: linear-gradient(to right, transparent, #000 40%);
}
.slide-dots {
  display: flex;
  gap: 10px;
  margin: var(--s6) 0 0;
  padding: 0;
  list-style: none;
}
.slide-dots button {
  width: 40px; height: 28px;
  border: 0; padding: 0;
  background: transparent;
  cursor: pointer;
  position: relative;
}
.slide-dots button::before {
  content: "";
  position: absolute;
  left: 3px; right: 3px; top: 50%;
  height: 4px;
  transform: translateY(-50%);
  border-radius: 2px;
  background: rgba(22,24,28,.18);
  transition: background 300ms var(--ease), transform 300ms var(--ease);
}
.slide-dots button[aria-current="true"]::before { background: var(--ink); }
.slide-dots button:hover::before { transform: translateY(-50%) scaleY(1.6); }
.slide-dots button:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; border-radius: 6px; }
@media (max-width: 900px) {
  /* The slideshow joins the side-by-side hero like every other. These
     selectors carry .slides, so they outrank the generic mobile hero rules
     further down and have to restate the same geometry rather than leave
     the old overlay in place: one cell for the copy, one for the picture.
     The scrim that used to be painted over each slide goes with it — there
     is no copy on the photograph here to make legible. */
  .phero.slides { grid-template-columns: minmax(0, 1fr) minmax(0, var(--hero-photo-w, 43%)); }
  .phero.slides .phero-copy {
    grid-area: auto;
    grid-column: 1;
    grid-row: 1;
    position: static;
    width: auto;
  }
  .phero.slides .phero-media {
    grid-area: auto;
    grid-column: 2;
    grid-row: 1;
    position: relative;
    height: auto;
  }
  .phero.slides .slide img { width: 100%; -webkit-mask-image: none; mask-image: none; }
  .phero.slides .slide::after,
  .phero.slides .phero-media::after { display: none; }
  .slide-dots button::before { background: rgba(22,24,28,.18); }
  .slide-dots button[aria-current="true"]::before { background: var(--ink); }
}
@media (prefers-reduced-motion: reduce) {
  .phero.slides .slide { transition: none; }
}

/* ---- the full-bleed landscape hero (19 Aug 2026) ----
   One 16:9 photograph edge to edge, the copy laid into the studio void beside
   the model. It replaces the half-column device, which existed only because
   every model file used to be 848px wide and could not be asked to fill a
   screen; the August delivery is 3200px on the long edge, so at 1600 CSS px the frame is
   still sampling below native.

   Which side the copy sits on comes from PHOTO_SIDE in build.mjs, opposite
   the model, and the build refuses a slideshow whose frames disagree. */
@media (min-width: 901px) {
  .phero.wide {
    display: block;
    grid-template-columns: none;
    /* no negative top margin: the hero starts BELOW the chrome */
    margin-top: 0;
    /* ALWAYS full-bleed. aspect-ratio + max-height was wrong here: when the
       screen is shorter than 16:9 (1920x1080 is), aspect-ratio transfers the
       height clamp into WIDTH and the hero shrinks to 1735px with dead paper
       on the right, squeezing the photograph toward the copy. Clamping the
       HEIGHT and letting cover crop the bottom keeps the box at 100vw at
       every size — and keeps every horizontal fraction mapping 1:1, which is
       what the copy-stop arithmetic assumes. A bottom crop can only ever
       take feet: the vertical anchor is 0%. */
    height: min(calc(100vw * 9 / 16), calc(100svh - var(--bar-h) - var(--head-h)));
    min-height: 480px;
  }
  .phero.wide .phero-media { position: absolute; inset: 0; height: 100%; }
  .phero.wide .slide { inset: 0; }
  .phero.wide .phero-media img,
  .phero.wide .slide img {
    position: absolute;
    top: 0; left: 0; right: auto; bottom: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: var(--hp-pos, 50% 0%);
    /* the soft left edge belonged to the half-column; full bleed has no edge */
    -webkit-mask-image: none;
            mask-image: none;
  }
  .phero.wide .phero-copy {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 3;
    /* Anchored at the gutter, STOPPED at --copy-stop — the per-hero line
       build.mjs draws just clear of the nearest subject edge across every
       frame, at the cinematic hold's worst case. Both edges scale with the
       viewport, so the clearance holds at every width, not just the ones
       that were checked. max-width keeps the measure honest on big screens. */
    width: auto;
    max-width: 780px;
    padding: 0;
    left: max(var(--gutter), calc((100vw - var(--wrap-wide)) / 2 + var(--gutter)));
    right: calc(100% - var(--copy-stop, 58%));
  }
  .phero.wide[data-copy="right"] .phero-copy {
    left: var(--copy-start, 42%);
    right: max(var(--gutter), calc((100vw - var(--wrap-wide)) / 2 + var(--gutter)));
    /* an absolutely positioned box resolves its spare width into an auto
       margin, so this anchors it against the right gutter */
    margin-left: auto;
  }
  .phero.wide[data-copy="right"] .phero-label {
    left: auto;
    right: max(var(--gutter), calc((100vw - var(--wrap-wide)) / 2 + var(--gutter)));
  }
  .phero.wide .phero-label { top: var(--s5); }
  /* 15ch was measured against the half-column; the void is wider than that */
  .phero.wide .phero-copy h1 { max-width: 18ch; }
}

/* ---- the cinematic hold ----
   A slow push from 1.00 to 1.06 over 28s on a brand hero, and a 7.2s settle
   from 1.03 on each home slide as it arrives — 7.2s being the slide's 6s hold
   plus the 1.2s crossfade, so the arc is always cut off by the next frame and
   never lands.

   THE TRANSFORM ORIGIN IS PINNED TO THE MODEL'S OWN EDGE, and that is the
   whole trick. A centred scale(1.06) crops 2.83% off BOTH sides, and these
   models stand out to 96-100% of the frame — it would shave an arm on every
   brand page and a face on the widest Karisma hero. Anchored at the model's edge,
   every pixel of the growth is taken out of the empty studio ground instead.
   The vertical origin is 0% for the same reason it is everywhere else on this
   site: growth may only ever go downward, never up into a head. That is also
   why there is no translate — with the origin pinned, a pan either eats the
   model or opens a gap at the opposite edge. */
/* data-copy names where the COPY sits, so the model is on the other side —
   and it is the MODEL's edge the growth has to be pinned to. */
.phero.wide .phero-media img,
.phero.wide .slide img { transform-origin: 100% 0%; }
.phero.wide[data-copy="right"] .phero-media img,
.phero.wide[data-copy="right"] .slide img { transform-origin: 0% 0%; }

/* AND (MIN-WIDTH: 901PX) — THE HOLD IS A DESKTOP EFFECT (1 Sep 2026).
   The phone rules used to try to switch it off with `transform: none`, and
   that CANNOT WORK: a running animation beats a normal declaration in the
   cascade whatever its specificity, so the hold went on scaling every slide
   to 1.03 on phones and pushing it 4.3px outside its own box, where
   overflow:hidden clipped it. Measured, not read — animationPlayState was
   "running" and the computed transform was matrix(1.0298…).

   The fix is not a louder override, it is not declaring the animation at a
   width it was never meant for. Below 901px the hero is a full-bleed
   photograph with nothing cropped, and a slow push would have nowhere to
   grow into but the frame's own edges. */
@media (prefers-reduced-motion: no-preference) and (min-width: 901px) {
  /* the generic hero reveal scales the photograph in; the hold does its own
     scaling, so here the reveal is opacity only */
  .js .phero.wide .phero-media img { transform: none; }
  .js.ready .phero.wide:not(.slides) .phero-media img {
    will-change: transform;
    animation: cine-hold 28s linear infinite alternate;
  }
  .js .phero.wide.slides .slide.on img {
    will-change: transform;
    animation: cine-arc 7200ms var(--ease-io) forwards;
  }
}
@keyframes cine-hold {
  from { transform: scale(1); }
  to   { transform: scale(1.06); }
}
@keyframes cine-arc {
  from { transform: scale(1.03); }
  to   { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .phero.wide .phero-media img,
  .phero.wide .slide img { animation: none !important; transform: none !important; }
}

/* compact brand block, high on the home page */
.section.brandrow { padding: clamp(40px, 5vw, 64px) 0 clamp(48px, 6vw, 80px); }
.brand-grid.compact { grid-template-columns: repeat(6, 1fr); gap: var(--s3); }
.brand-grid.compact.five { grid-template-columns: repeat(5, 1fr); }
.brand-grid.compact .brand-card { min-height: 112px; padding: clamp(16px, 2vw, 28px); border-radius: var(--r-md); }
.brand-grid.compact .brand-card.dark span { font-size: 15px; }
.brand-grid.compact .brand-card.dark .arw { font-size: 18px; margin-top: 6px; }
@media (max-width: 1100px) {
  .brand-grid.compact { grid-template-columns: repeat(3, 1fr); }
  /* five cards in threes is three-and-two; in twos the fifth spans the row */
  .brand-grid.compact.five { grid-template-columns: 1fr 1fr; }
  .brand-grid.compact.five > :nth-child(5) { grid-column: 1 / -1; }
}
@media (max-width: 560px) { .brand-grid.compact { grid-template-columns: 1fr 1fr; } }

/* light footer — bone, so the color lockups sit properly */
footer.site.light { background: var(--shell); color: var(--ink-2); }
footer.site.light .foot-grid { border-bottom-color: var(--rule-shell); }
footer.site.light .foot-brand .logo { color: var(--ink); }
footer.site.light .foot-brand p, footer.site.light .fhead, footer.site.light .nolink, footer.site.light .foot-legal { color: var(--ink-3); }
footer.site.light a { color: var(--ink-2); }
footer.site.light a:hover { color: var(--red); }
footer.site.light .foot-logos { border-bottom-color: var(--rule-shell); }
footer.site.light .foot-logos a { opacity: 1; }
@media (hover: hover) and (pointer: fine) {
  footer.site.light .foot-logos a:hover { transform: translateY(-2px); }
}


/* == 15  motion =========================================================== */
/* Two safety rails:
   1. Hidden initial states live ONLY inside prefers-reduced-motion:
      no-preference, so reduced-motion users get a plain visible page.
   2. Everything is gated on .js, set inline in <head>. If JS fails, no
      element is ever hidden and the brand stack's cards simply cover one
      another as the page scrolls. */

@media (prefers-reduced-motion: no-preference) {

  .js .reveal {
    opacity: 0;
    transform: translateY(20px);
  }
  .js .reveal.in {
    opacity: 1;
    transform: none;
    transition:
      opacity var(--dur) var(--ease),
      transform var(--dur) var(--ease);
    transition-delay: calc(var(--i, 0) * var(--stagger));
  }

  /* ---- the staggered entrance, on /about/'s BODY COPY (owner, 8 Sep 2026;
     rebuilt three times the same day, and the history is the useful part)

     21st.dev's TextRotate with the rotation taken out: a springy entrance,
     once, when the block scrolls into view. The copy stays exactly as
     written — no interval, no second string, nothing to rotate to. It rides
     the .reveal observer this file already has and adds no JavaScript.

     IT STARTED PER WORD AND STUTTERED. Measured in a real Chrome with real
     wheel gestures: 223 spans on /about/, up to 278 concurrent transitions,
     and the compositor layer count going from ELEVEN to 208 for the length
     of the reveal. The properties were never at fault — opacity and
     transform only, nothing left running afterwards, and the observer firing
     exactly seven times and never again. It was the layer churn, which is
     why no curve or window change touched it.

     FOUR WAYS OF LOWERING THAT PEAK WERE TRIED AND THREE FAILED. Triggering
     one paragraph at a time did nothing (149 layers against 145), because
     /about/'s three band-1 paragraphs are adjacent and cross the viewport
     together. Releasing each word on transitionend did nothing (142-156),
     because with transition-delay Chrome allocates the layer when the
     transition is QUEUED, not when the delay elapses — by transitionend
     every layer has already existed for the whole window. will-change made
     it WORSE (242), promoting every span permanently instead of only while
     it animates. Capping the number in flight did work (40-50) but needs a
     rAF pump on the main thread, and at 6x CPU that costs more than the
     layers save.

     SO THE UNIT IS A PHRASE, AND THE SPANS ARE INLINE. Three words to a
     span is 76 spans and 85 layers. Inline rather than inline-block is what
     makes the TYPE right: an inline-block phrase cannot break across a
     line, so lines could only break between groups, and measured that left
     body lines ending 40-60% of the way across and added one to two lines
     per paragraph. Inline spans break wherever they like — re-measured at
     1440, 390 and 360, the line counts and the rag are IDENTICAL to the
     page with no effect at all, to the pixel.

     THE PRICE IS THE RISE. transform does not apply to a non-replaced
     inline box, so the phrases fade in rather than lift in. The owner took
     that trade on 8 Sep 2026 knowing it: it is the only complete fix for
     the type, and it is also the cheapest of the three — 25 dropped frames
     at 4x CPU against the inline-block version's 39 and per-word's 46.

     THE WAVE SURVIVES. Each phrase takes its share of one 620ms window,
     computed in stagger() at build time and written on as --d, so a band's
     paragraphs still arrive in sequence down the column. */
  .js .stagger .w {
    display: inline;
    opacity: 0;
  }
  .js .in .stagger .w,
  .js.ready .hero-step.stagger .w {
    opacity: 1;
    transition: opacity 520ms var(--ease);
    transition-delay: var(--d, 0ms);
  }

  /* hero load ladder — runs once, no observer */
  .js .hero-step {
    opacity: 0;
    transform: translateY(20px);
  }
  /* a staggered hero lede does not ALSO ride the ladder — see the note above */
  .js .hero-step.stagger { opacity: 1; transform: none; }
  .js.ready .hero-step {
    opacity: 1;
    transform: none;
    transition:
      opacity var(--dur-x) var(--ease),
      transform var(--dur-x) var(--ease);
    transition-delay: calc(var(--i, 0) * 110ms);
  }
  /* the hero photograph settles in a touch behind the type */
  .js .phero-media img { opacity: 0; transform: scale(1.03); }
  .js.ready .phero-media img {
    opacity: 1; transform: none;
    transition: opacity 1200ms var(--ease-io), transform 1600ms var(--ease);
  }

  .card { will-change: transform; }
  .card:hover { transform: translateY(-4px); }
}

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


/* == 16  responsive ======================================================= */

@media (max-width: 1080px) {
  .split { grid-template-columns: 1fr 320px; }
  footer.site .foot-grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 960px) {
  /* minmax(0, 1fr): see the note on .catalog. Collapsed to one column this
     is what was putting a horizontal scrollbar on /catalog/ at 390 — the
     track sized to the card grid's 415px min-content inside a 350px
     container, which then fitted two cards where one belongs. */
  .catalog { grid-template-columns: minmax(0, 1fr); }
  /* ---- the filter drawer ----
     This used to be display:none -> display:block, which cannot be
     transitioned at all: the main browsing tool on a phone appeared and
     vanished with no path in and no path out. It is a sheet, so it now rises
     from the bottom edge and dismisses back down to the same edge — if
     something disappears one way we expect it to come back from there.

     visibility, NOT display, is what does the hiding. It still takes the drawer
     out of the tab order and out of the accessibility tree exactly as display
     did, but unlike display it can be transitioned — held visible for the whole
     slide out, then flipped at the end. opacity alone would have left a
     keyboard trap behind.

     The global prefers-reduced-motion rule flattens every duration on the page,
     so a reduced-motion visitor gets the drawer instantly with no slide and no
     extra rule needed here. */
  .side {
    position: fixed;
    inset: 0;
    z-index: 60;
    max-height: none;
    background: var(--paper);
    padding: var(--s5) var(--gutter) var(--s9);
    overflow-y: auto;
    overscroll-behavior: contain;   /* scrolling the drawer must not scroll the grid under it */
    visibility: hidden;
    opacity: 0;
    transform: translateY(100%);
    transition:
      transform 340ms var(--ease-io),
      opacity 200ms var(--ease-io),
      visibility 0s linear 340ms;
  }
  .side[data-open] {
    visibility: visible;
    opacity: 1;
    transform: none;
    transition:
      transform 380ms var(--ease),
      opacity 160ms var(--ease),
      visibility 0s;
  }
  .filter-toggle {
    display: inline-flex;
    align-items: center;
    gap: var(--s3);
    background: var(--ink);
    color: #fff;
    border: 0;
    cursor: pointer;
    font-family: var(--sans);
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0;
    padding: 13px 20px;
    border-radius: var(--r-pill);
  }
  .side .filter-toggle { width: 100%; justify-content: center; margin-top: var(--s5); }
  .side-actions { position: sticky; top: 0; background: var(--paper); z-index: 2; }
  .stats-panel { grid-template-columns: 1fr 1fr; }
  /* the hairline that divided columns now has to divide rows too */
  .stat-cell:nth-child(3) { border-left: 0; }
  .stat-cell:nth-child(n+3) { border-top: 1px solid rgba(255,255,255,.13); }
}

@media (max-width: 900px) {
  .editorial, .split, .style-head, .contact-grid {
    grid-template-columns: 1fr;
    gap: var(--s7);
  }
  .band, .band.flip, .fab { grid-template-columns: 1fr; gap: var(--s6); }
  .band.flip > .band-copy { order: 0; }
  .fab.flip > .fab-media { order: 0; }
  .band-media { max-width: 560px; }
  .side { top: 0; }
}

@media (max-width: 900px) {
  /* ---- every hero on a phone: copy and photograph SIDE BY SIDE ----
     Owner's call, 19 Aug 2026, replacing the stacked treatment. The whole
     hero — headline, lede, both buttons and the picture — has to land on
     the first screen with nothing scrolled, so the type comes down a step,
     the leading tightens and the buttons shrink to pay for it.

     The photo column is the delicate half. These are 16:9 landscapes in a
     slot narrower than they are, so the crop runs SIDEWAYS and the taller
     the box the less of the width survives. --hp-ar is the tallest this
     box may be per unit of its own width before the visible window becomes
     narrower than the model (build.mjs, heroAspect) — 1.5 to 2.0 for the
     landscapes, and a portrait or square file's own aspect otherwise,
     which crops nothing. --hp-pos-n anchors the crop to the model's own
     side, so what survives is her and not the far wall.

     No copy is over the photograph at all here — they are separate grid
     columns — so there is no legibility question and no scrim, exactly as
     when they were stacked. */
  .phero, .phero.wide {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, var(--hero-photo-w, 43%));
    align-items: center;
    column-gap: var(--s4);
    height: auto;
    min-height: 0;
    max-height: none;
    aspect-ratio: auto;
    margin-top: 0;
    padding: var(--s5) var(--gutter);
    background: var(--paper);
    overflow: hidden;
  }
  .phero-copy {
    grid-column: 1;
    grid-row: 1;
    position: static;
    z-index: auto;
    width: auto;
    padding: 0;
    color: var(--ink);
    background: none;
  }
  .phero-media {
    grid-column: 2;
    grid-row: 1;
    position: relative;
    inset: auto;
    height: auto;
    width: 100%;
    /* never wider than the file itself, whatever the viewport does */
    max-width: calc(var(--hp-w, 848) * 1px);
    margin-inline: auto;
    aspect-ratio: 1 / var(--hp-ar, 1.49);
    overflow: hidden;
    background: var(--hp-bg, var(--paper));
  }
  .phero-media img,
  .phero.wide .phero-media img,
  .phero .slide img {
    position: absolute;
    inset: 0;
    top: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: var(--hp-pos-n, var(--hp-pos, 50% 0%));
    -webkit-mask-image: none; mask-image: none;
    transform: none;
  }
  .phero .slide { position: absolute; inset: 0; }
  /* the scrim, and everything it was compensating for, gone */
  .phero-media::after,
  .phero .slide::after { opacity: 0; display: none; }

  /* Scaled to fit half a phone. */
  .phero .phero-copy h1 {
    font-size: clamp(20px, 5.4vw, 27px);
    line-height: 1.1;
    letter-spacing: -.02em;
    color: var(--ink);
    margin: 0 0 var(--s3);
  }
  /* Not 12px. The first pass shrank until it fitted and then kept going —
     everything cleared the fold at 360x640 with 121px to spare, which is
     slack bought at the cost of a lede a buyer has to squint at. Given back
     to the two elements that were paying for it. */
  .phero-copy .lede {
    font-size: clamp(12.5px, 3.5vw, 15px);
    line-height: 1.4;
    color: var(--ink-2);
    margin: 0 0 var(--s3);
    max-width: none;
  }
  .phero-copy .hero-actions {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--s2);
  }
  .phero-copy .btn,
  .phero-copy .btn.lg {
    justify-content: center;
    padding: 11px 16px;
    font-size: 13px;
    gap: var(--s2);
  }
  .phero-copy .btn.ghost { color: var(--ink); box-shadow: inset 0 0 0 1px var(--rule-strong, rgba(22,24,28,.22)); }
  .phero-copy .btn.ghost:hover { background: var(--ink); color: #fff; }
  .phero-copy .brand-h1 img { max-width: min(100%, 200px); height: auto; }
  .phero-label { display: none; }
  /* no scrim under it any more, so the logo needs no plate to sit on */
  .phero-copy .brand-h1 img { background: none; padding: 0; }

  /* ---- THE LANDSCAPE HERO STACKS AGAIN (owner, 1 Sep 2026) ----
     This REVERSES the 19 Aug side-by-side decision, but only for `.wide` —
     the home slideshow and the five brand pages. The square and portrait
     heroes keep the grid above, where they crop nothing and the whole hero
     still lands on one screen.

     The 19 Aug call was made before anyone measured what it costs a
     LANDSCAPE. These files are 16:9 and the side-by-side photo column is a
     box taller than it is wide (--hp-ar is 1.568 for them), so `cover` crops
     SIDEWAYS: at 390px the home hero keeps 35.9% of the frame's width and
     discards 64%, and the OnaFit brand hero keeps 24.6%. The crop is anchored
     at the model's own edge, so what survives is the model and a slice of
     wall — which is exactly why it read as fine against plain studio
     backgrounds for two weeks and became obvious the moment the patterned
     print slide led the rotation. All three home slides lose the same 64%;
     nothing about it was ever specific to the print.

     No anchor or ratio tuning gets that back, because the box and the file
     disagree about which way round they are. The only layout that shows a
     16:9 frame whole is one that gives it the full width. So the photograph
     is full-bleed at its OWN aspect (--hp-ar-full, the tallest frame in the
     set, from build.mjs) and the copy sits beneath it on paper. Nothing is
     cropped, which satisfies THE FACE RULE outright rather than by anchoring,
     and no hero stem carries a PHOTO_FEATURE entry to satisfy.

     The photograph comes first: it is the hero, and it is the reason the page
     opens the way it does. */
  .phero.wide,
  .phero.wide.slides {
    display: flex;
    flex-direction: column;
    grid-template-columns: none;
    column-gap: 0;
    align-items: stretch;
    padding: 0 0 var(--s7);
  }
  .phero.wide .phero-media {
    order: -1;
    grid-column: auto;
    grid-row: auto;
    width: 100%;
    max-width: none;
    margin-inline: 0;
    aspect-ratio: 1 / var(--hp-ar-full, .563);
  }
  .phero.wide .phero-media img,
  .phero.wide .slide img {
    /* the box now matches the file, so cover crops nothing — it is here to
       absorb the sub-pixel rounding a fractional aspect-ratio leaves */
    object-fit: cover;
    object-position: 50% 50%;
  }
  .phero.wide .phero-copy {
    grid-column: auto;
    grid-row: auto;
    padding: var(--s6) var(--gutter) 0;
  }
  /* the type came down a step to fit half a phone; it has the full width now */
  .phero.wide .phero-copy h1 { font-size: clamp(26px, 7.2vw, 38px); }
  .phero.wide .phero-copy .lede { font-size: clamp(14px, 3.9vw, 16px); max-width: 46ch; }
  .phero.wide .phero-copy .hero-actions { flex-direction: row; flex-wrap: wrap; }
  .phero.wide .phero-copy .btn,
  .phero.wide .phero-copy .btn.lg { padding: 13px 20px; font-size: 14px; }
  /* the dots sit on paper here rather than on the photograph */
  .phero.wide .slide-dots button::before { background: rgba(22,24,28,.18); }
  .phero.wide .slide-dots button[aria-current="true"]::before { background: var(--ink); }

  /* ---- THE WHOLE COPY BLOCK SITS IN THE COMPOSED COLUMN ----
     (owner, 1 Sep 2026, correcting the first pass the same day.)

     Every brand hero ships twice — a 3200x1800 landscape and a 900x1400
     portrait of the same photograph — and the portrait has clear ground
     composed into it beside the model for exactly this. So below 900px the
     frame changes shape (--hp-ar-mob, the portrait file's own aspect, so
     nothing is cropped and THE FACE RULE is satisfied outright) and the
     lockup, the tagline and BOTH buttons are laid into that ground, the way
     the desktop hero has always laid its copy into the studio void.

     THE FIRST VERSION PUT ONLY THE LOCKUP HERE and the rest on paper beneath,
     on the strength of a measurement taken from the HOME hero — a headline, a
     three-line lede and two buttons, 574px of copy inside a 607px frame. A
     brand page carries a lockup, ONE short tagline and two buttons: measured
     on its own content it is 217-255px, a third of the frame. The number was
     accurate and it was about a different page.

     WHERE THE COLUMN STOPS IS SOLVED PER FRAME, in build.mjs, from the
     photograph's own pixels — the five leave very different amounts of room
     (the model's near edge runs from 53% to 72% of the frame) and one fixed
     line would either waste EliteMed's or overrun ScrubAid's. Same device as
     --copy-stop on the desktop hero, same reason. The build FAILS if a frame
     cannot clear a column wide enough for the buttons to hold their labels on
     one line, and says how many pixels short it is.

     VERTICALLY CENTRED, like the desktop hero and for the same reason: the
     block belongs in the middle of the ground that was composed for it, not
     pinned to one end of it. An earlier pass anchored it near the top on the
     reasoning that centring would push it through the narrowest part of the
     column — where a hand on a hip cuts the clear ground to 53%. Measured,
     that turned out to cost nothing: the block is tall enough that its band
     ALREADY spanned that pinch point when it was top-anchored, so the widest
     column each frame can carry is the same either way. The one number that
     moved was EliteMed's, from 72% of clear ground to 66%, and its column is
     held at the 60% cap regardless.

     [data-overlay] IS THE SWITCH, so a wide hero with no portrait twin keeps
     the stacked treatment above exactly as it is. (The home slideshow was
     that hero until 7 Sep 2026 — see THE HOME SLIDESHOW below.) */
  .phero.wide[data-overlay] {
    position: relative;
    padding: 0;
  }
  .phero.wide[data-overlay] .phero-media {
    aspect-ratio: 1 / var(--hp-ar-mob, var(--hp-ar-full, .563));
  }
  .phero.wide[data-overlay] .phero-copy {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    left: var(--gutter);
    right: calc(100% - var(--ov-stop, 52%));
    z-index: 3;
    padding: 0;
    background: none;
  }
  /* The mark fills the column the build solved for. BLOCK, NOT INLINE, and
     that is not tidying: `.brand-h1` carries `line-height: 0` and an inline
     image sits on its parent's baseline, so the mark rendered 20px below the
     top of the box that had been measured — the check and the pixels were
     describing two different rectangles. */
  .phero.wide[data-overlay] .phero-copy h1 { margin: 0 0 var(--s3); }
  .phero.wide[data-overlay] .phero-copy .brand-h1 { display: block; line-height: 0; }
  .phero.wide[data-overlay] .phero-copy .brand-h1 img {
    display: block;
    width: 100%;
    max-width: 100%;
    height: auto;
  }
  .phero.wide[data-overlay] .phero-copy .lede {
    font-size: 14px;
    line-height: 1.35;
    margin: 0 0 var(--s4);
    max-width: none;
  }
  /* STACKED AND AT THE NARROW STEP. Side by side the two labels need 285px of
     row, which no frame here leaves; the narrow step is the one the phone
     already uses on every other hero, and at 13px "Browse the range" measures
     158px, which is what sets the minimum column the build refuses to go
     below.

     12px OF PADDING, NOT 11, AND THE REASON IS THE TAP TARGET. At 11px these
     came out 42.8px tall, a shade under the 44px floor this site holds itself
     to — measured in a browser rather than reasoned from the padding, which
     is how the shortfall was found in the first place. 12px puts them at
     44.8px. The four pixels it adds to the block are carried in the height
     model the build solves with. */
  .phero.wide[data-overlay] .phero-copy .hero-actions {
    flex-direction: column;
    align-items: stretch;
    gap: var(--s2);
  }
  .phero.wide[data-overlay] .phero-copy .btn,
  .phero.wide[data-overlay] .phero-copy .btn.lg {
    justify-content: center;
    padding: 12px 14px;
    font-size: 13px;
  }
  /* ---- THE HOME SLIDESHOW: THE SAME OVERLAY, SOLVED PART BY PART ----
     (7 Sep 2026.) Its three slides have composed portrait twins now
     (HERO_MOBILE and data/hero-mobile.json), so [data-overlay] is set on the
     slideshow too and everything above applies to it: the frame at the
     twins' own aspect, the H1 at the wide hero's phone size, the lede at
     14px, the buttons stacked at the narrow step.

     What differs is the COLUMN. A brand page's block takes one width; this
     block takes one per part — data-overlay="parts" — because every slide
     is a hand on a hip, and the elbow that narrows the frame to 60% does it
     at one height only. Beside her head and beside her legs the frame is
     clear past 68%, so the headline and the buttons run wider than the lede
     that sits level with the elbow: three lines of headline instead of
     five, and "What an account opens" on one line. build.mjs solves each
     width against both the clear profile and the figure's own edge, on
     every slide, and writes them as --ov-h1 / --ov-lede / --ov-actions /
     --ov-dots (solveOverlayParts). The block spans the frame and each part
     is sized from the gutter to its own stop, so a percentage here is a
     percentage of the FRAME, which is what the build measured.

     The dots sit in the column under the buttons at the 12px they can
     afford inside a frame, not the 32px they take on paper (the wrapper
     they arrive in is the flex item; the list inside it takes the width); the buttons'
     side padding is 10px rather than 14, which is what puts the public
     tree's longer label on one line inside the column the frames allow.
     HOME_HERO_PARTS in build.mjs was measured with both. */
  .phero.wide[data-overlay] .slide-dots { margin-top: var(--s3); }
  .phero.wide[data-overlay="parts"] .phero-copy {
    left: 0;
    right: 0;
    /* NOT centred: the headline and the lede hang from a top inset of the
       frame, the buttons and the dots stand on a bottom one, both fractions
       of the frame from build.mjs (--ov-top / --ov-bottom). A centred block
       drifts as the frame grows with the viewport, and each part here is
       sized to the band of the photograph it occupies — see the note at
       solveOverlayParts. At 360 the two groups meet and it reads centred. */
    top: var(--ov-top, 15%);
    bottom: var(--ov-bottom, 15%);
    transform: none;
    /* the base .phero-copy is align-self: center for the desktop grid, and a
       positioned box with both insets set is CENTRED inside them by that
       rather than stretched between them (CSS positioned layout 3, which
       Chrome implements) — measured: 372px of content sitting 94px down
       instead of the 392px between the insets. Stretch, so the groups
       actually reach the insets. */
    align-self: stretch;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
  }
  .phero.wide[data-overlay="parts"] .phero-copy .hero-actions { margin-top: auto; }
  /* each part sets its own left margin as well as its width: the h1 and
     lede rules above declare the margin shorthand at the same specificity,
     and a shared "> *" rule loses to them */
  .phero.wide[data-overlay="parts"] .phero-copy h1 { margin-left: var(--gutter); max-width: none; width: calc(var(--ov-h1, var(--ov-stop)) - var(--gutter)); }
  .phero.wide[data-overlay="parts"] .phero-copy .lede { margin-left: var(--gutter); max-width: none; width: calc(var(--ov-lede, var(--ov-stop)) - var(--gutter)); }
  .phero.wide[data-overlay="parts"] .phero-copy .hero-actions { margin-left: var(--gutter); width: calc(var(--ov-actions, var(--ov-stop)) - var(--gutter)); }
  .phero.wide[data-overlay="parts"] .phero-copy .slide-dots { margin-left: var(--gutter); width: calc(var(--ov-dots, var(--ov-stop)) - var(--gutter)); }
  .phero.wide[data-overlay="parts"] .phero-copy .btn,
  .phero.wide[data-overlay="parts"] .phero-copy .btn.lg { padding: 12px 10px; }
}

@media (max-width: 860px) {
  nav.main, .masthead-cta { display: none; }
  .menu { display: block; }
}

@media (max-width: 720px) {
  /* the bar needs 619px for all three parts; the address is in the footer of
     every page, so it is the one that goes rather than wrapping the bar */
  .tb-addr { display: none; }
  .topbar .wrap { gap: var(--s4); font-size: 12px; }
  /* 214px, not 190. auto-fill only lays a track it can fit, but it fits
     against the DECLARED minimum — so a declared 190 while a catalog card's
     real min-content is 206 let it lay two tracks that the card then burst
     out of. Declared at the true figure plus a little headroom, the
     arithmetic and the content agree and the count is right at every width
     without a hand-set breakpoint per page. */
  .grid { grid-template-columns: repeat(auto-fill, minmax(214px, 1fr)); }
  .colorgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
  footer.site .foot-grid { grid-template-columns: 1fr; }
  .brand-grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 560px) {
  .stats-panel { grid-template-columns: 1fr; }
  .stat-cell { border-left: 0; }
  .stat-cell + .stat-cell { border-top: 1px solid rgba(255,255,255,.13); }
}

@media (max-width: 480px) {
  :root { --t-body: 16px; }
  /* `.grid { 1fr 1fr }` used to sit here and was the bug: a 1fr track IS
     floored by min-content, so it forced two columns at widths where two
     cards could not fit — 31px of sideways page scroll at 390, and worse on
     /catalog/, whose cards carry the ratio line and run 206px rather than
     190. The auto-fill rule above already picks the honest count. */
  .card { padding: var(--s4); }
  .card .style-no { font-size: 18px; }
  .fig-strip { grid-template-columns: repeat(3, 1fr); }
  .btn { padding: 14px 22px; font-size: 13px; }
  .btn.lg { padding: 16px 26px; font-size: 14px; }
  .plate-grid { grid-template-columns: 1fr; }
  .brand-grid { grid-template-columns: 1fr; }
}

/* 360px floor — nothing may overflow horizontally below this */
@media (max-width: 380px) {
  :root { --gutter: 16px; }
  .grid { grid-template-columns: 1fr; }
  .colorgrid { grid-template-columns: 1fr 1fr; }
  .ratio-strip { font-size: 15px; gap: var(--s2); }
  .ratio-strip .seg::after { width: 10px; }
}


/* == 17  focus + print ==================================================== */

:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 3px;
}
.card:focus-visible { outline-offset: -2px; }

/* The burger/X is a <summary>, which keeps :focus-visible matched after a
   POINTER activation in Chromium and WebKit — so a tap to open the menu
   left a 2px red ring, offset 3px around a 40x40 control, drawn as a box
   around the X for the whole time the panel was open. Suppressed only when
   the script has recorded that the last input was a pointer; keyboard focus
   still paints the ring, which the accessibility floor requires. Scoped to
   this one control so focus behaviour is unchanged everywhere else. */
html[data-input="pointer"] .menu > summary:focus-visible { outline: none; }

/* == 18  quality of life (19 Aug 2026) ==================================== */

/* a jump target must land clear of the sticky masthead, not under it */
[id] { scroll-margin-top: calc(var(--head-h) + 24px); }

/* the scrollbar appearing and disappearing between short and long pages was
   shifting every centred layout sideways by half its width */
html { scrollbar-gutter: stable; }

/* selection carries the house ink; on dark bands it inverts */
::selection { background: var(--ink); color: #fff; }
.darkfield ::selection, .cta-close ::selection, .bfield-copy ::selection,
footer.site:not(.light) ::selection { background: #fff; color: var(--ink); }

/* any native control that ever slips through the custom chrome */
input, select { accent-color: var(--red); }

/* paragraph rag control — headings already balance */
p { text-wrap: pretty; }

/* scroll progress: a 2px ink line under the masthead, lookbook pages only —
   the spec pages stay calm — and only once the page is long enough that
   position feedback earns its pixels. Driven by --scroll-p from the script. */
[data-register="lookbook"] .masthead.progress::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -2px;
  height: 2px;
  background: var(--ink);
  transform-origin: 0 50%;
  transform: scaleX(var(--scroll-p, 0));
}

/* photographs arrive, they do not pop — the hidden state exists only under
   .js and only when motion is welcome, per the standing rule */
@media (prefers-reduced-motion: no-preference) {
  .js .band-media img.ph, .js .fig img.ph,
  .js .bfield-media img.ph {
    opacity: 0;
    transition: opacity 600ms var(--ease);
  }
  .js .band-media img.ph.loaded, .js .fig img.ph.loaded,
  .js .bfield-media img.ph.loaded {
    opacity: 1;
  }
}

@media print {
  nav.main, .menu, .masthead-cta, .side, .filter-toggle, .cta-close, .darkfield { display: none; }
  .masthead { position: static; }
  body { font-size: 11pt; }
  .fig { break-inside: avoid; }
  .card { break-inside: avoid; }
}


/* Frost is honoured as a PREFERENCE (apple-design): when the platform asks
   for reduced transparency, both glass surfaces go solid. Placed after the
   @supports frost rules so it wins by order at equal specificity. */
@media (prefers-reduced-transparency: reduce) {
  .masthead::before {
    background: rgba(255, 255, 255, .98);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
  .menu-panel {
    background: var(--paper);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* == 19  saved styles ===================================================== */
/* The star, the counter in the chrome, and /saved/. All of it is driven by
   src/saved-store.js through four calls; none of it knows where the list is
   kept. Kept in one block for the same reason the store is one file — when
   saving moves to accounts this is the surface that has to still look right,
   and a surface scattered through eighteen sections is one nobody re-checks. */

/* ---- the card is a container now ----
   It used to be the <a> itself. A save control is a button, and a button
   inside a link is invalid and unreachable by keyboard, so the link became a
   stretched overlay and the star a sibling above it. The click target is
   unchanged: ::after covers the whole card. */
.card-link {
  position: static;
  text-decoration: none;
  color: inherit;
}
.card-link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
}
/* The card carried a pointer cursor for free while it was an anchor. */
.card:not(.static) { cursor: pointer; }

/* The focus ring belongs to the whole card, not to a zero-size anchor in the
   corner of it — so it is drawn on the overlay, which is card-sized. */
.card-link:focus-visible { outline: none; }
.card-link:focus-visible::after {
  outline: 2px solid var(--red);
  outline-offset: -2px;
}

/* ---- the star ----
   No JS, no star: it is inert without the store, and an inert control is
   worse than an absent one. Every other progressive control here is gated the
   same way.

   AND NO SESSION, NO STAR (owner, 27 Aug 2026). The shortlist lives on the
   account now, so a signed-out visitor has nowhere to save to —
   /api/account/saved answers 401, and a star that appears to work and
   silently does not is the worst failure available here. `.is-in` is the
   class the masthead's auth controls already hang off, so this is one extra
   token on a selector that was already doing this job rather than any new
   machinery. Before the session answer lands neither class is set, so the
   star is absent rather than briefly wrong.

   It only matters while GATING is off. With the gate on a signed-out visitor
   cannot reach a style page at all, so the rule stops applying and does no
   harm — which is why it is a selector and not a second state in the
   template to design and maintain. */
.star { display: none; }
.js.is-in .star {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  border: 0;
  background: none;
  color: var(--ink-4);
  cursor: pointer;
  font-family: var(--sans);
  touch-action: manipulation;
  transition: color 300ms var(--ease), background 300ms var(--ease),
              transform 260ms var(--ease), box-shadow 300ms var(--ease);
}
.star-i {
  display: block;
  line-height: 0;
  flex: none;
}
.star-i svg { display: block; width: 100%; height: 100%; }
/* One closed path, so the whole state change is a fill. */
.star[aria-pressed="true"] { color: var(--red); }
.star[aria-pressed="true"] .star-i svg { fill: currentColor; }
.star:active { transform: scale(.92); }

/* on a card: a small plate in the top corner of the sketch, above the
   stretched link so the two are genuinely separate controls */
.js .star-icon {
  position: absolute;
  top: var(--s5);
  right: var(--s5);
  z-index: 2;
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: var(--r-pill);
  /* the sketches are shot on white, so the glyph needs its own ground to sit
     on rather than dissolving into the garment */
  background: rgba(255, 255, 255, .82);
  box-shadow: inset 0 0 0 1px var(--rule), var(--lift-1);
}
.star-icon .star-i { width: 17px; height: 17px; }
.star-icon:hover { color: var(--red); background: var(--paper); }
.star-icon[aria-pressed="true"] { background: var(--paper); }

/* beside "Order this style": a visible button, sized to the ghost buttons it
   stands next to */
.js .star-button {
  padding: 15px 24px;
  border-radius: var(--r-pill);
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--rule);
  background: var(--paper);
}
.star-button .star-i { width: 18px; height: 18px; }
.star-button:hover { color: var(--red); box-shadow: inset 0 0 0 1px var(--red); }
.star-button[aria-pressed="true"] { box-shadow: inset 0 0 0 1px var(--red); }
.star:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .js .star { transition: none; }
  .star:active { transform: none; }
}

/* ---- the counter in the chrome ----
   Rendered hidden by the build and revealed by the script only when there is
   something to count, so a first visit never sees a control holding nothing. */
.saved-link {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  text-decoration: none;
  color: var(--ink-2);
  flex: none;
  transition: color 300ms var(--ease);
}
.saved-link:hover { color: var(--ink); }
.saved-link .star-i { width: 18px; height: 18px; }
.saved-n {
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  min-width: 18px;
  padding: 4px 5px;
  border-radius: var(--r-pill);
  background: var(--red);
  color: #fff;
  text-align: center;
}
.saved-link[aria-current] { color: var(--ink); }

/* in the slide-out menu the link is set as text, like the other menu rows */
.sl-menu {
  font-size: 17px;
  font-weight: 500;
  color: var(--ink);
  padding: 10px 0;
}
.sl-menu .star-i { width: 20px; height: 20px; }
.saved-link:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }

/* ---------------------------------------------------------------------------
   THE MOBILE MASTHEAD (owner, 27 Aug 2026)

   What was here put `margin-left: auto` on the SAVED COUNTER and took it off
   the burger. That is one rule too clever by half, and it broke in two
   different directions depending on a state it had no business depending on:

     - the counter is HIDDEN AT ZERO — [hidden] is display:none !important, so
       a hidden flex item is not a flex item. With nothing saved (the common
       case, and every signed-out visitor) the bar lost its only auto margin,
       and since the burger's had been removed there was none left at all.
       Everything packed to the LEFT and left 71px of dead space against the
       right edge, measured at 375px.
     - with something saved, the auto margin came back and the whole
       right-hand group was pushed over — so the bar changed shape depending
       on whether the buyer had starred anything, which nobody asked for.

   So it was two mistakes compounding, not one: the spacer was put on a
   conditionally-hidden element, AND the reliable spacer was deleted in the
   same rule. Either alone would have been survivable.

   THE SPACER NOW LIVES ON THE LOGO, which is the one item in this bar that is
   never conditionally anything. Whatever else is present, the logo holds the
   left edge and everything after it sits in a group against the right.

   A third thing was wrong and was not a layout bug: nothing here met the 44px
   tap floor. The counter measured 19px tall, the account control 34, the
   burger 40. Those are set below, and the 64px bar has room for all of them.
   --------------------------------------------------------------------------- */
@media (max-width: 860px) {
  /* nav.main and the CTA are gone at this width (see the rule above), so the
     bar is: logo | ...gap... | counter, account, menu. */
  .masthead > .wrap { gap: var(--s4); }
  .masthead .logo { margin-right: auto; }
  /* Both of these are now inert and are stated rather than left to inherit,
     because the previous version's whole failure was an auto margin nobody
     could see. */
  .sl-bar { margin-left: 0; }
  .masthead .menu { margin-left: 0; }

  /* ---- 44px, the floor this stylesheet already keeps elsewhere ----
     The menu panel's own rows document it: "14px of vertical padding keeps
     each row at ~51px, clear of the 44px". These are the three controls that
     were under it. */
  .saved-link.sl-bar {
    min-width: 44px;
    min-height: 44px;
    justify-content: center;
    gap: var(--s1);
  }
  /* Scoped to .masthead so these beat the base rules on specificity rather
     than on source order. The sign-in/account block is written further down
     this file, so an unscoped .acctlink-bar here would set 44px and then be
     overruled by the 34px twenty rules later — which is exactly what happened
     the first time, and is invisible unless you measure. */
  .masthead .acctlink-bar { width: 44px; height: 44px; }
  /* min-WIDTH as well as height: "Log in" sets ~36px, so the link met the
     floor vertically and missed it horizontally. Centred inside its own box so
     widening the target does not move the word. */
  .masthead .authlink-bar { min-height: 44px; min-width: 44px; justify-content: center; }
  .menu > summary {
    width: 44px;
    height: 44px;
    /* The burger is a 22px glyph in a 44px box, so its ink sits 11px inside
       the box on each side. Pulling the box 10px past the content edge puts
       the GLYPH about where the logo's left edge is mirrored, which is what
       the eye lines up against — the old -8px did the same job for a 40px
       box. The target keeps its full 44px; only the box hangs over. */
    margin-right: -10px;
  }

  /* THE ACCOUNT CONTROL AND THE MENU ARE TWO CONTROLS, and at 44px each they
     have to read as two. The slot's own right-aligned content puts its 28px of
     slack on the LEFT, so the flex gap alone would separate the pair by less
     than it separates them from the counter. This is what makes the gap on
     the right side of the account control the wider of the two. */
  .authslot { margin-right: var(--s2); }
}

/* The "sign out" rules that were here are gone with the link (owner, 27 Aug
   2026). It left the masthead for /account/, where it is a button on the page
   the profile icon leads to. See the note over authControls() in build.mjs.

   Worth recording, because it was the argument for the shape it had: the
   mobile bar dropped that link entirely and left it to the slide-out, since
   at 360px a logo, a counter, a control and a burger is one item more than
   the row can carry without the counter losing its air. The bar is back to
   three items and does not need the exception. */

/* ---- sign in / your account ----
   Two controls, one slot, exactly one of them ever visible. The build renders
   both and neither is the default — see authControls() in build.mjs for why
   "render one and swap it" was not good enough. The rules below are the whole
   mechanism and their order matters:

     1. the account link is hidden outright, so it never appears to anyone we
        have not been told about — including a visitor with no JavaScript,
        where nothing will ever tell us;
     2. under .js the sign-in link hides too, so between parse and the
        session answer the slot is empty rather than wrong;
     3. the answer arrives as .is-in or .is-out on <html> and reveals one.

   Without .js only rule 1 applies and Log in renders, which is the correct
   no-script fallback: a real link to a real page in both gating modes.

   The two variants take different display values — the bar is inline-flex in
   a flex row, the slide-out is a full-width flex row of its own — so each
   states its own rather than sharing one and fighting over it. */
.acctlink { display: none; }
.js .authlink[data-signin-link] { display: none; }
.is-out .authlink-bar[data-signin-link] { display: inline-flex; }
.is-out .authlink-menu[data-signin-link] { display: flex; }
.is-in .acctlink-bar { display: inline-flex; }
.is-in .acctlink-menu { display: flex; }

/* THE SLOT KEEPS ITS WIDTH WHATEVER LANDS IN IT. The two states are a word
   and a 34px icon, so without a floor here the nav beside them would jump
   sideways the moment the fetch resolved — the layout shift version of the
   flash the markup went to such trouble to avoid. Right-aligned so the
   control still sits against the CTA. */
.authslot {
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  min-width: 72px;
  flex: none;
}

.authlink-bar {
  display: inline-flex;
  align-items: center;
}
.authlink {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  text-decoration: none;
  color: var(--ink-2);
  white-space: nowrap;
  transition: color 300ms var(--ease);
}
.authlink:hover { color: var(--ink); }
.authlink:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }

/* The signed-in control is an icon in a ring — the one place in the masthead
   that stands for a person rather than a destination, so it takes a shape
   instead of a word. Not a plate and not a button: the masthead already
   carries one button and it is the one the whole site funnels to. */
.acctlink-bar {
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  color: var(--ink-2);
  box-shadow: inset 0 0 0 1px var(--rule);
  transition: color 300ms var(--ease), box-shadow 300ms var(--ease);
}
.acctlink-bar:hover { color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink-4); }
.acctlink-bar[aria-current] { color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.acctlink:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }
.acct-i { width: 18px; height: 18px; display: block; }
.acct-i svg { display: block; width: 100%; height: 100%; }

/* in the slide-out both are set as text, like the other menu rows */
.authlink-menu { display: flex; }
.authlink-menu,
.acctlink-menu {
  align-items: center;
  gap: var(--s2);
  font-size: 17px;
  font-weight: 500;
  color: var(--ink);
  padding: 10px 0;
}
.acctlink-menu .acct-i { width: 20px; height: 20px; }

/* ---- /saved/ ---- */
.saved-bar {
  display: flex;
  align-items: center;
  gap: var(--s4);
  margin-bottom: var(--s5);
}
.saved-bar .rcount { margin-right: auto; }
/* .afchip's × means "remove this filter"; this one empties a list and asks
   before it does, so it carries neither the glyph nor the promise. */
.saved-clear::after { content: none; }
.saved-clear.armed { background: var(--red); }
.saved-empty { margin-top: var(--s5); }
