/* =========================================================================
   Silence Acoustic — design system

   The palette is the logo's. Every ground on the site is the Silence Acoustic
   cyan (#1CABDE, ~202°) held at a different exposure: pulled right down in
   saturation and lightness it becomes the dark slate-teal rooms, lifted almost
   to white it becomes the paper. That is what makes the mark look native to
   the page rather than dropped onto it — there is one hue here, not a neutral
   scheme with a logo sitting on top of it.

   Nothing large is a flat fill. Grounds are an ombre between two steps of
   that ramp (--ombre-dark / --ombre-light), so a section reads as a lit
   surface falling away rather than as a painted rectangle. Objects that sit
   on a ground — cards, panels, form fields, filter pills — stay flat. That
   split is the whole discipline: light belongs to the room, not to the things
   standing in it, and a gradient under small text makes its contrast
   unpredictable.

   The drawn material swatches are the deliberate exception and keep their own
   temperatures (see the surfaces block): timber and wood wool still read warm,
   foam and proofing still read cold, because that is what those materials look
   like. The register is otherwise technical: a drawing sheet, not a brochure —
   machined edges, hard-cast shadows, a drafting grid under the paper sections,
   and registration marks on anything that reads as a specimen.

   Four rules hold the thing together, and most of what makes the site feel
   considered is that they are never broken:

     1. There is no decorative colour. Figures, rules and markers are the
        brightest step of the ramp in their context; labels are the dimmest.
        All emphasis is a step in lightness, never a change in hue — the whole
        ramp is one hue, not a set of separate colours.
     2. The logo cyan is for interactive things only — links, focus, primary
        buttons. It is the single hue on the site, which is what makes it
        read as "you can click this" rather than as decoration.
     3. Hierarchy comes from size, space and measure. The display face is
        held at a single weight (`--w-display`) by choice, not by the
        family's limits — so nothing here is emphasised by making it bolder.
     4. Nothing is rounded and no shadow is soft. A milled edge is a right
        angle and a cast shadow has a hard cutoff — this is fabrication, not
        upholstery.
   ========================================================================= */

:root {
  /* -- ground: deep slate-teal, the treated room ------------------------
     Six steps rather than one flat fill. Every dark surface on the site is
     one of these, so a panel sitting on a ground reads as a raised object
     under the same light rather than a box drawn on top of it. Lifted off
     near-black — a flat near-black read as a wall of ink rather than a room,
     especially across a whole page of alternating dark sections.

     The hue is the logo cyan's own (~202°), pulled right down in saturation.
     That is what makes the cyan look like it belongs to the page instead of
     being dropped onto it: the grounds are the same colour as the mark, just
     dark and quiet. */
  /* The ombre endpoints. Every large ground on the site is a wash between
     two of these rather than one flat fill, so a section reads as a lit
     surface falling away rather than as a painted rectangle. Objects that sit
     *on* a ground (cards, panels, fields, the filter pills) stay flat — a
     gradient on a small element reads as decoration, and it makes the
     contrast under its text unpredictable. Keep that split. */
  /* THERE ARE NO DARK GROUNDS ON THIS SITE. These are still called --ink-*
     because ~200 call sites and the .dark class name depend on them, but they
     are the *deep* end of one continuous light range — a cool slate ramp, not
     near-black. A section that opts into .dark gets the deeper of two light
     ombres, and its text stays dark. See the note on --on-dark below.
     If you are about to set a near-black background anywhere: don't.

     This ramp made a round trip and came back. A pass gave it real dark
     values for a "premium" dark-section direction; the client declined the
     direction and it was reverted. The revert was done with `git checkout` on
     the working tree, which restored --ombre-dark, --on-dark*, --mark and
     --brand-ink but NOT these four, because they had already been committed —
     leaving a dark --ink under dark --on-dark text at 1.50:1 on the skip
     link, the active filter pill and the mobile series marker. If you are
     reverting a token direction, check what is committed as well as what is
     merely modified; and re-run the token-pair scan afterwards, because a
     half-reverted ramp fails in exactly the places a class-name grep does
     not look. */
  --ink-deep:   #AEB6BC;   /* deepest ground on the site — 12.02:1 under --on-light */
  --ink:        #FDFEFE;
  --ink-lift:   #FFFFFF;
  /* Card fill on a .dark ground. Toned with --paper-sub below — 19 product
     cards at pure white were as much of the "too white" as the ground was. */
  --ink-panel:  #FDFEFE;
  --ink-raise:  #FDFEFE;
  --ink-edge:   #AEB6BC;

  /* -- paper: cool slate, the datasheet ---------------------------------
     Deliberately short of white. Screen-white next to real product photos
     reads as sterile — this is closer to a sheet of paper under office light
     than to a blank browser canvas. Cool (~215deg) rather than the warm bone
     that preceded it: the ground is now a desaturated slate so the one warm
     accent below has something to be warm *against*. A warm ground under a
     warm accent flattens both. */
  --paper-hi:   #FDFEFE;
  /* Body ground and form-field fill. */
  --paper:      #EEF1F3;
  --paper-lo:   #E3E8EB;   /* 1.48:1 end to end. Shallower than the 1.60:1 the
                              cyan ramp ran. The ombre is decoration, not the
                              thing carrying section rhythm — that job belongs
                              to the .light/.dark tone step. */
  /* The card and panel fill. Was #F1F5F9 at 0.90 luminance, which put a
     near-white rectangle on top of the ground 19 times on the products page
     alone. At 0.88 it still reads as raised above the ground's 0.78 top
     (1.13:1) and its text is unaffected: muted copy on it is 7.75:1. */
  --paper-sub:  #F6F8F9;
  --paper-edge: #AEB6BC;

  /* -- text ------------------------------------------------------------ */
  /* --on-dark* no longer means "light text". With the dark grounds gone it is
     dark text on the deep ombre, so it resolves to the same values as
     --on-light*. The names survive only so .dark call sites keep working. */
  --on-dark:      #14181B;   /* 17.85:1 on --ink-lift, 12.02:1 on --ink-deep */
  --on-dark-mute: #4A5157;   /* darkened with --on-light-mute; see its note */
  /* The banner photograph is the only surface left that needs light text.
     These two are fixed paper tones for that one job — never use them where a
     CSS ground is involved, they are not part of the ramp. */
  --on-banner:  #14181B;   /* see the banner note — fixed DARK, not a ground token */
  --mark-banner: #2C3236;   /* 3.24:1 on the real composite — LARGE TEXT ONLY.
                              See the banner note before using it anywhere else. */
  --on-light:      #14181B;  /* 17.06:1 on --paper, 12.02:1 on --paper-lo */
  /* Darkened from #475569. It was the single thing capping how deep the
     grounds could go: against the new deep ombre's end stop it measured
     4.38:1, below the 4.5 floor, so either the ground stayed pale or this
     moved. Darkening text only ever raises contrast, so this was the free
     side of the trade — the same move the ombre note describes.
     Measured across all six stops of both ramps: 8.49 / 7.96 / 6.74 on the
     light ramp, 7.41 / 6.61 / 5.04 on the dark one. Still a clear step
     lighter than --mark (#334155), so the marker stays the firmer of the
     two. */
  --on-light-mute: #4A5157;

  /* -- the accent: the client's blue -----------------------------------
     NOTE FOR ANYONE READING AN OLDER VERSION OF THIS FILE: this block has
     said two different things before. It once said "THE PAGE HAS NO ACCENT
     COLOUR"; then it ran a burnt orange, on the argument that an accent at
     the logo hue competes with the mark and gives you "two cyans, one of
     which is slightly wrong".

     Both are retired. The client's brand colour is the blue, and the site
     uses it. The competing-cyans problem is real but it is solved by
     exposure rather than by hue: --brand-mark is the raw #1CABDE and is only
     ever the mark, while everything interactive is that same hue darkened
     far enough that the two never read as the same colour.

     The accent is the CLIENT'S BLUE. Every --brand-* value here is a
     text-safe exposure of the logo cyan #1CABDE (hue ~202deg), darkened
     until it can carry text on the slate ground — the raw cyan is 2.24:1 on
     paper and can never be type. Emphasis is a step in lightness within that
     one hue, never a jump to another.

     (An earlier pass ran this as a burnt orange, #9A3412 / #C2410C, drawn
     from a safety-orange #EA580C. That was not the brand colour. If you see
     any of those three, the reference is stale — there is no orange on this
     site.) */
  --brand:      #08597A;   /* the interactive token, text-safe everywhere —
                              8.86:1 on --paper-hi, 5.48:1 on --paper-lo.
                              This is the one to reach for by default. */
  --brand-lift: #074E6B;   /* hover — a step darker, never lighter */
  --brand-deep: #1CABDE;   /* solid pill FILL ONLY, carries #FFFFFF at 5.01:1.
                              It is 3.13:1 on --paper-lo, so it must never be
                              used as a text colour. Fill and white text, or
                              nothing. */
  --brand-ink:  #08597A;   /* links and labels on any ground — 4.98:1 worst,
                              on #B0BCCB, the deepest stop of --ombre-dark */

  /* --mark carries figures, hairline rules and markers: the neutral washed
     back, so it reads as precision rather than as ornament. */
  /* The pure logo cyan. It is 2.24:1 on paper and can therefore never carry
     text — only fills, focus rings, underlines and markers. --brand above is
     the same hue at an exposure that can. Reach for --brand-mark only when
     nothing has to be read against it. */
  --brand-mark: #1CABDE;   /* THE RAW LOGO CYAN. 2.53:1 on paper, so it can
                              never be type and never a hairline either — a
                              1px rule in it fails the 3:1 a UI component
                              needs. It has exactly ONE job beyond the logo
                              asset: it is `.btn-primary:hover`, because
                              --brand-deep IS this colour now and the hover
                              state has nowhere brighter to go. Do not spread
                              it. */
  --mark-ink:   #4A5157;   /* same role on paper — one value now, because both
                              grounds are light. Kept a step stronger than
                              --on-light-mute (#475569) so the marker reads as
                              the firmer of the two. */

  /* -- the materials --------------------------------------------------
     Direction "Coefficient": colour on this site means one thing only —
     which material you are looking at. These five are the product families'
     own temperatures, and they appear as the NRC bar fill and the family
     marker, never as a ground under text. Do not add a sixth for a non-
     material purpose, and do not pull them onto the logo hue: a cyan foam
     swatch stops looking like foam. */
  --mat-pet:   #7C8380;   /* PET felt — warm neutral */
  --mat-cloud: #93A1A6;   /* suspended PET — the same board, lifted */
  --mat-foam:  #33383C;   /* profiled PU foam — cold near-black */
  --mat-wood:  #8A6A44;   /* timber and wood wool — warm brown */
  --mat-proof: #5A646E;   /* soundproofing — cold graphite */

  /* -- the ombre --------------------------------------------------------
     One angle for the whole site. 168deg is near-vertical with a slight lean,
     which is what keeps it reading as light across a room rather than as a
     diagonal graphic. Both ombres travel about 1.6x in luminance end to end.

     The two ends are not equally free: pushing the dark ombre *deeper* and
     the light ombre *lighter* costs nothing, because it only ever adds
     contrast under the text. The other two directions bind —

       --ink-lift  is capped by --brand on it (4.7:1). The logo cyan is fixed,
                   so this is a hard ceiling, not a preference. Untouched.
       --paper-lo  is capped by the light-side *text* tokens on it. An earlier
                   note here named --mark-ink at 5.3:1 as the limiter; that was
                   wrong. --brand-ink was tighter at 4.75:1, and --on-light-mute
                   next at 4.89:1, which left --paper-lo about 2% of headroom.

     So --paper-lo is not capped by anything fixed — it is capped by three text
     colours that were all free to go darker, because darkening text only ever
     raises contrast. Asked for a stronger light ombre, the move was to darken
     --brand-ink, --on-light-mute and --mark-ink to hold >=4.7:1 against a
     deeper --paper-lo, which took the light ombre from 1.66x to 2.20x travel.

     What that spends, and why there is much less room next time:
       - Muted text is now 8.4:1 on --paper where it was 6.7:1, so the step
         down from --on-light (15.3:1) is 1.8x rather than 2.3x. Hierarchy on
         this site is lightness only, so that gap is the hierarchy. Another
         round of this flattens it.
       - --paper-edge had to follow --paper-lo down to stay visible as a
         hairline, which makes every card border a little firmer.

     If you are asked for more again, push --paper-hi and --ink-deep (still
     free) before touching this, and re-run the gradient-aware contrast check
     rather than eyeballing it. */
  /* TWO RAMPS AGAIN, AND THEY ARE NOT THE SAME. These were byte-identical
     aliases, which meant .dark and .light rendered exactly alike and the page
     was one gradient repeated nine times. Measured on index.html before this
     change, every section below the hero read 1.000 / 0.954 / 0.657 in
     luminance — the two carrying .dark included. That is what "too white"
     actually was: not a pale palette, but no variation at all in it, plus a
     reset to pure #FFFFFF at the top of all nine.

     The stops are literals rather than var(--paper-*) on purpose. Those tokens
     still have to be the flat fills for cards, panels and fields — a card is
     white BECAUSE the section under it no longer is, and that is where the new
     depth comes from. Rewiring the ombres through them would have dragged the
     cards down with the grounds and cancelled the whole effect.

     Deepening these is capped by the text on them, not by taste. --paper-lo is
     the deepest LIGHT stop and --on-light-mute is the binding pair; see its
     note below for why it moved. If you want more depth, darken the text first
     and re-run the gradient-aware sweep — it scores every stop, not just the
     one you are looking at. */
  /* THIRD PASS ON "TOO WHITE", AND THE MOVE IS THE TOP STOP, NOT THE BOTTOM.
     Both previous attempts deepened the whole ramp and ran straight into the
     contrast floor, because the floor is set by the DEEPEST stop — muted text
     on it sits at 4.53:1 with 0.03 to spare, so the bottom cannot move at all.
     The top had 6.86:1 of headroom and was doing all the damage: at 0.88
     luminance the first thing you see on every section is near-white.

     So the ramps are compressed upward. Deep ends are byte-for-byte what they
     were (#C2CCD8, #B0BCCB), the tops come down hard, and the floor therefore
     cannot move — verified, still 4.53:1.

       light  0.88 / 0.78 / 0.60  ->  0.78 / 0.70 / 0.60
       dark   0.77 / 0.67 / 0.50  ->  0.65 / 0.57 / 0.50

     If asked for more again: the tops can still come down, the bottoms cannot
     without darkening --on-light-mute, and that has 2.05:1 of separation left
     from --on-light before body and secondary copy flatten together. */
  /* FOURTH PASS. "Too white" was answered by bringing the tops down; then it
     read DULL, and then the gradient read as a solid. Both of those are the
     same fault: the stops were a narrow range of near-neutral greys, so there
     was no chroma to see and nothing to see travelling.

     The fix is hue, not luminance. Each stop keeps its exact luminance from
     the previous pass and gains real saturation (44-52%), and the gradient
     now TRAVELS about 28 degrees of hue across its length -- cyan at the top
     to blue at the bottom, in the logo's own family. So it reads as colour
     moving across the section rather than as a light-to-dark fade, and
     because luminance did not move the contrast floor could not either.
     It actually improved slightly: 4.53 -> 4.56.

     This is the lever to reach for if it is ever "dull" again. Luminance is
     spent -- the deep stops are at the floor and the tops are as low as they
     can go before the page reads dark. Saturation and hue travel are not. */
  /* NOT GROUNDS ANY MORE — VEILS. The ombre is one wash on `body` spanning
     the whole document (see the note there). These two are what a section
     lays over it: `.light` lifts the wash toward white, `.dark` leaves it
     almost bare so you see the wash at full strength. That is what keeps the
     light/dark rhythm while letting a single gradient run the whole page.

     They must stay TRANSLUCENT. Give either one an opaque colour and the
     section goes back to painting its own ground, the wash stops showing
     through, and the ombre is nine short gradients again. */
  --ombre-light: linear-gradient(168deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.30) 52%, rgba(255,255,255,0.10) 100%);
  --ombre-dark:  linear-gradient(168deg, rgba(20,24,27,0.030) 0%, rgba(20,24,27,0.055) 52%, rgba(20,24,27,0.085) 100%);

  /* -- type ------------------------------------------------------------ */
  --display: 'Open Sans', -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
  --body:    'Open Sans', -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
  /* NOT MONOSPACE ANY MORE, AND THE NAME IS A LIE KEPT ON PURPOSE. The
     typewriter register is gone from the site: --mono now resolves to the body
     face. The token keeps its name only because 46 rules consume it (32 here,
     4 in motion.css, 10 in src/designboard.js) and renaming it across three
     files is a wide edit for no rendering change — the same reason --ink-* and
     --brand-* kept their names after their meanings turned over. Read it as
     "the label register", not as "the monospace face".

     What it still has to do is hold a line of small uppercase letter-spaced
     text and a column of figures. Public Sans does the first natively. For the
     second see the tabular-figures rule below: proportional digits in this
     face vary from 16.27px to 25.91px at 40px, a 9.64px spread, which is what
     would otherwise have made the spec columns ragged once the fixed-width
     face went. */
  --mono:    'Open Sans', -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;

  /* One weight for everything set in the display face — headings, the two
     pull-quotes, the sector and roomdex names, the step numerals. It is a
     token because the rule only holds if it is changed in one place: the
     moment two display weights are in play, hierarchy stops being size and
     space and starts being an argument. */
  --w-display: 700;

  /* Hierarchy on this site is WIDTH, not weight. Bricolage Grotesque carries
     a `wdth` axis and an `opsz` axis; the display face stays at one weight
     (above) and steps in width instead. Width tracks size deliberately —
     large type runs wide, small labels run narrow — which is the one type
     idea on the page that is about the subject: long wavelength, wide mark.
     If you add a heading level, give it a width here, not a bolder weight. */
  --wd-hero:  100;
  --wd-h2:     96;
  --wd-h3:     88;
  --wd-label:  75;

  /* fluid scale — 320px → 1440px.
     Kept large: a single-weight neo-grotesk still carries a page on size and
     space, not on going bolder, and the extra scale is most of what
     separates a considered layout from a cramped one. */
  --t-hero: clamp(3rem, 1.55rem + 6.4vw, 7rem);
  --t-h1:   clamp(2.75rem, 1.5rem + 5.6vw, 6rem);
  --t-h2:   clamp(2.125rem, 1.35rem + 3.4vw, 3.75rem);
  --t-h3:   clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem);
  --t-lead: clamp(1.0625rem, 1rem + 0.42vw, 1.25rem);
  --t-body: clamp(1rem, 0.97rem + 0.16vw, 1.0625rem);
  /* Floors at 12px, not 11px. The clamp minimum is what a phone actually
     gets, and 80 elements on the homepage were landing on it — every eyebrow,
     button label and spec caption at 11.1px. It is uppercase with generous
     tracking, which carries it, but 11px is the bottom of comfortable on a
     handset and this is the token 46 call sites read from. */
  --t-mono: clamp(0.75rem, 0.7rem + 0.16vw, 0.8125rem);

  /* -- structure ------------------------------------------------------- */
  /* Measured height of .site-head, which is sticky at top: 0 and has no
     height of its own to read. Used by the mobile series marker below so it
     pins under the bar rather than behind it. Re-measure if the header's
     padding or logo size changes. */
  --head-h: 85px;
  --rail: 7.5rem;             /* left spec-margin on desktop */
  --gut: clamp(1.5rem, 5vw, 5rem);
  --measure: 64ch;
  --max: 88rem;
  --pad-y: clamp(5.5rem, 11vw, 11rem);
  /* Geometry. The site used to be hard-edged everywhere (--r: 0, "machined,
     not rounded"). It is now soft: surfaces get a generous radius and every
     control is a full pill. Two tokens, because they are different jobs — a
     pill on a 400px-wide card looks like a lozenge, and a 22px radius on a
     button looks like a mistake. */
  --r:      0px;                /* cards, panels, media, form fields */
  --r-sm:   0px;                /* small chips and inline media */
  --r-pill: 0px;               /* every button, filter and icon control */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* -- elevation --------------------------------------------------------
     These used to be hard offset shadows with no blur — a flat cast edge, like
     a plate lifted off a drawing. That reads as a mistake under the rounded
     geometry: a pill with a hard 3px offset shadow looks misprinted rather
     than lifted.

     Each is now two shadows, not one — a tight, low-alpha contact shadow
     close to the object plus a soft, wider ambient one further out. A single
     blurred shadow reads as a flat grey smudge under the object; splitting it
     into "where it touches" and "the room light around it" is what makes a
     card read as a physical object resting on the page rather than a div with
     a drop-shadow filter. Cost is one extra shadow layer per token — same
     ~200 call sites, nothing added at the component level. */
  --lift:   none;
  --lift-2: 0 18px 40px -22px rgba(26, 26, 26, 0.45);
  /* These used to be black, for objects standing on a near-black ground.
     Nothing on the site has a dark ground now, so they match --lift. */
  --lift-d:   none;
  --lift-d-2: 0 18px 40px -22px rgba(26, 26, 26, 0.45);

  /* -- drafting grid ------------------------------------------------------
     The ink for every grid on the site: the 64px graph on the document ground
     and the one the hero draws. ONE token and one spacing on purpose, so the
     two surfaces cannot disagree about what a grid looks like here.

     0.07 is a measured ceiling, not a preference — see the long note beside
     the body background for the arithmetic. 0.08 puts the muted-text floor at
     4.60:1 and 0.09 at 4.51:1, so there is roughly one more step in this and
     then none.

     The ink is DARK rather than white, and that was tested rather than
     assumed. White is the safer direction for contrast — it only ever adds
     luminance under dark text, the same argument that lets .review carry its
     sweep — and a white grid at 0.35 is nearly twice as visible as this on
     the deepest ground while RAISING the worst pair to 6.38:1. It still
     loses, because most of this page is not the deepest ground: under a
     .light veil the ground is already rgb(225,240,243), where a white line
     moves it ~8/channel and disappears, and the hero rail is paler still. A
     dark line is the more uniformly visible of the two here. If the wash is
     ever deepened substantially, re-run that comparison — the answer flips.

     The --ripple / --ripple-2 pair that used to sit here is gone with the
     concentric ripple field it fed; nothing referenced it afterwards. */
  --grid-line: rgba(20, 24, 27, 0);
}

/* =========================== reset ====================================== */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important; scroll-behavior: auto !important;
  }
}
body {
  font-family: var(--body);
  font-size: var(--t-body);
  line-height: 1.65;
  color: var(--on-light);
  /* Matches the light ramp's midpoint, not --paper. The grounds are deeper
     than the flat paper tokens now, and those tokens still have to be the
     near-white fills for cards, fields and the NRC track — so the body
     cannot borrow one of them or every scroll gap, overscroll bounce and
     uncovered strip flashes lighter than the section above it. */
  /* THE OMBRE LIVES HERE, NOT ON THE SECTIONS. This is the fourth attempt at
     "I want a gradient / ombre effect" and the first three failed for the
     same structural reason: the gradient was on each SECTION, so it ran
     0.78 -> 0.60 and then RESET, nine times down the page. A wash that
     restarts every 800px is not an ombre, however saturated its stops are.

     Now one continuous wash spans the whole document — 0.78 at the top to
     0.51 at the bottom, travelling 190deg to 222deg of hue on the way. You
     are somewhere different in it on every screen, which is the whole point.

     THE BOTTOM STOP IS SOLVED, NOT CHOSEN, and it is #E7DECE. The binding
     case is muted copy on a .dark section, over the wash's deepest stop,
     with a grid line directly beneath it — three darkenings stacked. That
     composites to rgb(204,196,181), and --on-light-mute at #564E41 reads
     4.74:1 on it. Going one step deeper on either the stop or the mute puts
     the site under the 4.5 floor.

     Sections do not paint their own ground — they veil this one. See
     --ombre-light / --ombre-dark, which are translucent. */
  background-color: #F3F4F7;
  /* A SQUARE GRID over the wash, by request. This replaces the two concentric
     "sound ripple" sources that were here — a grid and a ripple field are both
     figure on the same ground, and running them together read as neither, so
     this is a swap rather than an addition. The ripples are one commit back
     (834ebc7) if that judgement gets reversed again.

     Deliberately the SAME grid the hero already draws — 64px, one tier, from
     --grid-line — rather than a second grid vocabulary with its own spacing
     and a major/minor tier. The site had one grid idiom; now two surfaces
     share it instead of disagreeing about what a grid looks like here.

     Scale is fixed in px and the wash still stretches, so the grid stays 64px
     however long the page is — the same property the ripples' 1400x1000
     tiling was buying with more machinery.

     The alpha is load-bearing, not taste. A grid line lands under body text
     wherever it crosses one, and it darkens the ground there. Worst case is
     muted copy on a .dark section over the wash's deepest stop, where the
     ink veil is only 0.085 and the line adds to it: 5.06:1 with no grid,
     4.74:1 with a line directly beneath the text at the current 0.055. The
     floor is close — do not raise this alpha without re-running the sweep.
     Cards, panels and fields paint opaque --paper-* fills on top, so only
     text sitting directly on a section ground is affected at all — which is
     leads, headings and the section copy, not anything in a card. */
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
    linear-gradient(180deg, #F3F4F7 0%, #F3F4F7 100%);
  background-size: 64px 64px, 64px 64px, 100% 100%;
  background-repeat: repeat, repeat, no-repeat;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}
img, svg, video { display: block; max-width: 100%; height: auto; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; }

/* Film grain.
   One fixed, composited layer of very fine noise over the whole viewport at
   three per cent. It is doing the same job a paper stock does in print: large
   flat fills stop looking like flat fills. Take it out and the dark sections
   immediately read as screen-flat again. Non-interactive, and it sits under
   the skip link so it can never intercept focus. */
body::after {
  content: '';
  position: fixed; inset: 0; z-index: 150;
  pointer-events: none; opacity: 0.032;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}
/* ============================== print ===================================
   Printing a product page — or saving it as a PDF, which is the same code
   path — is how anyone gets a spec sheet off this site. There is no separate
   PDF build, so this stylesheet IS the spec sheet.

   THE REVEALS HAVE TO BE SWITCHED OFF FIRST, and this is not cosmetic. Every
   `[data-anim]` element carries `animation-fill-mode: both`, and a `view()`
   timeline has no scrollport to resolve against on the print canvas, so it
   holds its from-state: `opacity: 0`, and for `frame` a `clip-path` that
   removes the contents outright. Printed as it stood, a product page came out
   largely blank below the first screen — the specs, the applications and the
   related products all present in the document and none of them inked. The
   `beforeprint` listener in motion.js covers the Firefox path only, because
   only that path has an `.in` class to add.

   What is dropped is site furniture, not content: the header and its nav, the
   breadcrumb, the floating buttons, the scroll progress bar, the CTA band, the
   related-products grid, and the footer — which is where "About" appeared in
   the PDF and the reason this block exists. What is kept is the page head, the
   photograph, the description, the applications and the spec tables.

   Grounds go flat white. The two ombres exist to separate sections on a
   screen; on paper they are ink, and a spec sheet is a document rather than a
   page of the site. */
@media print {
  body::after { display: none; }

  .site-head, .site-foot, .cta-band, .crumbs,
  .float, .skip, .progress, .pd-related { display: none !important; }

  [data-anim], [data-in] {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
  }

  body, .light, .dark, .page-head, section {
    background: #fff !important;
    color: #000 !important;
  }
  .page-head::before, .cta-band::before, .cta-band::after { display: none !important; }

  section, .page-head { padding-block: 0.6rem !important; }
  .wrap { max-width: none !important; padding-inline: 0 !important; }

  /* Keep a table on one sheet where it fits, and never orphan its heading. */
  .spec { break-inside: avoid; }
  h1, h2, h3 { break-after: avoid; }

  a { text-decoration: none; color: inherit; }
}

:focus-visible {
  outline: 2px solid var(--brand-deep);
  outline-offset: 3px;
  border-radius: 1px;
}
.dark :focus-visible { outline-color: var(--brand-lift); }

/* --brand-deep is the logo cyan; white on it is 2.65:1. */
::selection { background: var(--brand-deep); color: var(--on-light); }

.skip {
  position: absolute; left: 0; top: 0; z-index: 200;
  transform: translateY(-120%);
  background: var(--ink); color: var(--on-dark);
  padding: 0.75rem 1.25rem; font-family: var(--mono); font-size: var(--t-mono);
  text-decoration: none;
}
.skip:focus { transform: translateY(0); }

/* =========================== type =======================================
   One superfamily, three cuts: IBM Plex Sans carries the display and the
   body, IBM Plex Mono carries every label, spec figure and button. Plex was
   Bricolage Grotesque carries the display and Public Sans everything else.
   --mono is kept as a token name because 46 call sites depend on it, but it
   now resolves to the Public Sans stack: there is no typewriter face on this
   site. Numeric columns rely on font-variant-numeric: tabular-nums. Bricolage is the only one
   with a personality; the other two are deliberately quiet under it.

   The site holds it at one display weight (`--w-display`, 600) and moves
   hierarchy onto Bricolage Grotesque's `wdth` axis instead: large type runs
   wide, small labels run narrow. That is the one type decision here that is
   about acoustics rather than about taste — a long wavelength is a wide
   mark — and it is what replaces the tonal step the site gave up when the
   dark grounds went. Never reach for a bolder weight; reach for a width.

   `opsz` is set alongside it because Bricolage is optically sized: left on
   auto the large sizes keep the thicker joins and looser fit drawn for text,
   and the headings look soft. Track and optical size move together. */
h1, h2, h3, h4 {
  font-family: var(--display);
  font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h2), 'opsz' 40;
  line-height: 0.98;
  letter-spacing: -0.018em;
  text-wrap: balance;
}
h1 { font-variation-settings: 'wdth' var(--wd-hero), 'opsz' 96; letter-spacing: -0.026em; }
h3, h4 {
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 20;
  line-height: 1.12; letter-spacing: -0.008em;
}

p { text-wrap: pretty; }

/* An eyebrow used to be cyan; three or four of them per screen turned the
   accent into wallpaper. The word is now quiet text and the rule beside it
   carries a bright hairline instead — emphasis by lightness, not by hue. */
.eyebrow {
  font-family: var(--mono);
  font-size: var(--t-mono);
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--on-light-mute);
  display: flex; align-items: center; gap: 0.85rem;
}
/* `.rail-sec` (the pinned family rail) is a dark section too, but it paints
   its own ground in motion.css instead of carrying `.dark`, so it misses
   every override scoped to that class. Without naming it here the eyebrow
   and lead inside it keep the paper-ground muted tone and render dark-on-
   dark at 1.8:1 — the one place on the home page where copy really was
   invisible. Any future dark section must either carry `.dark` or be added
   to these two selectors. */
.dark .eyebrow, .rail-sec .eyebrow { color: var(--on-dark-mute); }
.eyebrow::before {
  content: ''; width: 2.25rem; height: 1px; flex: none;
  background: var(--mark-ink); opacity: 0.9;
}
.dark .eyebrow::before, .rail-sec .eyebrow::before { background: var(--mark); }

.lead {
  font-size: var(--t-lead);
  line-height: 1.62;
  color: var(--on-light-mute);
  max-width: var(--measure);
}
.dark .lead, .rail-sec .lead { color: var(--on-dark-mute); }

/* Body copy one step down from --on-light, for paragraphs that are secondary
   but are not a .lead. This exists because the same colour was written inline
   at nine call sites in src/pages.js, and an inline style beats every .dark
   override in this file — so seven of those nine, sitting in sections that
   alternate onto the .dark ground, would render dark-on-dark and vanish the
   moment .dark is given genuinely dark values. A class can be overridden; a
   style attribute cannot. Mirrors .lead above deliberately. */
.muted { color: var(--on-light-mute); }
.dark .muted, .rail-sec .muted { color: var(--on-dark-mute); }

/* spec value — figures read brighter than their labels, and that is the only
   emphasis they get */
.val {
  font-family: var(--mono);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--mark-ink);
  letter-spacing: -0.01em;
}
.dark .val { color: var(--mark); }

/* =========================== layout ===================================== */
.wrap {
  width: 100%;
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gut);
}

/* the spec margin: a hairline rule with a rotated label, like a drawing
   sheet edge. Carries the section's real name — never a running number. */
.railed { position: relative; }
@media (min-width: 62rem) {
  .railed { padding-left: var(--rail); }
  .railed::before {
    content: ''; position: absolute; left: calc(var(--rail) - 2.6rem); top: 0.35rem; bottom: 0;
    width: 1px; background: currentColor; opacity: 0.16;
  }
  .rail-label {
    position: absolute; left: calc(var(--rail) - 2.6rem); top: 0.35rem;
    transform-origin: 0 0; transform: rotate(90deg) translateX(0.1rem) translateY(-0.62rem);
    font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    white-space: nowrap; opacity: 0.5;
  }
}
@media (max-width: 61.999rem) {
  .rail-label { display: none; }

  /* ...except on the product series, where it becomes the one thing telling
     you which series you are looking at.

     The catalogue IS grouped -- five sections, one per series -- but below
     62rem the only marker was each section's <h2>, which scrolls away and
     leaves you in an unlabelled run of cards. The rotated rail label already
     carries the series name in the markup and was simply switched off here,
     so it gets shown instead of adding anything: full-bleed, pinned, and
     replaced by the next series as you pass into it. Sticky is confined to
     its own .wrap, which spans exactly one series, so the handover happens
     at the section boundary for free.

     Scoped to .railed[id] because only the five product series carry an id.
     The section labels on the other pages (Rooms, Method, Work, Clients)
     stay hidden -- pinning those would be noise, not orientation. */
  .railed[id] > .wrap > .rail-label {
    display: block;
    position: sticky;
    top: var(--head-h);
    z-index: 6;
    margin: 0 calc(var(--gut) * -1) 1.75rem;
    padding: 0.6rem var(--gut);
    background: rgba(247, 249, 250, 0.92);
    backdrop-filter: blur(12px) saturate(1.4);
    -webkit-backdrop-filter: blur(12px) saturate(1.4);
    border-block: 1px solid rgba(16, 23, 27, 0.08);
    font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    color: var(--on-light); opacity: 1;
  }
  /* motion.js retracts the header on scroll-down; follow it up so the marker
     never floats with a dead band above it. */
  html.head-up .railed[id] > .wrap > .rail-label { top: 0; }

  /* Same forward-compatibility as .fampick above: the series sections
     alternate, so this marker sits on a .dark ground every other time. The
     hardcoded light tint is the light ramp's top stop, which would read as a
     pale band if .dark ever became genuinely dark. --ink is that ramp's
     equivalent and is currently the same colour, so this is a no-op today. */
  .dark.railed[id] > .wrap > .rail-label {
    background: var(--ink);
    color: var(--on-dark);
    border-block-color: var(--ink-edge);
  }
}

section { padding-block: var(--pad-y); }
.dark { background: var(--ombre-dark); color: var(--on-dark); }
.dark h1, .dark h2, .dark h3 { color: var(--on-dark); }

/* Every light section gets the same ombre the hero/CTA band use: a timber-
   slat texture at 50% strength, glazed by a left-to-right paper fade so it
   reads as a lit material sample bleeding in from the edge, not a pattern.
   Cards and panels sit on their own opaque fill on top, so this only shows
   in the gaps between them. */
/* Every light section used to layer a timber-slat repeating-gradient under a
   paper glaze, so the ribbing surfaced in the outer margin. That was another
   drawn panel texture — the same one, on every section of every page — and it
   went with the rest of them. The ground is the ombre alone now.

   Removing it also retired a long-standing wart in the contrast check: the
   glaze and the slat wash together made the checker pair the darkest slat stop
   with the most transparent glaze stop, a combination that only ever existed
   in the outer margin where no text sits, and it reported ~43 phantom failures
   against a background (#878584) that never rendered. Sweeps no longer need
   the `.light{background-image:var(--ombre-light)}` override. */
.light {
  /* transparent, not --paper: an opaque fill here would hide the page wash */
  background-color: transparent;
  background-image: var(--ombre-light);
}

.grid { display: grid; gap: clamp(1.5rem, 3vw, 2.5rem); }
.g2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); }
.g3 { grid-template-columns: repeat(auto-fill, minmax(min(100%, 17.5rem), 1fr)); }
.g4 { grid-template-columns: repeat(auto-fill, minmax(min(100%, 13.5rem), 1fr)); }

.stack   { display: flex; flex-direction: column; }
.stack-s { gap: 0.75rem; } .stack-m { gap: 1.5rem; } .stack-l { gap: 2.5rem; }
.row { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }

.section-head {
  display: grid; gap: clamp(1rem, 2.5vw, 2rem);
  grid-template-columns: 1fr; align-items: end;
  margin-bottom: clamp(2.5rem, 5vw, 4rem);
}
@media (min-width: 56rem) {
  .section-head.split { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
}
.section-head h2 { font-size: var(--t-h2); margin-top: 0.85rem; }

/* Three head shapes, because five sections opening with the same
   eyebrow-left / lead-right split read as a template even when the bodies
   below them differ. .split is the default pair; .stack runs the whole head
   down the left at full measure; .mid centres it and is used once, on Voices,
   where the content below is full-width quote rows. */
/* ------------------------------ the founder -----------------------------
   A statement block, not a section head. See the note above the HOME block in
   src/pages.js for why this exists: the split head bottom-aligns its two
   columns, so a short name beside three long paragraphs left a screen-high
   void and pinned the heading to the floor of it.

   The grid is two columns with the head and the opening paragraph spanning
   both. Everything after the first paragraph flows into the pair, so the
   shape is driven by how much the client wrote rather than by a count baked
   in here — one paragraph fills the lead slot and the columns collapse to
   nothing; five fills both columns evenly. */
.founder {
  display: grid;
  gap: clamp(1.5rem, 3vw, 2.5rem) clamp(2rem, 5vw, 4.5rem);
  grid-template-columns: 1fr;
}
@media (min-width: 56rem) {
  .founder { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .founder-head, .founder-lead { grid-column: 1 / -1; }
}
.founder-head { margin-bottom: clamp(0.5rem, 1.5vw, 1rem); }
.founder-head h2 { font-size: var(--t-h2); margin-top: 0.85rem; }
/* Wider than `.lead`'s default measure because it spans both columns — at
   46ch it would have sat as a short stub over a full-width rule. 74ch is
   long for body copy and right for one opening paragraph at lead size. */
.founder-lead { max-width: 74ch; }
.founder > .muted { max-width: var(--measure); margin: 0; }

.section-head.stack { grid-template-columns: 1fr; gap: 0; }
.section-head.stack .lead { margin-top: 1.25rem; max-width: 52ch; }
.section-head.mid {
  grid-template-columns: 1fr; justify-items: center; text-align: center; gap: 0;
}
.section-head.mid h2 { max-width: 22ch; }
.section-head.mid .eyebrow::before { display: none; }

/* The diptych is full-bleed inside its section, so the section supplies the
   head and then lets the panels run edge to edge under it. */
.dip-sec { padding-bottom: 0; }
.dip-sec .wrap { padding-bottom: clamp(3rem, 6vw, 5rem); }

/* =========================== SIGNATURE ==================================
   Acoustic surfaces, drawn in CSS. Every image on this site is the surface of
   a product — there is no stock photography anywhere. Each family keeps its
   own temperature, so a page of 19 cards reads as a materials library rather
   than a colour chart:
     PET felt  → warm neutral grey      foam  → cold near-black
     timber    → warm brown             proofing → cold graphite
   Only the printed panel carries saturated colour, because printing is the
   one product whose whole point is that it does.
   ------------------------------------------------------------------------ */

.surface {
  position: relative;
  overflow: hidden;
  background: var(--ink-panel);
  isolation: isolate;
}

/* A PHOTO surface is not a material specimen. The drawn swatches are the
   deliberate exception to the site hue and several of them are near-black —
   which is right on a product card, where the swatch IS the product, and
   wrong in the project gallery and the choreography, where it is only what
   you see until the photograph loads. On a light-only site that placeholder
   printed big black rectangles down the page on every slow load.
   `.s-plate` is the light stand-in for those. Photos still sit at z-index 4,
   above this, so nothing changes once they arrive. */
.s-plate { background: var(--paper-sub); }
.s-plate::before {
  content: ''; position: absolute; inset: 0; z-index: 1;
  background:
    repeating-linear-gradient(90deg,
      rgba(16, 23, 27, 0.05) 0 1px, transparent 1px 14px),
    linear-gradient(150deg, var(--paper-hi), var(--paper) 58%, var(--paper-lo));
}
/* Light source. A tighter, brighter key that follows the pointer, plus a fixed
   vignette underneath it — a panel photographed under one lamp rather than an
   evenly lit swatch. The vignette is what stops a wide, shallow surface from
   flattening into grey at card size. */
.surface::after {
  content: '';
  position: absolute; inset: -20%;
  background:
    radial-gradient(38% 52% at var(--lx, 32%) var(--ly, 16%),
      rgba(255, 255, 255, 0.20), rgba(255, 255, 255, 0.05) 44%, transparent 70%),
    radial-gradient(120% 108% at 50% 32%,
      transparent 44%, rgba(6, 10, 13, 0.30) 82%, rgba(6, 10, 13, 0.5));
  pointer-events: none; z-index: 2;
  transition: background 220ms linear;
}
.surface > * { position: relative; z-index: 3; }
.surface::before { content: ''; position: absolute; inset: 0; z-index: 1; }

/* -- the drawn material swatches ---------------------------------------
   Restored from HEAD~1 after a pass removed them. They are NOT decoration:
   `.surface > img` sits above them, so the drawn material is what shows
   while a photograph loads and what stays if one ever 404s. Every product
   family has its own, which is what makes a page of nineteen cards read as
   a materials library rather than a grid of failed images.

   They are the one deliberate exception to the site's colour rule and keep
   their own temperatures — felt warm neutral, timber warm brown, wood wool
   straw, foam cold near-black, proofing cold graphite. Do not pull them onto
   the accent hue: a tinted foam swatch stops looking like foam. */
/* -- PET felt: a fine woven tooth, warm neutral -------------------------- */
.s-felt::before {
  background:
    repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 4px),
    repeating-linear-gradient(0deg,  rgba(0,0,0,0.26) 0 1px, transparent 1px 4px),
    radial-gradient(circle at 30% 25%, #3D3D3C, #272625 60%, #1C1B1A);
}
/* -- felt tiles: the same board, with the module joints showing ---------- */
.s-tile::before {
  background:
    linear-gradient(90deg, rgba(0,0,0,0.55) 0 2px, transparent 2px) 0 0 / 25% 100%,
    linear-gradient(0deg,  rgba(0,0,0,0.55) 0 2px, transparent 2px) 0 0 / 100% 33.33%,
    repeating-linear-gradient(90deg, rgba(255,255,255,0.045) 0 1px, transparent 1px 4px),
    repeating-linear-gradient(0deg,  rgba(0,0,0,0.24) 0 1px, transparent 1px 4px),
    radial-gradient(circle at 62% 30%, #3B3B3A, #252423 62%, #1A1918);
}
/* -- desk screen: felt board inside an aluminium edge -------------------- */
.s-screen::before {
  background:
    linear-gradient(90deg, #646362 0 4px, transparent 4px calc(100% - 4px), #646362 calc(100% - 4px)),
    linear-gradient(0deg,  #646362 0 4px, transparent 4px calc(100% - 4px), #51504E calc(100% - 4px)),
    repeating-linear-gradient(90deg, rgba(255,255,255,0.045) 0 1px, transparent 1px 4px),
    repeating-linear-gradient(0deg,  rgba(0,0,0,0.26) 0 1px, transparent 1px 4px),
    radial-gradient(circle at 40% 30%, #3B3B3A, #252423 62%, #1A1A1A);
}
/* -- micro-perforation: a precise sub-millimetre dot grid ---------------- */
.s-perf::before {
  background:
    radial-gradient(circle at 50% 50%, #06060600 1.3px, #080808 1.5px, #08080800 2.8px) 0 0 / 11px 11px,
    radial-gradient(circle at 50% 50%, #06060600 1.3px, #080808 1.5px, #08080800 2.8px) 5.5px 5.5px / 11px 11px,
    linear-gradient(158deg, #353433, #1F1F1E 58%, #151514);
}
/* -- CNC grooves: V-cut lines with a lit face and a shadow face ---------- */
.s-groove::before {
  background:
    repeating-linear-gradient(90deg,
      #131313 0px, #131313 2px,
      #5E5D5B 3px, #403F3D 13px,
      #232322 21px, #131313 23px);
}
/* -- 3D embossed: offset raised blocks catching light on one corner ------ */
.s-emboss::before {
  background:
    linear-gradient(135deg, rgba(255,255,255,0.15) 0 45%, transparent 45%) 0 0 / 26px 26px,
    linear-gradient(-45deg, rgba(0,0,0,0.52) 0 45%, transparent 45%) 13px 13px / 26px 26px,
    linear-gradient(160deg, #353433, #1D1D1C);
}
/* -- parametric: flowing contour ribs ------------------------------------ */
.s-param::before {
  background:
    repeating-radial-gradient(ellipse 150% 46% at 22% 122%,
      #0A0A0A 0 5px, #3A3937 6px 11px, #0A0A0A 12px 17px);
}
/* -- printed: the one surface whose job is to carry colour ---------------
   Held to darkened, desaturated versions of the palette's own hues. At full
   saturation this tile read as a rendering fault in a grid of eighteen
   monochrome ones — the point is "this panel takes print", not "look here". */
.s-print::before {
  background:
    radial-gradient(circle at 50% 50%, rgba(0,0,0,0.42) 1.2px, transparent 1.4px) 0 0 / 9px 9px,
    linear-gradient(118deg, #17627A 0 26%, #6E7276 26% 52%, #3C4044 52% 74%, #23252A 74%);
}

/* -- ceiling cloud: a flat board seen from below, edge lit --------------- */
.s-cloud::before {
  background:
    linear-gradient(0deg, rgba(0,0,0,0.6) 0 6%, transparent 6% 94%, rgba(255,240,214,0.14) 94%),
    repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0 1px, transparent 1px 4px),
    linear-gradient(168deg, #3C3B3A, #252423 65%, #181817);
}
/* -- baffles: fins on edge, deep shadow between ------------------------- */
.s-baffle::before {
  background:
    repeating-linear-gradient(90deg,
      #040404 0px, #040404 13px,
      #232322 15px, #55534F 27px, #232322 39px,
      #040404 41px, #040404 54px);
}

/* -- foam: colder and darker than felt; foam is charcoal ---------------- */
.s-wedge::before {
  background:
    repeating-linear-gradient(90deg, #070708 0px, #2E2E30 13px, #070708 26px),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.5) 0px, rgba(0,0,0,0) 14px, rgba(0,0,0,0.5) 28px);
}
.s-pyramid::before {
  background:
    repeating-linear-gradient(45deg,  #070708 0, #2F2F31 11px, #070708 22px),
    repeating-linear-gradient(-45deg, rgba(20, 17, 13,0.7) 0, rgba(100, 92, 80,0.22) 11px, rgba(20, 17, 13,0.7) 22px);
  background-blend-mode: overlay;
}

/* -- timber slats: warm brown, rounded ribs, shadow valley -------------- */
.s-slat::before {
  background:
    repeating-linear-gradient(90deg,
      #0C0C0B 0px, #0C0C0B 3px,
      #2A2A28 3px, #454340 9px,
      #6B6862 16px, #4E4C48 23px,
      #262624 28px, #0C0C0B 31px);
}
/* -- wood wool: pale straw fibre, the lightest surface in the set ------- */
.s-wool::before {
  background:
    repeating-linear-gradient(74deg,  rgba(221, 214, 201,0.26) 0 1px, transparent 1px 6px),
    repeating-linear-gradient(-64deg, rgba(206, 199, 186,0.20) 0 1px, transparent 1px 5px),
    repeating-linear-gradient(21deg,  rgba(136, 127, 113,0.28) 0 1px, transparent 1px 8px),
    linear-gradient(140deg, #5A5856, #393837 72%);
}

/* -- perforated timber: larger holes on a warm veneer ground ------------ */
.s-woodperf::before {
  background:
    radial-gradient(circle at 50% 50%, #08080800 3.1px, #080808 3.4px, #08080800 4.6px) 0 0 / 19px 19px,
    radial-gradient(circle at 50% 50%, #08080800 3.1px, #080808 3.4px, #08080800 4.6px) 9.5px 9.5px / 19px 19px,
    repeating-linear-gradient(96deg, rgba(107, 98, 85,0.16) 0 1px, transparent 1px 9px),
    linear-gradient(150deg, #494745, #2B2B2A 66%, #1E1E1D);
}
/* -- polyester batt: soft, low-density fibre, paler and cooler ---------- */
.s-batt::before {
  background:
    repeating-linear-gradient(82deg,  rgba(226,226,232,0.14) 0 2px, transparent 2px 9px),
    repeating-linear-gradient(-72deg, rgba(210,212,220,0.10) 0 2px, transparent 2px 7px),
    radial-gradient(ellipse 70% 50% at 30% 30%, #505152, #323232 62%, #242425);
}
/* -- acoustic glazing: offset laminated panes, edge-lit ----------------- */
.s-glass::before {
  background:
    linear-gradient(104deg, transparent 0 38%, rgba(198,204,208,0.16) 38% 46%, transparent 46%),
    linear-gradient(104deg, transparent 0 58%, rgba(198,204,208,0.10) 58% 63%, transparent 63%),
    linear-gradient(0deg, #464748 0 5%, transparent 5% 95%, #404142 95%),
    linear-gradient(150deg, #2C2D2E, #181919);
}

/* -- proofing: cold graphite, dense and near-flat ----------------------- */
.s-mlv::before {
  background:
    repeating-linear-gradient(112deg, rgba(255,255,255,0.03) 0 2px, transparent 2px 5px),
    linear-gradient(150deg, #2B2B2C, #141415);
}
.s-door::before {
  background:
    linear-gradient(90deg, #505051 0 8%, transparent 8% 92%, #414243 92%),
    linear-gradient(0deg,  #505051 0 6%, transparent 6% 94%, #383838 94%),
    linear-gradient(150deg, #2B2B2C, #171717);
}
/* The twenty drawn panel-type swatches that used to live here — .s-felt,
   .s-slat, .s-perf, .s-foam, .s-mlv, .s-wool and the rest, one per product
   family — have been removed. They were the site's most distinctive idea and
   also its most repeated one: the same ribbed texture stood behind every page
   head, the hero, the CTA band and every card, which is a lot of wallpaper for
   a page you are meant to read.

   What replaced them: decorative walls (.hero-wall, .page-wall, .cta-wall,
   .dip-surface) are gone outright, and every surface that actually carries a
   photograph now falls back to .s-plate, the neutral light placeholder defined
   above. Photographs still sit at z-index 4, so nothing changes once they load.

   They are recoverable from git history if the drawn-material direction ever
   comes back. Do not reintroduce them one at a time. */

/* -- photographs -------------------------------------------------------
   Real product and project photography sits on top of the drawn texture, so
   the texture is what shows while an image loads or if one ever 404s. Photos
   sit above the light wash (z-index 2) — a moving highlight belongs on a
   drawn surface, not on someone's photograph. */
.surface > img,
.surface > picture > img {
  position: absolute; inset: 0; z-index: 4;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center;
  display: block;
}
/* ================== THE SAMPLE SITS ON ITS MATERIAL =====================
   Twelve of the nineteen catalogue shots are cut out on pure white. The old
   treatment put a soft inset frame around that white rectangle so it did not
   punch a hole in the page — a patch on the symptom. This removes the white
   instead.

   `mix-blend-mode: multiply` is the whole trick: white multiplied by anything
   is that thing, so the backdrop shows straight through the cut-out's
   background and only the product survives. What it multiplies against has to
   be LIGHT, which is why `.is-cut` suppresses the drawn `.s-*` swatch — those
   are the real material temperatures and several are near-black, and
   multiplying a timber slat against a near-black slat drawing gives mud. The
   ground here is the same material colour taken to a 12% tint, so a wood card
   sits on warm paper and a foam card on cold, without either going dark.

   `object-fit: contain` plus padding is what makes it read as a specimen on a
   mount rather than a photograph cropped to a box.

   THERE IS NO DROP SHADOW ON THE SAMPLE, and two separate reasons say there
   cannot be. `filter` on a blended element puts it in its own compositing
   group, and the blend then has nothing to multiply against — measured, not
   guessed: three cards side by side with filter on, filter off and parallax
   toggled, and only the one without a filter dropped its white. And it would
   not have worked anyway: these shots are opaque JPEG-style rasters with no
   alpha channel, so `drop-shadow` traces the image's RECTANGLE rather than
   the product's silhouette — a hard-edged box shadow around a shape that is
   no longer visible. The lit-mount feel comes from `.surface::after`, the
   vignette the component already had.

   ONLY CUT-OUTS. `is-cut` comes from `PRODUCTS[].cut` in content.js, which is
   measured off the asset — see the note there, and recompute it when a card
   image changes. The seven room photographs keep `object-fit: cover` and the
   drawn swatch behind them, because there is no white to drop and multiply
   would only darken someone's finished room.

   The selector is `.surface`, not `.card-surface`, so the product detail
   page's `.pd-hero` gets the same treatment. That is measured too: the
   `-hero.webp` assets classify identically to the `-card.webp` ones, the
   same twelve, so one flag drives both.

   The isolation is deliberate: `.surface` already sets `isolation: isolate`,
   so the blend is confined to the card and can never reach the page ground. */
.surface.is-cut {
  /* The literal is the fallback for a browser without color-mix; it is the
     PET tint, the commonest of the five, so a fallback card is plausible
     rather than obviously wrong. 20% is where the mount starts reading as a
     colour rather than as an off-white — 12% was tried and disappeared. */
  background-color: #E4E7E9;
  /* Mixed into --paper-hi, not pure white. The five material colours are the
     real material temperatures and do not move, but the thing they are mixed
     INTO is the site's own card colour — on a warm palette a mount mixed into
     #FFFFFF reads cold against the card it sits in. */
  background-color: color-mix(in oklab, var(--mat, #7C8380) 20%, var(--paper-hi));
  display: flex; align-items: center; justify-content: center;
  padding: clamp(0.9rem, 3vw, 1.6rem);
}
/* The drawn swatch is the thing being replaced here — without this it would
   paint over the tint and the sample would multiply against a near-black
   ground, which is mud. */
.surface.is-cut::before { display: none; }
.surface.is-cut > img {
  /* `relative`, not `static`: the image has to keep its place in the flex
     centring AND sit above `.surface::after`, the light wash at z-index 2.
     Left static it painted below the wash and the sample came out hazed. */
  position: relative; z-index: 3; inset: auto;
  width: 100%; height: 100%;
  object-fit: contain;
  mix-blend-mode: multiply;
}
/* The inset frame below exists for a white rectangle that is no longer there,
   and over a multiplied sample it reads as a smudge along the bottom edge. */
.dark .surface.is-cut::after { display: none; }

/* Many of their catalogue shots are cut out on white. On the dark ground a
   raw white rectangle punches a hole in the page, so those that are NOT
   handled by `.is-cut` above — the seven room photographs — get a soft inset
   frame rather than sitting flush. */
.dark .card-surface::after,
.dark .gal-surface::after {
  content: ''; position: absolute; inset: 0; z-index: 5; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255,0.10),
              inset 0 -40px 48px -32px rgba(9,7,5,0.55);
}
/* Descendant, not direct child: .gal-zoom now sits between .gal-item and
   .gal-surface. As `>` this silently stopped matching. */
.gal-item:hover .gal-surface > img { transform: scale(1.035); }
.gal-surface > img { transition: transform 240ms var(--ease); }

/* product detail: featured shot + supporting gallery */
.pd-hero { position: relative; overflow: hidden; }
.pd-gallery {
  display: grid; gap: 0.75rem; margin-top: 0.75rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
}
.pd-shot {
  position: relative; aspect-ratio: 4 / 3; overflow: hidden;
  border-radius: var(--r); border: 1px solid var(--paper-edge);
  background: var(--ink-panel);
}
.pd-shot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

/* =========================== header ===================================== */
.site-head {
  position: sticky; top: 0; z-index: 100;
  /* the same move as the hero scrim: the bar was near-white above a page
     that is not. Tinted to the ground's top stop, alpha unchanged. */
  background: rgba(247, 249, 250, 0.86);
  backdrop-filter: blur(20px) saturate(1.4);
  -webkit-backdrop-filter: blur(20px) saturate(1.4);
  border-bottom: 1px solid rgba(16, 23, 27, 0.08);
  color: var(--on-light);
}
.site-head .wrap {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1.5rem; min-height: 5.75rem;
}
.brand {
  display: inline-flex; align-items: center; gap: 0.7rem;
  text-decoration: none; flex: none;
}
/* The logo is the client's own asset — never restyled, only scaled. Height is
   fixed and width follows, so the lockup can never distort.

   The header carries the FULL lockup now, tagline and all, which is what the
   live site does. That sets the floor on this height: at 2rem the tagline
   ("Innovating Sound In A Better Way", roughly 1/7 of the artwork's height)
   rendered at under 5px and read as a grey smear. 2.75rem puts it near 6.5px
   — still small, but drawn rather than mushed, and it is the same call the
   client's own header makes. `.site-head .wrap` min-height went 5.25rem to
   5.75rem to keep the bar's breathing room around it. Below 30rem it scales
   to 2.1rem so the lockup can share the bar with the menu button; the tagline
   is decorative at that size, which is acceptable because the company name
   above it is still perfectly legible and the <img> alt carries both. */
.brand-logo { height: 2.75rem; width: auto; flex: none; }
.brand-logo-full { height: 3.25rem; width: auto; flex: none; }
@media (max-width: 30rem) { .brand-logo { height: 2.1rem; } }
.brand:hover .brand-logo, .brand:hover .brand-logo-full { opacity: 0.85; }
.brand-logo, .brand-logo-full { transition: opacity 200ms var(--ease); }

.nav { display: flex; align-items: center; gap: clamp(1rem, 2.2vw, 2.25rem); }
.nav a {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.2em; text-transform: uppercase;
  text-decoration: none; color: var(--on-light-mute);
  padding-block: 0.45rem; position: relative;
  transition: color 220ms var(--ease);
}
.nav a::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: var(--mark-ink); transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 320ms var(--ease);
}
.nav a:hover, .nav a[aria-current="page"] { color: var(--on-light); }
.nav a:hover::after, .nav a[aria-current="page"]::after { transform: scaleX(1); }
/* `.nav a` (0,1,1) outranks `.btn-primary` (0,1,0), so the CTA in the nav needs
   its colour restating or the label inherits the muted link grey. It restates
   `--on-light`, NOT white: the fill is the logo cyan and white on it is
   2.65:1. This line held `#FFFFFF` while the fill was a dark blue, and left
   as it was it would have put the one unreadable pair on the site on the most
   prominent button. */
.nav a.btn-primary, .nav a.btn-primary:hover { color: var(--on-light); }
.nav a.btn::after { content: none; }

.nav-toggle {
  display: none; background: none; border: 1px solid rgba(16, 23, 27, 0.2);
  /* 2.75rem, not 2.6rem. At 2.6 it rendered 42px — two pixels under the 44px
     both platform guidelines ask for, on the one control every mobile visitor
     has to hit before they can go anywhere. The drawn circle is unchanged to
     the eye; it is the hit area that moved. */
  color: var(--on-light); width: 2.75rem; height: 2.75rem; border-radius: 50%;
  cursor: pointer; align-items: center; justify-content: center; flex: none;
}
.nav-toggle-bars { display: block; width: 1.0625rem; height: 9px; position: relative; }
.nav-toggle-bars::before, .nav-toggle-bars::after {
  content: ''; position: absolute; left: 0; right: 0; height: 1.5px;
  background: currentColor; transition: transform 260ms var(--ease), opacity 160ms;
}
.nav-toggle-bars::before { top: 0; } .nav-toggle-bars::after { bottom: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle-bars::before { transform: translateY(3.75px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle-bars::after  { transform: translateY(-3.75px) rotate(-45deg); }

@media (max-width: 60rem) {
  .nav-toggle { display: inline-flex; }
  .nav {
    position: fixed; inset: 5.25rem 0 auto 0;
    background: var(--paper); border-bottom: 1px solid rgba(16, 23, 27, 0.1);
    flex-direction: column; align-items: stretch; gap: 0;
    padding: 0.5rem var(--gut) 1.5rem;
    transform: translateY(-8px); opacity: 0; visibility: hidden;
    transition: opacity 200ms var(--ease), transform 240ms var(--ease), visibility 200ms;
    max-height: calc(100dvh - 5.25rem); overflow-y: auto;
  }
  /* THE PANEL OPENS OFF THE BUTTON'S OWN ARIA STATE, not off a class the
     script has to remember to set on a second element.

     It used to key on `.nav[data-open="true"]` alone, and the menu was dead
     on every phone: tapping the control flipped the button to an X and
     nothing appeared. Verified with a MutationObserver — the click produced
     exactly one attribute change, `aria-expanded` on the button, and none at
     all on the nav, so the panel never left opacity 0 / visibility hidden.
     A fresh listener could set `data-open` on that same node perfectly well,
     so the DOM was fine; the second write simply was not landing.

     Keying on the button removes the dependency entirely. `aria-expanded` is
     the state the control already publishes for assistive tech, the nav is
     its next sibling in `.wrap`, and one attribute now drives both the icon
     and the panel — they cannot disagree. The data-open rule is kept so the
     script's write still works if it starts landing again. */
  .nav-toggle[aria-expanded="true"] ~ .nav,
  .nav[data-open="true"] { transform: none; opacity: 1; visibility: visible; }
  .nav a { padding-block: 0.95rem; border-bottom: 1px solid rgba(16, 23, 27, 0.08); font-size: 0.8125rem; }
  .nav a::after { display: none; }
  .nav .btn { margin-top: 1.1rem; justify-content: center; }
}

/* =========================== buttons ==================================== */
/* Wider, quieter, more letterspaced than before, and the ghost variant fills
   from the left on hover instead of just recolouring its outline. */
.btn {
  display: inline-flex; align-items: center; gap: 0.7rem;
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  padding: 1.05rem 1.9rem; border-radius: var(--r-pill);
  text-decoration: none; cursor: pointer; border: 1px solid transparent;
  position: relative; overflow: hidden; isolation: isolate;
  transition: background 260ms var(--ease), color 260ms var(--ease),
              border-color 260ms var(--ease), transform 260ms var(--ease),
              box-shadow 260ms var(--ease);
}
/* THE PRESS IS THE POINT OF AN OFFSET BLOCK. Hover lifts the block up and
   left, away from its shadow, so the gap widens; pressing drops it the whole
   way INTO the shadow — the block lands on the 4px offset and the shadow goes
   to nothing, which is what a physical key does. A blur-based shadow cannot
   do this and a `translateY` on its own only reads as a float.

   The press is deliberately faster than the release (90ms down, 260ms back):
   a control that takes as long to depress as it does to return feels
   mushy. */
.btn:hover { transform: translate3d(-2px, -2px, 0); }
.btn:active {
  transform: translate3d(4px, 4px, 0);
  box-shadow: 0 0 0 var(--on-light);
  transition-duration: 90ms;
}
.btn-arrow { transition: transform 260ms var(--ease); }
.btn:hover .btn-arrow { transform: translateX(4px); }

/* THE PRIMARY BUTTON IS THE LOGO'S OWN CYAN, and its label is DARK. That is
   not a style choice — it is the only way the raw logo colour can be a
   button at all. `--brand-deep` is `#1CABDE`, the untouched cyan from the
   client's asset; white on it measures 2.65:1 and fails outright, while
   `--on-light` on it reads 6.74:1. Every other filled control on this site
   carries white, so this one is the exception and it has to stay one: if the
   fill is ever darkened for a white label, it stops being the logo's colour
   and the reason for using it goes with it.

   The rule is on the button too — with a hard offset shadow and no radius,
   an unbordered fill reads as a sticker rather than a block. */
.btn-primary {
  background: var(--brand-deep); color: var(--on-light);
  border-color: var(--on-light); box-shadow: var(--lift);
}
.btn-primary:hover { background: var(--brand-mark); box-shadow: var(--lift-2); }

/* Light is the default ground now — most of the site is paper, not ink — so
   the ghost button defaults to that reading and `.dark`/`.on-dark` is the
   exception, for the handful of contexts still on the material-photo ground
   (diptych, sector tiles, the family rail). */
.btn-ghost { border-color: var(--on-light); color: var(--on-light); }
.btn-ghost::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: var(--on-light);
  transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 420ms var(--ease);
}
.btn-ghost:hover { border-color: var(--on-light); color: var(--paper); }
.btn-ghost:hover::before { transform: scaleX(1); }
.dark .btn-ghost, .btn-ghost.on-dark { border-color: rgba(16, 23, 27, 0.276); color: var(--on-dark); }
.dark .btn-ghost::before, .btn-ghost.on-dark::before { background: var(--on-dark); }
.dark .btn-ghost:hover, .btn-ghost.on-dark:hover { border-color: var(--on-dark); color: var(--paper-hi); }

/* text link with a rule that draws in */
.tlink {
  display: inline-flex; align-items: center; gap: 0.5rem;
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
  text-decoration: none; color: var(--brand-ink);
  padding-bottom: 0.3rem; position: relative;
}
.dark .tlink { color: var(--brand-ink); }
.tlink::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: currentColor; opacity: 0.35;
  transform: scaleX(0.24); transform-origin: 0 50%;
  transition: transform 300ms var(--ease), opacity 300ms;
}
.tlink:hover::after { transform: scaleX(1); opacity: 1; }
.tlink:hover .btn-arrow { transform: translateX(3px); }

/* =========================== hero =======================================
   A full-bleed banner photograph of a finished room. This replaced the drawn
   slat band, which went when the panel-type textures came out.

   THE SCRIM NO LONGER GUARANTEES ANY PHOTOGRAPH. READ THIS BEFORE SWAPPING
   THE BANNER. It used to: a white scrim whose effective alpha never dropped
   below 0.643 across the type column, which held ~7:1 over even a pure-black
   image. That was deliberately given up, by request, because it was the
   single reason the landing page still read white after the grounds were
   deepened — at 0.82 alpha the wash, not the picture, was what you saw.

   Two changes. The scrim is the page's own light rgb(247,249,250) rather than
   #FFFFFF, and its alphas are roughly two thirds of what they were: 0.60 /
   0.56 / 0.50 held across the type column, then falling to 0 by the right
   edge so the photograph carries that half outright.

   What that costs, stated plainly: the effective floor across the type column
   is now ~0.53, so over the worst case a photograph can present — pure black
   — the title would sit near 3.6:1 and the lead would be marginal. The
   banner's contrast is therefore a property of THIS photograph, not of the
   scrim. Measured against the real composite by replicating both gradients on
   a canvas over the actual image and sampling inside the eyebrow, h1 and lead
   boxes: text 5.34:1 (eyebrow 9.06, h1 5.34, lead 5.70) and mute 3.24:1.

   So: if you change assets/img/projects/adani-bkc-mumbai.webp for anything
   else, re-run that canvas measurement. Do not assume it holds, in either
   direction — a first attempt at this dropped the alphas evenly across the
   whole frame and the h1 and lead fell to 4.01 and 3.98 against this same
   photograph, because the text column reaches the 50% mark exactly where the
   falloff was steepest and the desk behind it is dark.

   The shape matters more than the strength. Coverage is held flat to 55%,
   which is where the type ends, and only then collapses; the vertical
   gradient is weighted to the foot because both failures were at the bottom
   of their blocks. That is why a pale banner still works here: the coverage
   is spent on the left 55% and nowhere else. A darker image would also be
   fine. A paler one would not.

   The one muted tone in the banner is --mark-banner, on the italic .decay in
   the headline and on the eyebrow's rule. At 3.24:1 it now clears the
   LARGE-TEXT threshold only, which .decay satisfies at hero size. Do not use
   --mark-banner on anything body-sized in the banner; there is no headroom
   left for it, and darkening it far enough to earn 4.5 would collapse the
   tonal step between "place." and the words either side of it. Measured against the real composite
   rather than against the worst case, the banner's body text is 8.99:1 and
   the mute 4.75:1 — so the mute clears AA at body size, not merely at the
   display size it happens to be used at. Hierarchy is otherwise size and
   measure, as everywhere else on the site.

   The photography is 760x570 — see README. At 1440 that is a ~1.9x upscale.
   The scrim and the viewport grain hide much of it, but higher-resolution
   originals are the real fix and this is the one place it shows. */
.hero {
  position: relative; color: var(--on-light);
  padding-block: 0 clamp(2.5rem, 5vw, 4rem);
  overflow: hidden;
  /* the rail sits on the same fine graph every other paper section gets from
     body — its own opaque ombre would otherwise paint over that grid and read
     as a flat, ungrained white the rest of the site doesn't have. */
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
    var(--ombre-light);
  background-size: 64px 64px, 64px 64px, auto;
}
.hero-top {
  position: relative; overflow: hidden;
  min-height: min(84vh, 920px);
  display: flex; align-items: flex-end;
  padding-block: clamp(7rem, 14vw, 11rem) clamp(3rem, 6vw, 5rem);
  color: var(--on-banner);
}
.hero-media { position: absolute; inset: 0; z-index: 0; background: var(--paper); }
.hero-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Three banner photographs, cross-faded. The slide wrapper carries the fade so
   that `.hero-media img` keeps `mWall` to itself — one element, one animation,
   rather than a two-entry animation list with two different timelines.

   Slides 2 and 3 are hidden HERE, in the stylesheet that always loads, so the
   default state of this component is a single static banner. The cross-fade in
   motion.css only ever turns that into three; if it never runs — reduced
   motion, an old browser, a stylesheet that failed — the hero is exactly what
   it was before, never three photographs stacked on top of each other.

   --paper sits behind them because an opacity cross-fade does not keep total
   coverage at 1: at the midpoint the two slides cover 75% and the ground shows
   through the rest. A pale ground only ever lightens what is under the dark
   banner type, so the transition cannot dip below the contrast either
   photograph is measured at on its own. */
.hero-slide { position: absolute; inset: 0; }
.hero-slide + .hero-slide { opacity: 0; }

/* Manual mode. The moment the reader uses an arrow, JS takes the wheel: it
   adds .is-manual and marks one slide .is-on, and the auto cross-fade stops
   for good. The two-class selector outranks the `.hero-slide` animation in
   motion.css on specificity, so it wins regardless of which sheet loads
   first — do not "simplify" it to a single class.

   This path is deliberately OUTSIDE the reduced-motion gate. Reduce Motion
   asks for nothing to move on its own; it does not ask for buttons to stop
   working. A cross-fade the reader asked for by clicking is opacity only,
   which is the same thing the `iomin` tier already allows. */
.hero-media.is-manual .hero-slide {
  animation: none;
  opacity: 0;
  transition: opacity 420ms var(--ease);
}
.hero-media.is-manual .hero-slide.is-on { opacity: 1; }

/* THE HEADLINE FOLLOWS THE PHOTOGRAPH, and it follows it by the same two
   mechanisms in the same order: hidden by default here, cross-faded by
   motion.css, overridden by .is-manual when the reader takes an arrow.

   `.hero-say` is the copy block's exact counterpart to `.hero-slide`, with
   one difference that matters. The default hidden state is keyed to
   `:not(.is-on)` rather than to `+ .hero-say`, because the copy blocks are
   grid-stacked in one cell and JS moves `.is-on` between them — sibling
   order stops describing which one is showing the moment an arrow is
   pressed. The markup ships `.is-on` on the first block, so with no CSS
   animation and no JS at all the hero is one headline over one photograph,
   which is the same fail-safe the slides have. */
.hero-say:not(.is-on) { opacity: 0; }
.is-manual .hero-say {
  animation: none;
  opacity: 0;
  transition: opacity 420ms var(--ease);
}
.is-manual .hero-say.is-on { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .hero-media.is-manual .hero-slide { transition-duration: 1ms; }
}

/* The arrows sit on the photograph, in the half where the scrim has already
   fallen to alpha 0 — so they cannot borrow its coverage the way the type
   does. They carry their own solid fill instead, which makes their contrast
   a property of two tokens (--brand on --paper-hi, 9.58:1) rather than of
   whichever photograph is on screen. Do not make them translucent. */
/* One arrow on each edge, vertically centred — the shape everybody already
   knows how to read.

   Two constraints it has to respect, both measured rather than guessed:

   1. NEVER PIN THIS TO THE BOTTOM OF THE HERO. .hero-top is a fixed 801px
      tall while a laptop viewport is commonly 700-780px, so a bottom-anchored
      control lands below the fold on the most common screen there is —
      present, keyboard reachable, and invisible to the person it exists for.
      That was the first version, and it measured 134px past the fold at
      1440x720. Vertical centre of the hero is ~400px, which is in view on
      anything 620px and taller.

   2. The left edge is shared with the type column, which starts 88px in at
      1440 and 23px in at 375. The inset is therefore deliberately small — the
      buttons hug the frame and stay outside the copy. Widen it to var(--gut)
      and the left arrow lands on the h1.

   Below 60rem the middle of the banner is solid type from the eyebrow to the
   lead, so there is no centred position that is not on top of a word. There
   the pair moves up into the empty band above the eyebrow, still one on each
   edge. The bar itself is pointer-events: none so it never swallows a click
   meant for the photograph or the copy behind it. */
.hero-nav {
  position: absolute; z-index: 5;
  left: 0; right: 0; top: clamp(1rem, 4vw, 1.5rem);
  /* Track the CONTENT column, not the viewport. --max is 88rem and every
     other block on the site is centred inside it, so `left: 0; right: 0`
     alone parks the arrows against the bare edges of the monitor — on a
     1920px screen that is 256px outside the layout on each side, reading as
     stranded at the far edges rather than as part of the banner. Matching
     --max and auto margins puts them on the edges of the design instead. */
  width: 100%; max-width: var(--max); margin-inline: auto;
  display: flex; justify-content: space-between; align-items: center;
  padding-inline: 0.75rem;
  pointer-events: none;
}
.hero-nav-btn { pointer-events: auto; }

/* Centred on the edges only where the left one actually clears the copy.
   The type column starts at --gut, which is 5vw for most of the range, while
   the button needs a fixed 44px plus its inset — so the clearance SHRINKS as
   the window narrows and goes negative around 1100px. Measured at the left
   arrow's right edge against the h1's left edge:
       1440px   32px clear
       1280px    8px clear
       1024px   -8px  <- overlapping "in its place."
   Hence 80rem. Below it the pair stays on the two edges but rides up into the
   empty band above the eyebrow, which is clear at every width. Lower this
   breakpoint and the left arrow lands on the headline. */
/* Bottom corners, one per edge, above 80rem. The left arrow does not fight
   the copy here even though it shares the CTA's row: the text column starts
   at 88px and the button ends at 72px, so it sits in the margin beside the
   buttons rather than on them. Below 80rem that margin is gone (--gut is 5vw
   and the column starts around 51px) and the pair goes back up to the clear
   band above the eyebrow.

   The min() is what stops this repeating the very first bug. .hero-top is a
   fixed 801px, so `bottom` alone puts the arrows at 745px down — off-screen
   on any window shorter than ~800px, which is most laptops. The second term
   pins them to the viewport instead whenever that is tighter, so they ride up
   and stay in view.

   THE 141px IS NOT ARBITRARY AND THE TWO TERMS DO NOT SHARE AN ORIGIN. `top`
   is measured from .hero-top, which begins 85px down the page under the
   sticky header, while 100vh is measured from the top of the window. Without
   the header in the sum the clamp is 85px too generous and puts the arrows
   just below the fold — which it did, at 830px in an 820px window. 141 =
   85 header + 44 button + 12 breathing room. */
@media (min-width: 80rem) {
  .hero-nav { top: min(calc(100% - 3.5rem), calc(100vh - 141px)); }
}
/* SOLID BRAND FILL, not a white pill. The first version was --paper-hi with a
   1px --paper-edge outline, which is a white circle faintly outlined — and the
   top of the banner is very often a white ceiling or a daylit window, so it
   disappeared into the photograph. It was on the page, measured, keyboard
   reachable and effectively invisible.

   A saturated fill makes the control a figure against any photograph instead
   of borrowing contrast from whatever happens to be behind it, and it matches
   .btn-primary, which is the site's existing "this is the thing you press".
   White on --brand-deep is 5.01:1. The shadow is there to separate it from a
   busy photograph, not for depth. */
.hero-nav-btn {
  width: 2.75rem; height: 2.75rem;      /* 44px — the touch-target floor */
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0; cursor: pointer;
  color: var(--on-light);          /* the fill is the logo cyan — see .btn-primary */
  background: var(--brand-deep);
  border: 1px solid transparent;
  border-radius: var(--r-pill, 999px);
  box-shadow: var(--lift);
  transition: background-color 180ms var(--ease), transform 180ms var(--ease);
}
.hero-nav-btn:hover { background: var(--brand-lift); }


/* visually hidden, still announced */
.vh {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; border: 0; overflow: hidden; white-space: nowrap;
  clip-path: inset(50%);
}
.hero-media::after {
  content: ''; position: absolute; inset: 0;
  /* THE SCRIM IS THE PAGE'S OWN LIGHT, NOT WHITE. rgb(247,249,250) is the top
     stop of --ombre-light written out as a literal, because a scrim cannot
     take a gradient token.

     It was pure #FFFFFF, and that was the single biggest reason the landing
     page still read white after the grounds were deepened: every section
     below the fold had come down to 0.875-0.596 luminance while the hero —
     the first full screen, and the largest single surface on the site — was
     still washing an already-pale photograph toward 1.000. The alphas are
     unchanged; only the colour moved, so the coverage and the geometry of the
     guarantee are exactly as they were.

     If the ground ramp is deepened again, this literal has to move with it or
     the hero drifts back to being the whitest thing on the page. */
  background:
    linear-gradient(102deg,
      rgba(247, 249, 250, 0.60) 0%, rgba(247, 249, 250, 0.56) 34%,
      rgba(247, 249, 250, 0.50) 55%, rgba(247, 249, 250, 0.12) 78%,
      rgba(247, 249, 250, 0.00) 100%),
    linear-gradient(180deg,
      rgba(247, 249, 250, 0.10) 0%, rgba(247, 249, 250, 0.06) 42%, rgba(247, 249, 250, 0.26) 100%);
}
/* THE SCRIM ABOVE IS SHAPED FOR THE DESKTOP TYPE COLUMN, WHICH ENDS AT ~52%.
   Below 64rem the copy runs the full width instead — the lead reaches ~91%
   across the banner, which is past the 78% stop where the 102deg gradient has
   already fallen to alpha 0.12 and heading for 0. The type there sits on the
   bare photograph.

   That was measured, not guessed: on the incumbent banner the lead's darkest
   composited pixel scored 3.80:1 at 375px, under the 4.5 floor, while the
   same text scores 5.63:1 at 1440px. It is the reason only 9 of the 170
   project photographs clear AA at mobile width.

   So the narrow layout gets a scrim shaped for a full-width column: the
   falloff is removed and the wash is held across the frame. It covers more of
   the photograph than the desktop scrim does, which is the price of the copy
   being full-bleed at this size.

   The alphas are tuned, not maximal. The first pass ran 0.74 -> 0.64 and put
   the lead at 8.67:1 — nearly double the floor, and it flattened the
   photograph to the point where the banner stopped reading as a room. These
   values clear AA with margin while leaving the picture legible.

   THE THREE BANNERS ARE THE CLIENT'S OWN, ported from their live site, and
   they were measured before they went in — darkest composited pixel under
   each word, both gradients replicated on a canvas over the real image, at
   both widths. Nothing in the scrim changed to accommodate them:

                          ----- 1440x900 -----   ----- 375x682 -----
                          eye    h1   lead  dec   eye    h1   lead  dec
     recording-studio    10.01 10.42  5.87 6.87  6.92  7.30  7.41 6.73
     office-conference    5.85  5.36  5.78 3.13  8.02  5.96  6.08 3.77
     auditorium           6.31  4.97  5.46 3.55  7.14  7.33  5.79 4.53

   Worst text pair 4.97:1 (auditorium h1, desktop), worst mute 3.13:1
   (office-conference .decay, desktop). The mute column is --mark-banner on
   .decay and is LARGE TEXT ONLY — 3:1 is its floor, not 4.5, and 3.13 clears
   it by very little. That is the tightest number on the site now. A darker
   banner, or shrinking .hero-title further, takes it under.

   The previous set measured, for reference, at 375x682 only:
       adani-bkc-mumbai    eye 13.70  h1 9.08  lead 5.82  decay 5.60
       atlas-pune          eye  8.62  h1 6.80  lead 6.29  decay 8.31
       meraki-arean        eye  6.86  h1 6.84  lead 7.00  decay 4.15
   Re-measure per word, not per element box, against the darkest pixel — see
   the note in motion.css. */
@media (max-width: 63.999rem) {
  .hero-media::after {
    background:
      linear-gradient(102deg,
        rgba(247, 249, 250, 0.62) 0%, rgba(247, 249, 250, 0.60) 45%,
        rgba(247, 249, 250, 0.56) 78%, rgba(247, 249, 250, 0.52) 100%),
      linear-gradient(180deg,
        rgba(247, 249, 250, 0.10) 0%, rgba(247, 249, 250, 0.06) 42%, rgba(247, 249, 250, 0.26) 100%);
  }
}
/* The banner is the ONE place on this light-only site with light text, because
   it is text on a photograph rather than on a ground. --on-dark is a
   near-black now, so it cannot be used here. Name every colour: a scrim no
   more makes inherited text light than a dark ground did. */
.hero-top .eyebrow,
.hero-top .lead,
.hero-top .hero-lead,
.hero-top h1 { color: var(--on-banner); }
.hero-title .decay { font-style: italic; color: var(--mark-banner); }
.hero-top .eyebrow::before { background: var(--mark-banner); }
/* a paper pill reads as the one solid object in the frame */
.hero .btn-primary { background: var(--brand-deep); color: var(--on-light); }
.hero .btn-primary:hover { background: var(--brand-mark); }
.hero .btn-ghost { border-color: rgba(15, 23, 42, 0.30); color: var(--on-banner); }
.hero .btn-ghost:hover { border-color: var(--on-banner); color: var(--paper-hi); }
.hero .btn-ghost::before { background: var(--on-banner); }

.hero .wrap { position: relative; z-index: 3; }
.hero-top > .wrap { width: 100%; }
@media (min-width: 64rem) {
  /* the type column stays inside the scrim's guaranteed half */
  .hero-top > .wrap { padding-right: calc(42% + var(--gut)); }
}

/* THE THREE HEADLINES ARE STACKED IN ONE GRID CELL, not absolutely
   positioned. That is the whole reason the lead and the buttons under them
   hold still while the banner turns: a grid track sized by its tallest child
   reserves the height of the longest headline once, at layout time, and the
   other two fade inside a box that was already that tall. Position: absolute
   would collapse the container to zero and drag the rest of the hero up.

   Only one of the three is visible at a time, so `align-items: start` keeps
   them all hanging from the same top edge rather than centring the short one
   against the tall one's box. */
.hero-copy { display: grid; align-items: start; }
.hero-say { grid-area: 1 / 1; }

/* THE CLIENT'S HEADLINES ARE SENTENCES, NOT TWO-WORD SLOGANS, and that is why
   the banner type is smaller than --t-hero. "Sound, put in its place." was 24
   characters and sat at 7rem across 14ch on two lines. The longest of the
   three replacing it — "Where Superior Acoustics Meet Elegant Design" — is
   44, and at those settings it ran to four lines and pushed the lead and both
   buttons off a 900px screen.

   Only two things can absorb that, and only one of them is safe. Widening the
   column is not: `.hero-top > .wrap` is held to `padding-right: 42% + gut`
   because that is where the 102deg scrim still has alpha to spare, and type
   past it sits on the bare photograph. So the type comes down instead —
   5.25rem at the top end against the 7rem the old grotesque ran at. The
   ceiling moved up from 4.25rem when the display face became Instrument
   Serif: a 400-weight serif sets narrower and lighter than Bricolage at 600,
   so the same number read noticeably smaller on the page.

   If a future headline is longer than 44 characters this will break again,
   and the fix is the copy or another step down here — not a wider column. */
.hero-title {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-hero), 'opsz' 96;
  font-size: clamp(2.5rem, 1.2rem + 5.1vw, 5.25rem);
  letter-spacing: -0.03em;
  line-height: 0.94;
  margin-block: 1.75rem 2.5rem;
  /* 20ch, NOT 24ch, AND THIS IS A CONTRAST CONSTRAINT RATHER THAN A
     TYPOGRAPHIC ONE. `.decay` is the last word of the headline, so it sits at
     the right end of the longest line — the deepest any type gets into the
     scrim's falloff. The scrim holds full alpha to 55% and is heading for 0
     by 78%; at 24ch and 5.25rem the last word reached past 55% and landed on
     the bare photograph. Measured there: 2.25:1, under even the 3:1
     large-text floor `--mark-banner` lives by. At 20ch the line breaks
     before the ramp and the same word scores 4.4:1.

     So: raising `--t-hero` here, or widening this, or widening
     `.hero-top > .wrap`, all move type into the falloff. Re-measure the
     three banners after any of them. */
  max-width: 20ch;
}

/* Stacked, deliberately. Lead and buttons side by side was the first attempt
   and it does not fit: the copy column is already only ~62% of the page once
   the sample band takes its share, and two buttons at their natural width
   left the lead about twelve characters wide. */
.hero-foot { display: grid; gap: clamp(1.75rem, 3vw, 2.5rem); justify-items: start; }
/* the echo: the last line decays like a reverb tail. Italic rather than a
   tone shift — the word leans away instead of merely going grey.

   THE COLOUR IS NOT SET HERE. It used to be — `color: var(--on-light-mute)`
   — and because this rule comes 76 lines AFTER the `.hero-top` block at
   equal specificity, it silently overrode `--mark-banner`, the token that
   exists for exactly this word. Nobody caught it because the two values were
   close enough on the old palette that the numbers looked plausible; the
   note in CLAUDE.md said `--mark-banner` carried `.decay` while the page was
   rendering `--on-light-mute`. On a warm palette they are not close, and the
   override took the word to 2.25:1 over the office banner — under even the
   3:1 large-text floor. The colour belongs to the banner block above. */
.hero-title .decay { font-style: italic; }
.hero-lead { max-width: 46ch; }
.hero-cta { margin-top: clamp(2.25rem, 4vw, 3.25rem); gap: 0.85rem; }

/* the spec strip: real, checkable claims — not vanity counters */
/* This strip sits between the banner photograph and the first big heading, and
   it used to do nothing with that position: four equal columns of small mono
   set on the same ground as everything around it, reading as a dead band.

   Three changes. The figures moved from mono to the display face and roughly
   doubled in size, so they read as claims rather than as a data table — mono
   is right for a spec table on a product page, wrong for the four things this
   company most wants believed. The first column is wider than the other three
   at desktop, so the row has a lead rather than four equal weights, the same
   move .cat-grid makes for the first product in a family. It sits directly on
   the hero's own ombre rather than a plinth of its own — a separate fill here
   read as a flat pale slab with nothing in it, and section rhythm on this site
   is carried by rule, space and type, not by stacking grounds. */
/* ---------------------- the material rule and the plinth -----------------
   Direction B's two structural moves on the hero. The rule closes the
   photograph with the five material colours; the plinth puts the client's
   four counters on the deeper of the two grounds so the banner ends on a
   definite edge instead of dissolving into the page.

   THE PLINTH IS ONLY SAFE BECAUSE `.hero-rail` IS EXCLUDED FROM `mDrift`.
   CLAUDE.md carries a warning not to give this rail a background, and the
   reason was real: `mDrift` fades `.hero .wrap` to opacity 0.25 over the
   first 78vh of scroll, and the rail is a `.hero .wrap`, so a ground on it
   used to fade with it and whatever sat behind read straight back through.
   The rule is `.hero .wrap:not(.hero-rail)` now, so the rail no longer
   drifts and can hold a fill. If that selector is ever widened again, this
   plinth starts ghosting on scroll — check them together. */
.mat-rule { display: flex; height: 6px; }
.mat-rule > i { display: block; }

.hero-rail {
  background: var(--ombre-dark), var(--paper);
  /* full-bleed: the rail is a .wrap, so its own gutters stay, but the fill
     has to reach both edges or it reads as a floating slab. */
  max-width: none; width: 100%;
  padding-inline: var(--gut);
  border-bottom: 1px solid var(--paper-edge);
}
.hero-specs {
  margin-top: clamp(3.5rem, 7vw, 5.5rem);
  border-top: 1px solid var(--paper-edge);
  display: grid; gap: 0;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
  /* Trimmed from clamp(2.5rem, 5vw, 4rem). The rail is a short summary, not a
     section, and the old value left a band of empty ground under it taller
     than the figures themselves. */
  padding-bottom: clamp(1.5rem, 2.6vw, 2.25rem);
}
/* The track count is explicit, so it has to match the number of items — the
   `auto-fit` rule above copes with any count, this one does not. It was four
   tracks while the rail carried four items; dropping the Coverage item left
   the fourth track empty and the strip read as truncated. Three items, three
   tracks. The first is wider because it carries the longest caption. */
@media (min-width: 64rem) {
  .hero-specs { grid-template-columns: 1.35fr 1fr 1fr; }
  /* Four of the client's own counters, so four tracks. The first is no longer
     the widest — every caption in this set is short — so they are even. */
  .hero-specs-4 { grid-template-columns: repeat(4, 1fr); }
}
.hero-spec { padding: 2.25rem 1.5rem 0 0; position: relative; }
@media (min-width: 48rem) {
  .hero-spec + .hero-spec { border-left: 1px solid var(--paper-edge); padding-left: 1.75rem; }
}
/* a tick above each figure, on the rule — the drawing-sheet detail that
   makes the strip read as a measured scale rather than four stat boxes */
.hero-spec::before {
  content: ''; position: absolute; left: 0; top: -1px; width: 1.5rem; height: 1px;
  background: var(--mark-ink);
}
@media (min-width: 48rem) { .hero-spec + .hero-spec::before { left: 1.75rem; } }
.hero-spec dt {
  font-family: var(--body); font-size: 0.9375rem; font-weight: 500;
  letter-spacing: 0; color: var(--on-light-mute);
  margin-bottom: 0.65rem;
}
.hero-spec dd {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 32;
  font-size: clamp(1.875rem, 1.1rem + 2.6vw, 3rem);
  letter-spacing: -0.022em; color: var(--brand); line-height: 0.94;
}
.hero-spec dd small {
  display: block; font-family: var(--body); font-weight: 400;
  font-size: 0.875rem; letter-spacing: 0; color: var(--on-light-mute);
  margin-top: 0.65rem; line-height: 1.5;
}

/* =========================== cards ====================================== */
/* A card is a mounted sample, not a bordered box: the fill is the ground it
   sits on, the hairline is barely there, and the object is separated from the
   page by a long shadow rather than a drawn outline. */
.card {
  display: flex; flex-direction: column;
  background: var(--paper-hi); border: 2px solid var(--on-light);
  border-radius: var(--r); overflow: hidden;
  text-decoration: none; color: inherit;
  box-shadow: var(--lift);
  transition: border-color 320ms var(--ease), transform 320ms var(--ease),
              box-shadow 320ms var(--ease);
}
.dark .card {
  background: var(--ink-panel); border-color: var(--on-light);
  box-shadow: var(--lift-d);
}
/* THE BLOCK MOVES AWAY FROM ITS OWN SHADOW. With a hard offset shadow the old
   `translateY(-4px)` read as the card sliding off its own drawing; moving
   UP-AND-LEFT into a bigger offset is the move this device wants. Pressing
   drops it into the shadow, the same physics as `.btn:active`. */
a.card:hover {
  transform: translate3d(-3px, -3px, 0);
  border-color: var(--on-light);
  box-shadow: var(--lift-2);
}
a.card:active {
  transform: translate3d(4px, 4px, 0);
  box-shadow: 0 0 0 var(--on-light);
  transition-duration: 90ms;
}
.dark a.card:hover { border-color: var(--on-light); box-shadow: var(--lift-d-2); }

.card-surface { aspect-ratio: 4 / 3; flex: none; }
.card-body { padding: 1.4rem 1.4rem 1.5rem; display: flex; flex-direction: column; gap: 0.55rem; flex: 1; }
.card-cat {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase; color: var(--on-light-mute);
}
.dark .card-cat { color: var(--on-dark-mute); }

/* The family marker. Colour on this site identifies material and nothing
   else, so the category label carries a short rule in its family's own
   temperature — the same five values the NRC bar fills with. It is a 2px
   mark, not a tint behind text: these colours are chosen to look like felt,
   foam and timber, and none of them is guaranteed to hold 4.5:1 under a
   label. Keep them off grounds that carry type. */
.card[data-mat] .card-cat::before, .fam[data-mat] .fam-n::before {
  content: ''; display: inline-block; vertical-align: 0.22em;
  width: 1.15rem; height: 2px; margin-right: 0.55rem;
  background: var(--mat, var(--mat-pet));
}
[data-mat="pet"]   { --mat: var(--mat-pet); }
[data-mat="cloud"] { --mat: var(--mat-cloud); }
[data-mat="foam"]  { --mat: var(--mat-foam); }
[data-mat="wood"]  { --mat: var(--mat-wood); }
[data-mat="proof"] { --mat: var(--mat-proof); }
.card h3 { font-size: 1.3125rem; letter-spacing: -0.014em; }
.card p { font-size: 0.9375rem; line-height: 1.58; color: var(--on-light-mute); }
.dark .card p { color: var(--on-dark-mute); }
.card-foot {
  margin-top: auto; padding-top: 1.1rem;
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--brand-ink);
  display: flex; align-items: center; gap: 0.5rem;
}
.dark .card-foot { color: var(--brand-ink); }
a.card:hover .btn-arrow { transform: translateX(4px); }

/* A panel is a card that is not a link: same mounted-sample treatment, no
   hover lift. These used to be inline `border: 1px solid` on a bare article,
   which is why they read as boxes next to the real cards. */
.panel {
  background: var(--paper-hi);
  border: 2px solid var(--on-light);
  border-radius: var(--r);
  overflow: hidden;
  box-shadow: var(--lift);
}
.dark .panel {
  background: var(--ink-panel);
  border-color: rgba(16, 23, 27, 0.103);
  box-shadow: var(--lift-d);
}
.panel-pad { padding: clamp(1.5rem, 3vw, 2.25rem); }
.panel h3 { font-size: var(--t-h3); margin: 1rem 0 0.75rem; }
.panel p { color: var(--on-light-mute); }
.dark .panel p { color: var(--on-dark-mute); }

/* room-type panel: the target figure leads, because that number is the whole
   argument — a room is designed to hit it, not to a coverage percentage */
.room-target {
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--mark-ink) !important;
  padding-bottom: 0.9rem; border-bottom: 1px solid var(--paper-edge);
}
.dark .room-target { color: var(--mark) !important; border-bottom-color: rgba(16, 23, 27, 0.138); }
.room h3 { font-size: 1.375rem; margin: 0.9rem 0 0.5rem; }
.room-note { font-size: 0.9375rem; line-height: 1.58; }

/* sector tiles — dark, textured, minimal */
.sector {
  position: relative; display: flex; align-items: flex-end;
  min-height: clamp(11rem, 20vw, 14rem);
  padding: 1.25rem; text-decoration: none; color: var(--on-dark);
  border-radius: var(--r); overflow: hidden;
  border: 1px solid rgba(16, 23, 27, 0.138);
  transition: border-color 240ms var(--ease), transform 240ms var(--ease);
}
.sector { box-shadow: var(--lift-d); }
.sector:hover { border-color: var(--mark); transform: translateY(-4px); box-shadow: var(--lift-d-2); }
/* the tile's own scrim: sits above the texture, behind the label */
.sector > span { position: relative; display: block; width: 100%; }
.sector > span::before {
  content: ''; position: absolute; z-index: -1;
  left: -1.25rem; right: -1.25rem; bottom: -1.25rem; top: -3rem;
  background: linear-gradient(180deg, transparent, rgba(6, 10, 13, 0.62) 45%, rgba(6, 10, 13, 0.92));
}
.sector-name {
  font-family: var(--display); font-weight: var(--w-display);
  font-size: 1.3125rem; line-height: 1.1; letter-spacing: -0.016em;
  text-shadow: 0 2px 14px rgba(0,0,0,0.8);
}
.sector-rt {
  display: block; margin-top: 0.5rem;
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.14em;
  color: var(--mark); text-shadow: 0 2px 10px rgba(0,0,0,0.9);
}

/* =========================== spec table =================================
   The datasheet is the part of this site a specifier actually reads, so it
   gets the same care as the headlines: a bright rule under the caption, room
   between the rows, and the label in sentence case rather than shouting in
   tracked-out caps at a line that was already wrapping. */
/* Tabular figures, now that the fixed-width face is gone.
   A monospace kept these columns aligned for free: every glyph was one width,
   so decimal points stacked and a column of thicknesses read as a column.
   Public Sans is proportional — measured at 40px its digits run 16.27px (the
   1) to 25.91px (the 8), a 9.64px spread — so without this the spec tables,
   the NRC figures and the room counts would go visibly ragged. tabular-nums
   switches on the face's own fixed-width digit set: measured again with it on,
   all ten digits are exactly 28px.

   This is the whole reason removing the typewriter face cost nothing in the
   one place it was genuinely doing work. .val already did this; these are the
   rest of the roles where a number sits above another number. */
.spec, .spec th, .spec td,
.hero-spec dd, .nrc-fig, .rdx-count, .room-target, .sector-rt {
  font-variant-numeric: tabular-nums;
}

/* A figure that counts up to itself changes digit count on the way — 0 to 20
   goes one glyph to two — so the "+" beside it would step sideways as it
   ran. `2ch` on the tabular figures above is exactly two digit widths, and
   `min-width` rather than `width` so a three-digit figure still fits. Right
   alignment keeps the growth on the left, where there is nothing to push. */
.tick {
  display: inline-block;
  min-width: 2ch;
  text-align: right;
}

.spec {
  width: 100%; border-collapse: collapse;
  font-family: var(--mono); font-size: 0.8125rem;
}
.spec caption {
  text-align: left; font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--on-light-mute);
  padding-bottom: 1rem; margin-bottom: 0.25rem;
  border-bottom: 1px solid var(--mark-ink);
}
.dark .spec caption { color: var(--on-dark-mute); border-bottom-color: var(--mark); }
.spec th, .spec td { text-align: left; padding: 1rem 1rem 1rem 0; vertical-align: top; }
.spec td { padding-right: 0; }
.spec th {
  font-family: var(--body); font-weight: 400; font-size: 0.875rem;
  color: var(--on-light-mute); width: 46%;
  letter-spacing: 0; text-transform: none; line-height: 1.45;
}
.dark .spec th { color: var(--on-dark-mute); }
.spec td {
  color: var(--mark-ink); font-variant-numeric: tabular-nums;
  line-height: 1.5;
}
.dark .spec td { color: var(--mark); }
.spec tr + tr { border-top: 1px solid var(--paper-edge); }
.dark .spec tr + tr { border-top-color: rgba(16, 23, 27, 0.115); }

/* pill list for applications / finishes */
/* The family picker at the top of the catalogue. A nav, not a card grid —
   the page already has 19 cards below it and five more would read as the
   catalogue starting twice. Shaped like .pills, but these are links rather
   than the inert li chips that component renders, so they carry their own
   rule instead of being bolted onto it.

   min-height: 44px is not decoration: this is the first thing a phone user
   taps, and it has to clear the target size the rest of the site now holds
   to. The count is tabular so five pills of different name lengths still
   line their numbers up. */
.fampick { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.fampick a {
  display: inline-flex; align-items: center; gap: 0.6rem;
  min-height: 44px; padding: 0.5rem 1.15rem;
  border: 1px solid var(--paper-edge); border-radius: var(--r-pill);
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--on-light); text-decoration: none;
  transition: background 200ms var(--ease), border-color 200ms var(--ease),
              color 200ms var(--ease);
}
.fampick a b {
  font-weight: 500; color: var(--on-light-mute);
  font-variant-numeric: tabular-nums;
}
@media (hover: hover) and (pointer: fine) {
  .fampick a:hover { border-color: var(--mark-ink); background: var(--paper-hi); }
  .fampick a:hover b { color: var(--on-light); }
}
.fampick a:active { border-color: var(--mark-ink); background: var(--paper-hi); }

/* The picker lives in a .dark section. Today .dark is the deeper of two LIGHT
   ombres, so --on-light and --on-dark hold the same value and these three
   rules change nothing at all. They exist because that is about to stop being
   true: if .dark is ever given genuinely dark values, a pill painted with
   --on-light on a dark ground is dark-on-dark and the whole family picker
   disappears. Naming the on-dark tokens now costs nothing and means the
   component survives that change without anyone remembering it. */
.dark .fampick a { color: var(--on-dark); border-color: var(--ink-edge); }
.dark .fampick a b { color: var(--on-dark-mute); }
@media (hover: hover) and (pointer: fine) {
  .dark .fampick a:hover { border-color: var(--mark); background: var(--ink-raise); }
  .dark .fampick a:hover b { color: var(--on-dark); }
}
.dark .fampick a:active { border-color: var(--mark); background: var(--ink-raise); }

.pills { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; padding: 0; }
.pills li {
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.14em;
  text-transform: uppercase;
  padding: 0.45rem 0.95rem; border-radius: var(--r-pill);
  border: 1px solid var(--paper-edge); color: var(--on-light-mute);
}
.dark .pills li { border-color: rgba(16, 23, 27, 0.184); color: var(--on-dark-mute); }

/* ------------------------------- the bento ------------------------------
   Replaces the At-a-glance spec table on the About page. A table was the
   wrong shape for eight standalone figures — it implied a row-by-row
   relationship none of them have, and buried the two numbers that actually
   sell the company (2035+ installations, 98%) in a column of labels.

   Asymmetric on purpose: the lead tile takes two columns and two rows so the
   headline figure is read first, the four single tiles carry the countable
   facts, and the two wide tiles hold the qualitative ones that need a
   sentence. Four columns above 48rem, two below, and the spans collapse with
   it so nothing is ever orphaned in a half-row.

   Flat fills, deliberately. These are objects sitting on a ground, so the
   flat-objects rule applies — a gradient under the small uppercase label
   would make its contrast depend on where the text landed. Depth comes from
   the border and --lift instead, which sit outside the text entirely. */
.bento {
  display: grid; gap: 0.75rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: 1.25rem;
}
.bento-i {
  /* --paper-sub, not --paper-hi: the tiles are cards and should tone with the
     rest of them rather than staying the one pure-white block on the page. */
  background: var(--paper-sub);
  border: 1px solid var(--paper-edge);
  border-radius: var(--r);
  box-shadow: var(--lift);
  padding: clamp(1.1rem, 2.2vw, 1.6rem);
  display: flex; flex-direction: column; gap: 0.3rem;
}
.bento-i b {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 32;
  font-size: clamp(1.75rem, 1.1rem + 2.2vw, 2.5rem);
  line-height: 0.95; letter-spacing: -0.02em; color: var(--on-light);
  /* the figures sit in a grid and have to line up down the columns */
  font-variant-numeric: tabular-nums;
}
.bento-i span {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--on-light-mute);
}
.bento-i small {
  font-size: 0.875rem; line-height: 1.5; color: var(--on-light-mute);
  margin-top: 0.4rem;
}
.bento-lg, .bento-wide { grid-column: span 2; }
.bento-lg b { font-size: clamp(2.5rem, 1.5rem + 3.4vw, 4rem); }
/* The lead tile is two rows tall and its content is short, so without help it
   reads as a mostly-empty box. Push only the NOTE to the floor — spacing the
   three children evenly instead (justify-content: space-between) stranded
   "installations completed" in mid-air, away from the figure it labels. */
@media (min-width: 48rem) { .bento-lg small { margin-top: auto; } }

@media (min-width: 48rem) {
  .bento { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }
  .bento-lg { grid-row: span 2; }
}

/* =========================== process ==================================== */
/* Numbered here because it genuinely is a sequence: each step gates the next. */
.steps { counter-reset: step; display: grid; gap: 0; }
.step {
  counter-increment: step;
  display: grid; gap: 0.35rem 1.5rem;
  padding: clamp(1.5rem, 3vw, 2.25rem) 0;
  border-top: 1px solid var(--paper-edge);
  grid-template-columns: 3.5rem 1fr;
  align-items: start;
}
.dark .step { border-top-color: rgba(16, 23, 27, 0.149); }
.step:last-child { border-bottom: 1px solid var(--paper-edge); }
.dark .step:last-child { border-bottom-color: rgba(16, 23, 27, 0.149); }
.step::before {
  content: counter(step, decimal-leading-zero);
  grid-row: 1 / span 2;
  font-family: var(--mono); font-size: 0.8125rem; font-weight: 500;
  color: var(--mark-ink); letter-spacing: 0.05em; padding-top: 0.28rem;
}
.dark .step::before { color: var(--mark); }
.step h3 { font-size: var(--t-h3); }
.step p { color: var(--on-light-mute); max-width: 60ch; }
.dark .step p { color: var(--on-dark-mute); }
@media (min-width: 56rem) {
  .step { grid-template-columns: 3.5rem minmax(0, 15rem) minmax(0, 1fr); align-items: baseline; }
  .step h3 { grid-column: 2; grid-row: 1; }
  .step p  { grid-column: 3; grid-row: 1; }
}

/* =========================== quotes ===================================== */
.quote {
  display: flex; flex-direction: column; gap: 1.75rem;
  padding: clamp(1.75rem, 3.5vw, 2.5rem);
  border: 1px solid var(--paper-edge); border-radius: var(--r);
  background: var(--paper-sub);
  box-shadow: var(--lift);
}
.dark .quote {
  border-color: rgba(16, 23, 27, 0.103); background: var(--ink-panel);
  box-shadow: var(--lift-d);
}
/* Set in the display face. A testimonial is the one place on the site where
   somebody else is speaking, and the change of voice should be audible. */
.quote blockquote {
  font-family: var(--display); font-weight: var(--w-display);
  font-size: clamp(1.25rem, 1.1rem + 0.7vw, 1.5rem);
  line-height: 1.38; letter-spacing: -0.011em;
  color: var(--on-light);
}
.dark .quote blockquote { color: var(--on-dark); }
.quote blockquote::before { content: '\201C'; color: var(--mark-ink); margin-right: 0.06em; }
.quote blockquote::after  { content: '\201D'; color: var(--mark-ink); margin-left: 0.03em; }
.dark .quote blockquote::before, .dark .quote blockquote::after { color: var(--mark); }
.quote figcaption {
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.16em;
  text-transform: uppercase; color: var(--on-light-mute); margin-top: auto;
  padding-top: 1.1rem; border-top: 1px solid var(--paper-edge);
  line-height: 1.7;
}
.dark .quote figcaption { color: var(--on-dark-mute); border-top-color: rgba(16, 23, 27, 0.149); }
.quote figcaption b { display: block; color: var(--on-light); font-weight: 500; margin-bottom: 0.2rem; }
.dark .quote figcaption b { color: var(--on-dark); }

/* =========================== forms ====================================== */
.form { display: grid; gap: 1.35rem; }
@media (min-width: 40rem) { .form .half { grid-column: span 1; } .form { grid-template-columns: 1fr 1fr; } .form .full { grid-column: 1 / -1; } }
.field { display: flex; flex-direction: column; gap: 0.55rem; }
.field label {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--on-light-mute);
}
.field .req { color: var(--mark-ink); }
.field input, .field select, .field textarea {
  background: var(--paper);
  border: 1px solid var(--paper-edge);
  border-radius: var(--r); padding: 1rem 1.1rem;
  color: var(--on-light); font-family: var(--body); font-size: 1rem;
  transition: border-color 200ms var(--ease), background 200ms var(--ease);
  width: 100%;
}
.field textarea { min-height: 7.5rem; resize: vertical; }
.field select {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--brand-ink) 50%),
                    linear-gradient(135deg, var(--brand-ink) 50%, transparent 50%);
  background-position: calc(100% - 1.15rem) 1.32rem, calc(100% - 0.8rem) 1.32rem;
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
  padding-right: 2.5rem;
}
.field select option { background: var(--paper); color: var(--on-light); }
.field input::placeholder, .field textarea::placeholder { color: rgba(86, 76, 62,0.55); }
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--brand-ink); background: var(--paper-sub);
}
.field input:user-invalid, .field textarea:user-invalid { border-color: #B4574A; }
.form-note { font-size: 0.875rem; color: var(--on-light-mute); }
.form-status {
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--mark-ink); min-height: 1.2em;
}

/* =========================== contact rows =============================== */
.contact-list { list-style: none; padding: 0; display: grid; gap: 0; }
.contact-list li { padding: 1.15rem 0; border-top: 1px solid var(--paper-edge); }
.contact-list li:last-child { border-bottom: 1px solid var(--paper-edge); }
.contact-list .k {
  display: block; font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--on-light-mute);
  margin-bottom: 0.4rem;
}
.contact-list .v { font-size: 1.0625rem; text-decoration: none; }
.contact-list a.v:hover { color: var(--brand-ink); text-decoration: underline; text-underline-offset: 4px; }

/* =========================== CTA band =================================== */
.cta-band { position: relative; overflow: hidden; background: var(--ombre-light); color: var(--on-light); }
.cta-band::before {
  content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(96deg,
    var(--paper) 0 30%, rgba(250, 251, 251, 0.84) 52%, rgba(250, 251, 251, 0.4) 76%, rgba(250, 251, 251, 0.18));
}
@media (max-width: 56rem) {
  .cta-band::before { background: rgba(250, 251, 251, 0.86); }
}
.cta-band .wrap { position: relative; z-index: 3; }
.cta-band h2 { font-size: var(--t-h1); max-width: 18ch; }

/* =========================== footer ===================================== */
/* The footer joins the page's own ground instead of ending it with a slab.
   It used to paint an opaque --paper-sub, which did to the bottom of the page
   exactly what an opaque section background does anywhere else here: it hid
   the document wash AND the 64px grid under a flat near-white rectangle, so
   the site faded out into something that looked like a different page.

   Now it veils rather than covers, the same move .light and .dark make, and
   both the colour and the grid read through. It takes --ombre-dark, the
   thinner of the two veils (white 0.12 -> 0.03), because the footer sits at
   the very bottom of the document where the wash is deepest — so this is the
   most saturated band on the site, which is the right place for the page to
   arrive rather than to drain away.

   That depth is measured, not chosen by eye. Against the wash's deepest stop
   with a grid line directly under the text: links and body copy
   (--on-light-mute) 4.68:1, .foot-h (--on-light) 9.58:1, link hover
   (--brand-ink) 5.15:1. The 4.68 is exactly the worst case the .dark sections
   already ship, so the footer is no tighter than the rest of the page — but
   it is at the limit. Dropping the veil entirely to get more colour was
   tested and rejected: that lands muted copy at 4.58:1, and 0.08 of headroom
   is not headroom once grain and antialiasing are in it.

   The three --paper-edge hairlines soften on this deeper ground. Left as they
   are on purpose — .note and hr.rule already sit on the same deep ground in
   the dark sections, so this is the established look for a rule down here,
   not a regression. */
.site-foot {
  background-color: transparent;
  background-image: var(--ombre-dark);
  color: var(--on-light-mute);
  padding-block: clamp(4rem, 8vw, 6.5rem) 2.25rem;
  border-top: 1px solid var(--paper-edge);
}
.foot-grid {
  display: grid; gap: clamp(2rem, 4vw, 3rem);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
  padding-bottom: clamp(2rem, 4vw, 3rem);
}
.foot-grid > div:first-child { grid-column: 1 / -1; }
/* Five equal columns left the brand blurb and the postal address both wrapping
   every two or three words. The two columns that carry sentences get the room;
   the three link lists do not need it. */
@media (min-width: 56rem) {
  .foot-grid {
    grid-template-columns: 1.45fr 1fr 1fr 0.9fr 1.35fr;
    gap: clamp(2rem, 3.5vw, 3.5rem);
  }
  .foot-grid > div:first-child { grid-column: span 1; }
}
.foot-h {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--on-light);
  margin-bottom: 1.35rem; padding-bottom: 0.85rem;
  border-bottom: 1px solid var(--paper-edge);
}
.foot-list { list-style: none; padding: 0; display: grid; gap: 0.6rem; font-size: 0.9375rem; }
.foot-list a { text-decoration: none; transition: color 180ms var(--ease); color: inherit; }

/* A footer link is an 18px line of text, which is a fine mouse target and a
   poor thumb one: 18px tall with 0.6rem between them puts adjacent links
   about 34px apart, against the 44px both platform guidelines ask for. The
   links already span the column, so only vertical accuracy is at stake.

   Padding rather than a min-height, because the target has to grow around the
   text rather than leave it sitting at the top of a taller box. Scoped to
   coarse pointers and narrow screens: a mouse does not need 44px, and
   applying it everywhere would add ~200px to the desktop footer for nothing.
   The list gap goes to 0 in the same breath, so the padding IS the rhythm
   instead of stacking on top of it. */
@media (max-width: 56rem), (pointer: coarse) {
  .foot-list { gap: 0; }
  .foot-list a { display: block; padding-block: 0.85rem; }
}
.foot-list a:hover { color: var(--brand-ink); }
.foot-bar {
  border-top: 1px solid var(--paper-edge); padding-top: 1.75rem;
  display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between;
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.16em;
}
.foot-blurb { font-size: 0.9375rem; max-width: 34ch; margin-top: 1rem; }

/* =========================== page header ================================ */
.page-head {
  background: var(--ombre-light); color: var(--on-light);
  padding-block: clamp(3.5rem, 7vw, 5.5rem) clamp(2.5rem, 5vw, 4rem);
  position: relative; overflow: hidden;
}
.page-head::before {
  content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(94deg,
    var(--paper) 0 24%, rgba(250, 251, 251, 0.86) 48%, rgba(250, 251, 251, 0.5) 74%, rgba(250, 251, 251, 0.28));
}
@media (max-width: 56rem) {
  .page-head::before { background: rgba(250, 251, 251, 0.84); }
}
.page-head .wrap { position: relative; z-index: 3; }
.page-head h1 { font-size: var(--t-h1); margin-block: 1rem 1.25rem; }
.crumbs {
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--on-light-mute);
  display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.75rem;
}
.crumbs a { text-decoration: none; }
.crumbs a:hover { color: var(--brand-ink); }
.crumbs span[aria-hidden] { opacity: 0.4; }

/* =========================== product detail ============================= */
.pd-grid { display: grid; gap: clamp(2rem, 4vw, 3.5rem); align-items: start; }
@media (min-width: 60rem) { .pd-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 21rem); } }
.pd-hero { aspect-ratio: 16 / 10; border-radius: var(--r); border: 1px solid var(--paper-edge); }
.pd-aside {
  border: 1px solid var(--paper-edge); border-radius: var(--r);
  padding: clamp(1.5rem, 3vw, 2.25rem); background: var(--paper-sub);
  box-shadow: var(--lift);
  position: sticky; top: 6.75rem;
}
@media (max-width: 59.999rem) { .pd-aside { position: static; } }
.pd-body h2 { font-size: var(--t-h3); margin-bottom: 0.75rem; }
.pd-body p { color: var(--on-light-mute); max-width: var(--measure); }
.pd-body > * + * { margin-top: 1.5rem; }
.pd-body ul { color: var(--on-light-mute); padding-left: 1.1rem; max-width: var(--measure); }
.pd-body li + li { margin-top: 0.5rem; }
.pd-body li::marker { color: var(--mark-ink); }

/* =========================== gallery ==================================== */
/* Six tiles on a 3x3 grid, the first spanning two columns and two rows. An
   even grid of eight made every project look equally weighted and leaned hard
   on photography that cannot carry that much repetition; one feature plus five
   supporting tiles fills the grid exactly and gives the block a focal point. */
.gal { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); }
@media (min-width: 64rem) {
  .gal { grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr; }
  .gal-item:first-child { grid-column: span 2; grid-row: span 2; }
  .gal-item:first-child .gal-surface { aspect-ratio: auto; height: 100%; }
  .gal-item:first-child .gal-cap h3 { font-size: 1.25rem; }
  .gal-item { display: flex; flex-direction: column; }
  /* THE LINK IS IN THE FLEX CHAIN. .gal-zoom wraps every .gal-surface so the
     tile degrades to a plain link to the photograph without JavaScript, which
     means the direct-child rule that used to stretch the surface no longer
     reaches it. Left as `.gal-item > .gal-surface` it matched nothing, the
     feature tile's surface got no height, and the first project's photograph
     — Artillery Museum, PROJECTS[0], the 2x2 tile — collapsed to zero and
     rendered as an empty box. Both hops need to stretch, and both need
     min-height: 0 or the image's intrinsic height wins and overflows. */
  .gal-item > .gal-zoom { flex: 1; min-height: 0; display: flex; flex-direction: column; }
  .gal-item > .gal-zoom > .gal-surface { flex: 1; min-height: 0; }
}
.gal-item { border-radius: var(--r); overflow: hidden; border: 1px solid rgba(16, 23, 27, 0.149); background: var(--ink-panel); }
/* THIS RULE IS WHAT MAKES THE ROOM-TYPE FILTER VISIBLE, and it is not
   optional. site.js filters by setting el.hidden, which relies on the UA
   stylesheet's `[hidden] { display: none }`. That rule has specificity 0,1,0
   from an attribute selector but it lives in the UA origin, so ANY author
   declaration of `display` on .gal-item beats it — and the feature-tile grid
   above sets `.gal-item { display: flex }` at >=64rem.

   The result was a filter that looked wired and did nothing: clicking a room
   type set `hidden` on 163 of 170 tiles and every one of them stayed on
   screen at `display: flex`. Verified, after I had already told the user the
   filter worked on the strength of reading el.hidden — the property was
   perfectly correct, which is exactly why the JS-side check missed it.
   Check getComputedStyle(el).display, not el.hidden.

   Specificity 0,2,0, so it also survives anyone adding another
   `.gal-item { display: ... }` later. */
.gal-item[hidden] { display: none; }
.gal-surface { aspect-ratio: 3 / 2; }
.gal-cap { padding: 1rem 1.1rem 1.2rem; }
/* `.gal-item` is a dark card that lives inside a `.light` section, so the
   caption has to name its colour: without this the h3 inherits the paper
   ground's near-black onto the panel fill and the project title vanishes
   at 1.4:1. The `p` below always set its own colour, which is why the
   sector line stayed readable while the title did not. */
.gal-cap h3 { font-size: 1rem; margin-bottom: 0.35rem; color: var(--on-dark); }
.gal-cap p { font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.16em; text-transform: uppercase; color: var(--on-dark-mute); }

/* =========================== filter bar ================================= */
.filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 2.5rem; }
.filter {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  padding: 0.7rem 1.15rem; border-radius: var(--r); cursor: pointer;
  background: none; border: 1px solid var(--paper-edge); color: var(--on-light-mute);
  /* Named, not `all`. `transition: all` on a filter pill also transitions the
     properties you did not think about — `aria-pressed="true"` changes
     `background` and `border-color`, and `all` drags width, padding and any
     future property into the same 200ms, so a token change can silently start
     animating layout. These four are the ones that actually change. */
  transition: background 200ms var(--ease), border-color 200ms var(--ease),
              color 200ms var(--ease), transform 200ms var(--ease);
}
.filter:hover { border-color: var(--mark-ink); color: var(--on-light); }
.filter[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--on-dark); }
/* the filter bar sits on the dark ground on the projects page */
.dark .filter { border-color: rgba(16, 23, 27, 0.253); color: var(--on-dark-mute); }
.dark .filter:hover { border-color: var(--brand); color: var(--brand-ink); }
.dark .filter[aria-pressed="true"] {
  background: var(--on-dark); border-color: var(--on-dark); color: var(--ink);
}

/* =========================== utilities ================================== */
.vh {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.center { text-align: center; }
.mt-l { margin-top: clamp(2.5rem, 5vw, 4rem); }
hr.rule { border: 0; border-top: 1px solid var(--paper-edge); margin-block: clamp(2.5rem, 5vw, 4rem); }
.dark hr.rule { border-top-color: rgba(16, 23, 27, 0.149); }

/* =========================== statement band =============================
   One section on the homepage that breaks the eyebrow / heading / lead / grid
   rhythm every other section keeps. Eight identical section heads in a row is
   what made the page feel like a template; this one is just a sentence, set
   large, with the answer underneath it. */
.statement {
  padding-block: clamp(5rem, 11vw, 9.5rem);
  position: relative;
}
.statement::before {
  content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
  background: linear-gradient(90deg, var(--mark-ink), transparent 55%);
  opacity: 0.7;
}
.statement-h {
  font-size: var(--t-h1);
  letter-spacing: -0.028em; line-height: 0.94;
  margin-block: 1.25rem clamp(2.5rem, 5vw, 4rem);
  max-width: 18ch;
}
.statement-em { font-style: italic; color: var(--mark-ink); }
.statement-grid { display: grid; gap: clamp(1.75rem, 3vw, 3rem); align-items: end; }
@media (min-width: 56rem) {
  .statement-grid { grid-template-columns: minmax(0, 1fr) minmax(0, auto); }
}

/* =========================================================================
   STRUCTURE
   The page used to be one shape repeated: eyebrow + heading left, lead right,
   then a grid of equal cards. Eight of those in a row is what made a careful
   layout read as a template. Each block below is a different shape, chosen
   because it suits the content rather than because it was next.
   ========================================================================= */

/* ------------------------------- diptych --------------------------------
   Two answers to two different problems, so: two halves, full bleed, floor
   to ceiling, split by a single hairline. The structure states the argument
   before anybody reads a word of it. */
/* TWO ROWS, NOT TWO COLUMNS. As a 2-up split each panel was ~47rem tall with
   the photograph behind a scrim that reached 98% white by 60% of its height,
   so the image faded to nothing and the copy sat alone at the bottom — two
   thirds of each panel was empty ground, and the picture never appeared
   beside the words it illustrated.

   Now each idea is a full-width row: photograph on one side, copy on the
   other, mirrored on the second row so the two read as a pair rather than a
   list. The hairline between them is the 1px gap showing the grid's own
   background through. */
.diptych {
  display: grid; grid-template-columns: 1fr; gap: 1px;
  background: var(--ink-edge);
}

.dip {
  position: relative; isolation: isolate; overflow: hidden;
  display: grid; grid-template-columns: 1fr; align-items: stretch;
  /* no min-height: the row is now as tall as the picture needs, not 82vh */
  padding: 0;
  /* An OPAQUE fill, not the veil. .dip is an object sitting on the page wash,
     not a section veiling it — and when --ombre-light became translucent this
     started showing the wash through, which dropped its muted copy to 3.79:1
     at the point in the document where the wash is deepest. Objects on this
     site carry their own fill for exactly this reason; see the flat-objects
     note. --paper-sub is the same fill the cards use. */
  background: var(--paper-sub); color: var(--on-light);
  text-decoration: none;
}
/* THE SCRIM IS RETIRED — read this before reinstating it.

   Everything that used to be documented here described a guarantee for the
   old 2-up layout: panels clamp(30rem, 82vh, 47rem) tall with the copy lying
   ON the photograph, and stops tuned so that from 44% of panel height down
   the scrim was at or above 0.80 alpha, compositing to 4.99:1 against
   --on-light-mute over even a pure-black pixel.

   None of those numbers mean anything now. The rows are as tall as the
   picture needs, padding moved to .dip-body, and the copy sits BESIDE the
   photograph on the panel's own opaque fill rather than over it. "44% of
   panel height" no longer describes where any text is.

   So the scrim is off (`content: none` below) rather than retuned, because a
   scrim exists only to make text legible on top of an image and there is no
   longer any text on top of an image. Its measured contrast is now simply the
   copy on --paper-sub: h2 15.86:1, body 7.75:1, independent of the
   photograph entirely — which is the better property.

   If a future layout puts copy back over the picture, do not carry the old
   stops across. Re-derive them against that layout's geometry. */
.dip-surface {
  position: relative; z-index: 0; overflow: hidden;
  min-height: clamp(15rem, 32vw, 24rem);
}
@media (min-width: 48rem) {
  .dip { grid-template-columns: 1.05fr 1fr; }
  /* mirror the second row so the pair reads as a pair */
  .dip:nth-child(even) .dip-surface { order: 2; }
}
.dip-surface img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block;
  transition: transform 900ms var(--ease);
}
.dip:hover .dip-surface img { transform: scale(1.03); }
/* The scrim is gone. It existed to make the bottom of the panel readable
   when the copy sat ON the photograph; the copy now sits beside it on the
   panel's own fill, so a 98%-white veil over the picture only destroyed it.
   Kept as a no-op rather than deleted so the layering (z-index 1 between
   surface and body) is still documented if anyone puts text back over it. */
.dip::before {
  content: none;
  background: linear-gradient(180deg,
    rgba(255, 255, 255, 0.03) 0 28%, rgba(255, 255, 255, 0.38) 35%,
    rgba(255, 255, 255, 0.80) 44%, rgba(255, 255, 255, 0.95) 52%,
    rgba(255, 255, 255, 0.985) 60%);
  transition: opacity 420ms var(--ease);
}
.dip-body {
  position: relative; z-index: 2;
  align-self: center;
  padding: clamp(1.75rem, 4vw, 3.5rem);
  max-width: 42ch;
}
.dip-body h2 { font-size: var(--t-h2); margin: 1rem 0 1rem; }
/* --on-light-mute, not --on-dark-mute. This paragraph renders on the panel's
   own --paper-sub fill inside a `.light railed dip-sec` section — there is no
   dark ground anywhere near it. The two tokens hold the same value today, so
   this changes nothing now; it stops the paragraph inverting to near-white if
   the on-dark family is ever given genuinely dark-ground values. */
.dip-body p { color: var(--on-light-mute); }
.dip .tlink { margin-top: 1.75rem; color: var(--brand-ink); }

.dip:hover .btn-arrow { transform: translateX(4px); }

/* The section head is the index's own head: tight to the first hairline and
   sharing the left edge the entries start from, rather than a `.section-head
   split` floating above with a section-sized gap under it. */
.rdx-intro {
  display: grid; gap: clamp(1rem, 2.5vw, 2rem); align-items: end;
  margin-bottom: clamp(1.5rem, 3vw, 2.25rem);
}
.rdx-intro h2 { font-size: var(--t-h2); margin-top: 0.85rem; }
@media (min-width: 56rem) {
  .rdx-intro { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
}

/* The caption above the index: what it lists on the left, the total it adds
   up to on the right. Both figures counted, neither typed. */
.rdx-cols {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: 1.5rem; margin: 0 0 0.8rem;
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--on-light-mute);
}
.dark .rdx-cols, .rail-sec .rdx-cols { color: var(--on-dark-mute); }

/* ----------------------------- project index -----------------------------
   The heading promises every project type has a number to hit, so the number
   IS the layout: it leads each entry at display size, tabular so the ten sit
   on one optical column, and the type and its target follow underneath.

   Two columns, not one. Ten full-width rows made a 650px ladder that had to
   be scrolled through; at two columns the same ten fit one screen and the
   figures can be compared down the column, which is the only reason to put
   them next to each other. Photographs are gone from here entirely — they
   were tried at 4/3 and at 16/9, both times they were the size problem, and
   none of them showed the figure the entry is about. The material swatch
   survives as a two-line colour cue, drawn in CSS, no request. */
.roomdex { list-style: none; padding: 0; margin: 0; display: grid; }
@media (min-width: 52rem) {
  .roomdex { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: clamp(2rem, 4vw, 4rem); }
}
.rdx { border-top: 1px solid var(--paper-edge); }
/* With two columns the last row is two cells, so :last-child alone underlines
   one of them. Both of the final pair need it. */
.rdx:last-child, .rdx:nth-last-child(2):nth-child(odd) { border-bottom: 1px solid var(--paper-edge); }
@media (max-width: 51.99rem) { .rdx:nth-last-child(2):nth-child(odd) { border-bottom: 0; } }

.rdx > a {
  display: grid; grid-template-columns: auto minmax(0, 1fr) 1.5rem;
  gap: 0 1.1rem; align-items: baseline;
  padding: 1.15rem 0; text-decoration: none; color: inherit;
  transition: color 320ms var(--ease);
}
.rdx-fig {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 32;
  font-size: 1.75rem; line-height: 1; letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  min-width: 2.4ch; text-align: right;
  color: var(--on-light);
}
.rdx-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.rdx-name {
  font-family: var(--display); font-weight: var(--w-display);
  font-size: 1.0625rem; line-height: 1.25; letter-spacing: -0.01em;
}
.rdx-note { font-size: 0.875rem; line-height: 1.45; color: var(--on-light-mute); }
.rdx-swatch {
  align-self: center; width: 1.5rem; height: 1.5rem;
  border-radius: 2px; border: 1px solid var(--paper-edge);
}
.rdx > a:hover { color: var(--brand-ink); }
.rdx > a:hover .rdx-note { color: var(--on-light-mute); }

.dark .rdx-fig, .rail-sec .rdx-fig { color: var(--on-dark); }
.dark .rdx-note, .rail-sec .rdx-note { color: var(--on-dark-mute); }
.dark .rdx, .rail-sec .rdx { border-color: var(--ink-edge); }

/* --------------------------- sticky process -----------------------------
   Seven steps that gate each other is a long read, and the heading used to
   scroll away at step two. It now stays put while the steps move past it. */
.process-grid { display: grid; gap: clamp(2.5rem, 5vw, 5rem); }
@media (min-width: 60rem) {
  .process-grid { grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.5fr); }
  .process-aside { position: sticky; top: 8rem; align-self: start; }
}
.process-aside h2 { font-size: var(--t-h2); margin: 1rem 0 1.25rem; }

/* The numeral is the point of a sequence, so it is set like one — and it is
   non-interactive and carrying --brand, by request. So is the hero spec
   rail's dd. That makes TWO exceptions to the rule stated in CLAUDE.md: the
   blue normally means "you can click this", and spending it on decoration is
   what made an earlier palette's eyebrows read as wallpaper.

   Both survive the same argument — they are figures, and in each case the
   figure IS the content rather than an ornament on it. That argument does not
   extend to a third thing. Colour one more non-interactive element and the
   blue stops distinguishing anything, at which point the honest move is to
   drop it from all of them rather than keep adding.

   Contrast is not the constraint: these render 24-36px, so the large-text
   threshold is 3:1 and --brand is 5.48:1 against even the deepest ground. */
.steps-spine .step::before {
  font-family: var(--display); font-weight: var(--w-display);
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2.25rem);
  letter-spacing: 0; color: var(--brand); padding-top: 0;
  line-height: 1;
}
.dark .steps-spine .step::before { color: var(--brand); }

/* ------------------------------- reviews --------------------------------
   Modelled on a Google-reviews block: centred head, then a row of white cards
   on a deeper ground, each opening with a round avatar, a name and a role.

   WHAT IS DELIBERATELY MISSING, and why. The reference carries a Google mark,
   an aggregate score, per-card star ratings, dates, review counts and
   Helpful/Share actions. This site has three testimonials with exactly three
   fields each — quote, name, role. Everything else in that reference would
   have to be invented, and inventing star ratings or a Google score would not
   be a design shortcut, it would be presenting fabricated content as verified
   third-party reviews. So the layout and the card language are here and the
   trust signals are not. If the client connects a real review source, the
   ratings row goes above the quote and the marquee below becomes worth having.

   Three cards is also why this is a grid rather than the reference's two
   scrolling rows: a marquee looping three items reads as padding. It already
   scroll-snaps horizontally under 60rem, which is where a row of three stops
   fitting; raise the item count and the same markup can become a real
   carousel. */
/* ========================= the client wall ==============================
   Fifty client logos, on tiles. This replaced the pinned scroll
   choreography; see the note beside `clientWall()` in src/pages.js for why it
   is a grid and not a marquee.

   THE TILE IS WHITE AND THE GROUND IS NOT, and that is the whole design.
   Every logo is somebody else's artwork drawn for a white background — half
   of them are flat JPEGs with the white baked in, so they cannot be knocked
   out or tinted without wrecking them. Trying to sit them directly on the
   ombre would have given fifty pale rectangles of slightly the wrong white on
   a slate ground. Giving them a real white tile makes the mismatch the
   design: a wall of mounted plates.

   `--paper-hi`, not the `--paper-sub` the bento tiles take. That is the one
   place on this site where pure white is right, because it is the ground the
   artwork was drawn on, not a card colour chosen for tone.

   `object-fit: contain` and a fixed aspect ratio, so a wide wordmark and a
   square crest occupy the same plate and the wall reads as a grid rather
   than as fifty differently-sized pictures. The source files are all
   402x162, but that is the client's own normalisation and this must not
   depend on it — a logo added at another size still lands correctly. */
.client-wall {
  list-style: none; margin: clamp(1.5rem, 3vw, 2.5rem) 0 0; padding: 0;
  display: grid; gap: clamp(0.5rem, 1.2vw, 0.875rem);
  /* auto-fit, so fifty is not a magic number anywhere. At 1280 this settles
     on six columns; the floor is what stops a logo being shrunk past
     readability on a phone, where it gives two. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
}
.client {
  background: var(--paper-hi);
  border: 1px solid var(--paper-edge);
  border-radius: calc(var(--r) * 0.5);
  aspect-ratio: 5 / 2;
  display: flex; align-items: center; justify-content: center;
  padding: 0.75rem 0.9rem;
  /* No shadow. Fifty lifted plates is a quilt; these are flat objects on a
     ground, which is the rule the cards, panels and filter pills follow. */
}
.client img {
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  object-fit: contain; display: block;
  /* THE LOGOS SHOW IN THEIR OWN ORIGINAL COLOURS, by request (30 Sep 2026).
     They were desaturated (grayscale 0.62, opacity 0.92) and came back on
     hover, which read as a grey shadow over every mark. No filter, no
     opacity: each logo is its owner's artwork exactly as supplied. */
}

.reviews {
  display: grid; gap: clamp(1rem, 2vw, 1.5rem);
  /* THREE COLUMNS, EXPLICITLY, BECAUSE THERE ARE EXACTLY THREE TESTIMONIALS.
     This was auto-fit with a minmax floor, and the floor is what decided
     whether the row wrapped: at 23rem it broke to two-up and orphaned the
     third quote, so it was pinned to 20rem — three columns need <=20.2rem
     each once the gap is out of the ~1017px usable .wrap at 1280.

     That held at 1280 and still wrapped below about 1200, which is a
     wrong-looking row rather than a contrast bug but is the thing people
     actually notice. An explicit track cannot wrap at all, so the orphan case
     stops being reachable instead of being kept just out of reach by a number
     that has to be re-derived every time the gutter or the measure moves.

     The cost is real and deliberate: between 60rem and ~76rem the cards are
     NARROWER than the old 20rem floor — about 17rem at 960px — so each quote
     sets to a tighter measure than it would like. Three-up was chosen over
     ideal measure. Below 60rem none of this applies: the block below turns
     the row into a horizontal snap rail, which is why this rule needs no
     breakpoint of its own. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 60rem) {
  .reviews {
    grid-auto-flow: column; grid-auto-columns: min(86vw, 24rem);
    grid-template-columns: none;
    overflow-x: auto; scroll-snap-type: x mandatory;
    /* Setting overflow-x alone computes overflow-y to auto, so the row clips
       vertically. The pressed card grows ~3%, so it needs headroom here or it
       is shaved off top and bottom on exactly the devices the press is for. */
    padding-block: 0.75rem;
    scrollbar-width: thin;
  }
  .review { scroll-snap-align: start; }
}

.review {
  margin: 0;
  /* A GRADIENT ON A CARD IS A DELIBERATE EXCEPTION HERE. The rule elsewhere
     on this site is that objects standing on a ground stay flat, because a
     gradient under small text makes its contrast depend on where the text
     lands. That rule is relaxed for this one component by request, and the
     cost is paid rather than ignored: the ramp ends on --paper-lo, and every
     text colour in the card is measured against THAT stop, not against the
     white it starts from. Measured at all three stops --
     #FFFFFF / #F1F5F9 / #CBD5E1 -- name 17.85 / 16.30 / 12.02, quote
     10.35 / 9.45 / 6.97, role 7.58 / 6.92 / 5.10. The worst column is the
     one that matters. Deepen the end stop and all three move together.
     168deg is the site-wide ombre angle; this is the same light. */
  background: linear-gradient(168deg,
    var(--paper-hi) 0%, var(--paper-sub) 62%, var(--paper-lo) 100%);
  border: 1px solid var(--paper-edge);
  /* Square, not var(--r). Asked for directly, and it happens to move back
     toward the founding rule this file states up top — "nothing is rounded
     and no shadow is soft; a milled edge is a right angle". That rule drifted
     when --r arrived at 22px. The review cards are square again; the rest of
     the site still rounds, so this is now the inconsistent one. If the whole
     site should follow, set --r to 0 rather than squaring call sites one at a
     time — every rounded object on the page reads from that token. */
  border-radius: 0;
  box-shadow: var(--lift);
  padding: clamp(1.65rem, 3vw, 2.25rem);
  display: flex; flex-direction: column; gap: 1.25rem;
  /* relative so the raised card can out-stack its neighbours; without it the
     next card's border draws over the lifted one's edge. */
  position: relative; z-index: 0;
  transition: transform 280ms var(--ease), box-shadow 280ms var(--ease),
              border-color 280ms var(--ease);
}

/* The pop. A review is a <figure>, not a link, so this deliberately breaks the
   "a panel is a card that is not a link: no hover lift" rule a few blocks up —
   asked for directly, and a quote you can pick out of a row of three is worth
   the exception. It leads nowhere, so nothing here implies a click target:
   no cursor: pointer, no colour change.

   Hover is scoped to devices that actually hover. Unscoped, a tap on a phone
   latches the hover state and the card stays popped until you tap elsewhere —
   the single most common way this effect breaks on iOS. */
/* ------------------------- touch target sizes ---------------------------
   Audited at 375px across all nine templates. The site was already sound on
   the things that usually break — no horizontal overflow anywhere, 16px form
   inputs so iOS does not zoom the page on focus, a 44x44 nav toggle, and both
   pinned sections degrading to static. What it did NOT have was tappable
   inline links: the tel: and mailto: links in the contact list rendered 17-20px
   tall, and on a phone tapping the phone number is the single most likely
   thing anyone does on that page.

   Two techniques, because these links are not all the same kind of box:

   Inline links take vertical padding. On an inline box, padding-block grows
   the hit area WITHOUT growing the line box, so the target reaches 44px and
   nothing on the page moves. The contact rows are ~110px apart, so the
   enlarged areas cannot overlap into each other.

   Flex and inline-flex items are atomic, so padding would shift layout —
   they get an explicit min-height instead and centre their own text.

   The gate is `(pointer: coarse), (max-width: 48rem)`, not `pointer: coarse`
   alone. A touchscreen laptop is coarse at desktop width and needs this; a
   narrow window on a mouse machine is not coarse but is still a phone-shaped
   layout where these links are equally cramped. Either arm is sufficient.
   (It also makes the rule testable: `pointer: coarse` cannot be exercised by
   resizing a desktop browser, which is how the first version of this block
   silently did nothing.) */
@media (pointer: coarse), (max-width: 48rem) {
  /* Every tel:/mailto: on the site, not just the ones in .contact-list —
     one of them lives inline in a form-note paragraph and was 17px tall.
     Matching on the href is what makes this hold for links added later. */
  .contact-list a,
  a[href^="tel:"],
  a[href^="mailto:"] { padding-block: 0.85rem; }

  .crumbs a,
  .tlink {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  .brand { min-height: 44px; }
}

@media (hover: hover) and (pointer: fine) {
  .review:hover {
    transform: translateY(-8px) scale(1.035);
    box-shadow: var(--lift-2);
    border-color: var(--mark-ink);
    z-index: 2;
  }
}

/* Touch gets the same gesture on press. Slightly shallower, because a finger
   is already covering the card and a big jump under it reads as a glitch. */
.review:active {
  transform: translateY(-4px) scale(1.02);
  box-shadow: var(--lift-2);
  border-color: var(--mark-ink);
  z-index: 2;
}

/* Reduced motion keeps the feedback and drops the movement: the shadow and
   border still answer the cursor, nothing travels. */
@media (prefers-reduced-motion: reduce) {
  .review { transition: box-shadow 160ms linear, border-color 160ms linear; }
  .review:hover, .review:active { transform: none; }
}
/* The initial in a ring, not a face. This is the settled answer to "put
   photos on the testimonials", which has now been asked twice.

   Synthetic headshots were declined both times. Urmil Vaidhya, Jatin Patel
   and Hudson Taylor are real, identifiable people named here with their
   organisations, so a generated face published as one of their portraits is
   a fabricated likeness presented as genuine, and none of them consented to
   one. That is the failure the content-provenance rule in CLAUDE.md exists to
   prevent: these quotes are transcribed verbatim because an earlier pass
   invented content and all of it had to be replaced.

   A monogram impersonates nobody, which is why it is here and a portrait is
   not. If real photographs ever arrive from the client, with permission, they
   can replace it. Nothing else may. */
/* The head wraps rather than crushes. Forcing three columns takes the card
   down to ~249px at a 1024 viewport, and the monogram is a fixed 3.25rem that
   does not yield — so the name block took the whole loss and fell to 114px,
   which broke "Hudson Taylor" over two lines and ran the role to five. The
   min-width makes the name block refuse to go below 9rem; when it no longer
   fits beside the monogram the row wraps and the monogram sits above instead,
   giving the text the full card width.

   This is deliberately not a breakpoint. The card's width depends on the
   viewport, the --rail margin and the gutter clamp all at once, so any pixel
   value picked here would be right at one size and wrong after the next
   change to any of the three. Measured: the row holds at 1180 (156px
   available) and 1280 (185px), and wraps at 1024 (123px). */
.review-head { display: flex; align-items: center; gap: 0.85rem; flex-wrap: wrap; }
/* One fill, not the reference's six. Coloured avatars are a lot of the charm
   there, but this site allows exactly one saturated colour and spending it on
   six random hues would cost the logo its status as the only one. */
.review-avatar {
  flex: none; width: 3.25rem; height: 3.25rem; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--brand); color: var(--paper-hi);
  font-family: var(--display); font-size: 1.35rem; line-height: 1;
}
.review-who { display: flex; flex-direction: column; gap: 0.15rem; min-width: 9rem; flex: 1; }
.review-who b { font-weight: 500; color: var(--on-light); }
.review-who > span {
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--on-light-mute); line-height: 1.4;
}
.review blockquote {
  margin: 0; padding-top: 1.25rem;
  border-top: 1px solid var(--paper-edge);
  font-size: 1rem; line-height: 1.66;
  /* The quote is what the card is FOR, and at --on-light-mute (7.58:1 on the
     card's white fill) it was the palest text on it — the caption colour doing
     the work of body copy. --mark-ink is 10.35:1 and still sits a clear step
     under the name at --on-light (17.85:1), so the three levels stay ordered:
     name, quote, then role. */
  color: var(--mark-ink);
}
.review blockquote::before { content: '\201C'; }
.review blockquote::after  { content: '\201D'; }

/* The cards carry their own paper fill, so they are opaque objects on whatever
   ground the section uses and none of the .dark text tokens reach inside. */
.dark .review-who b { color: var(--on-light); }
.dark .review blockquote { color: var(--mark-ink); }

/* =========================== registration marks =========================
   Four L-brackets in `currentColor`, drawn as eight background-gradient
   strokes on a single pseudo-element — the corner marks a print shop or a
   drafting sheet puts on anything meant to be checked against a reference.
   Reserved for things that read as a mounted specimen or a sealed record:
   the card, the panel, the quote, the product aside, the sector tile. Pure
   CSS — no markup change, so it reaches every page and every future card. */
.card, .panel, .quote, .pd-aside, .sector { position: relative; }
.card::before, .panel::before, .quote::before, .pd-aside::before, .sector::before {
  content: ''; position: absolute; inset: 0.6rem; z-index: 6; pointer-events: none;
  color: var(--mark-ink); opacity: 0.55;
  background:
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat,
    linear-gradient(currentColor, currentColor) no-repeat;
  background-size:
    1px 0.8rem, 0.8rem 1px,
    1px 0.8rem, 0.8rem 1px,
    1px 0.8rem, 0.8rem 1px,
    1px 0.8rem, 0.8rem 1px;
  background-position: top left, top left, top right, top right,
                        bottom left, bottom left, bottom right, bottom right;
}
.dark .card::before, .dark .panel::before, .dark .quote::before,
.dark .pd-aside::before, .sector::before { color: var(--mark); }


/* =========================== the NRC bar ================================
   The signature of the "Coefficient" direction, and the only place besides
   the logo where colour appears on this site.

   NRC is already a proportion — 0.85 means 85% of incident sound energy is
   absorbed — so the bar states the number rather than illustrating it. The
   filled run is the absorbed share in the material's own colour; the empty
   run is the paper, i.e. what comes back off the wall. That reading only
   holds if the track stays the page ground, so do not tint it.

   Only products with a published NRC get one. `nrcOf` in src/build.js
   returns null otherwise and the component renders nothing — see the
   content-provenance note in CLAUDE.md before adding a figure here. */
.nrc { display: grid; gap: 0.5rem; margin: 0.15rem 0 0.1rem; }

.nrc-track {
  position: relative; height: 8px;
  background: var(--paper-lo);
  border: 1px solid var(--paper-edge);
}
.nrc-track > i {
  display: block; height: 100%; width: var(--v, 0%);
  background: var(--mat, var(--mat-pet));
}
/* the 0.5 gridline: where half the energy would be absorbed, so the eye has
   something to measure the fill against rather than judging a bare bar */
.nrc-track::after {
  content: ''; position: absolute; top: -3px; bottom: -3px; left: 50%;
  width: 1px; background: var(--paper-edge);
}

.nrc-fig {
  display: flex; align-items: baseline; gap: 0.5rem;
  font-family: var(--mono); font-size: var(--t-mono); line-height: 1.35;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--on-light-mute); margin: 0;
}
/* the qualifier the client publishes ("up to …") travels with the figure and
   is never dropped — on the product page it is spelled out, on a card it is
   hidden rather than truncated, because a clipped "up to 0.6 (9 mm) / up…"
   reads as a different claim than the one they make. */
.nrc-raw::before { content: '\00b7'; margin: 0 0.4em; opacity: 0.75; }

.nrc-fig b {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 20;
  font-size: 1.25rem; letter-spacing: -0.01em; line-height: 1;
  color: var(--on-light); font-feature-settings: 'tnum' 1;
}

/* which material this is. The five families keep their own temperatures —
   a cyan foam swatch stops looking like foam. */
.nrc[data-mat="pet"]   { --mat: var(--mat-pet); }
.nrc[data-mat="cloud"] { --mat: var(--mat-cloud); }
.nrc[data-mat="foam"]  { --mat: var(--mat-foam); }
.nrc[data-mat="wood"]  { --mat: var(--mat-wood); }
.nrc[data-mat="proof"] { --mat: var(--mat-proof); }

/* No .dark override here, deliberately. NRC bars only ever appear inside
   .card, and `.dark .card` repaints itself with --ink-panel, which is one of
   the two ink tokens held at a LIGHT value on purpose — so a bar in a .dark
   section is still sitting on a light card and its paper/on-light tokens are
   already correct. An earlier pass added a dark track and on-dark figures
   here on the assumption that .dark cards darken with the section. They do
   not, and the override painted a dark slab with invisible figures onto a
   white card. If --ink-panel is ever darkened, this is where the four rules
   go back. */

/* on a card the caption drops to the figure alone — the client's full
   wording is on the product page, and two lines of mono in a card body
   crowds out the product name */
.nrc-sm { gap: 0.4rem; }
.nrc-sm .nrc-track { height: 6px; }
.nrc-sm /* the qualifier the client publishes ("up to …") travels with the figure and
   is never dropped — on the product page it is spelled out, on a card it is
   hidden rather than truncated, because a clipped "up to 0.6 (9 mm) / up…"
   reads as a different claim than the one they make. */
.nrc-raw::before { content: '\00b7'; margin: 0 0.4em; opacity: 0.75; }

.nrc-fig b { font-size: 1.05rem; }
.nrc-sm .nrc-raw { display: none; }   /* the full published string is on the product page */

/* On the product page the bar is the headline figure, so it runs at the
   measure of the lead rather than the full column and keeps the client's
   own wording ("up to 0.85 (12 mm)") beside it. */
.nrc-lead { max-width: 26rem; margin: 1.5rem 0 0; gap: 0.65rem; }
.nrc-lead .nrc-track { height: 12px; }
.nrc-lead /* the qualifier the client publishes ("up to …") travels with the figure and
   is never dropped — on the product page it is spelled out, on a card it is
   hidden rather than truncated, because a clipped "up to 0.6 (9 mm) / up…"
   reads as a different claim than the one they make. */
.nrc-raw::before { content: '\00b7'; margin: 0 0.4em; opacity: 0.75; }

.nrc-fig b { font-size: 1.75rem; }

/* ============================ the notes list ============================
   One article today, and the shape has to survive both that and twenty.
   A three-column grid could do neither: at one post it left two thirds of
   the row empty, and it is the grid-of-equal-cards the rest of the site
   avoids.

   The first note runs wide with its surface beside it; any note after it is
   a compact index row, the same move `.cat-grid` makes for the first product
   in a family. Adding posts needs no layout change. */
.notes { list-style: none; margin: 0; padding: 0; }
.note { border-top: 1px solid var(--paper-edge); }
.note:last-child { border-bottom: 1px solid var(--paper-edge); }
.note > a {
  display: grid; gap: 1.25rem; align-items: start;
  padding: 1.75rem 0; text-decoration: none; color: inherit;
  transition: color 320ms var(--ease);
}
.note-surface { display: none; border-radius: var(--r-sm); }
.note-body { display: grid; gap: 0.6rem; }

.note-meta {
  display: flex; flex-wrap: wrap; gap: 0.55rem;
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--on-light-mute);
}
.note-meta i, .note-meta time { font-style: normal; }
.note-meta i::before, .note-meta time::before { content: '\00b7'; margin-right: 0.55rem; }

.note-t {
  font-family: var(--display); font-weight: var(--w-display);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 24;
  font-size: var(--t-h3); line-height: 1.1; letter-spacing: -0.012em;
  color: var(--on-light); text-wrap: balance;
}
.note-d { font-size: 0.9375rem; line-height: 1.6; color: var(--on-light-mute); max-width: 54ch; }
.note-go {
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--brand-ink);
  display: inline-flex; align-items: center; gap: 0.45rem;
}
.note > a:hover .note-t { color: var(--brand-ink); }
.note > a:hover .btn-arrow { transform: translateX(3px); }

/* The notes list sits in blog.html's "Articles" section, which is .dark, and
   .note paints NO ground of its own — only a hairline rule — so unlike a card
   this text composites directly onto the section. That made it the one
   component still naming paper tokens while ~20 sibling `.dark X` rules had
   already been moved onto --on-dark*, so it would have been the one block
   left behind when --ombre-dark darkens. It now moves with them: correct or
   broken exactly when `.dark .lead` is, which is the point. */
.dark .note, .dark .note:last-child { border-color: var(--ink-edge); }
.dark .note-t { color: var(--on-dark); }
.dark .note-d, .dark .note-meta { color: var(--on-dark-mute); }

/* the lead note: the surface joins it, and the title steps up a size */
@media (min-width: 46rem) {
  .note-lead > a { grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 2rem; padding: 2.25rem 0; }
  .note-lead .note-surface { display: block; aspect-ratio: 4 / 3; }
  .note-lead .note-t {
    font-size: var(--t-h2); font-variation-settings: 'wdth' var(--wd-h2), 'opsz' 40;
    letter-spacing: -0.018em;
  }
  .note-lead .note-body { gap: 0.75rem; }
}

/* A second-level heading inside a column or panel — "Send the details",
   "Direct", "Others in Acoustic Wood". These are h2 for the document
   outline but sit at h3 in the visual scale, which is a real distinction
   and so belongs in a class rather than in seven inline font-sizes. The
   width follows the scale step, because on this site hierarchy is width. */
.sub-h {
  font-size: var(--t-h3);
  font-variation-settings: 'wdth' var(--wd-h3), 'opsz' 20;
}
.mb-s { margin-bottom: 0.6rem; }
.mb-m { margin-bottom: 1rem; }
.mb-l { margin-bottom: 1.5rem; }

/* ========================= the floating controls ========================
   Back-to-top on the left, WhatsApp on the right, both revealed once the
   reader is a screen down. Split to opposite corners deliberately: stacked in
   one corner, a thumb reaching for one lands on the other.

   The wrapper is the full width of the viewport and does not take pointer
   events; only the two controls do. That is what puts one hard left and one
   hard right without either needing its own fixed offset, and it keeps the
   safe-area inset in one place for iPhone home-indicator clearance.

   z-index 120 sits above the page and below the mobile nav overlay at 150,
   so an open menu covers them rather than the reverse.

   Visibility is toggled by site.js, not motion.css: a way back to the top of
   a 15,000px page is a control, not an ornament, and motion.js returns early
   under prefers-reduced-motion — putting it there would leave it dead for the
   readers most likely to want it. */
.floats {
  position: fixed;
  /* BELOW the header, not above it. .site-head is z-index 100 and the mobile
     nav panel lives inside it, so at 120 these two buttons painted over an
     open menu. They sit at the bottom of the viewport and the header at the
     top, so nothing is lost by yielding the stack — and an open full-screen
     menu now covers them for free, with no :has() guard and no JS. */
  z-index: 90;
  left: 0; right: 0; bottom: 0;
  display: flex; justify-content: space-between; align-items: flex-end;
  padding: clamp(1rem, 3vw, 1.75rem);
  padding-bottom: calc(clamp(1rem, 3vw, 1.75rem) + env(safe-area-inset-bottom, 0px));
  pointer-events: none;
}
.float {
  pointer-events: auto;
  width: 3rem; height: 3rem;          /* 48px — over the 44px tap floor */
  display: grid; place-items: center;
  border-radius: 50%; border: 1px solid transparent;
  background: var(--brand-deep); color: var(--on-light);
  box-shadow: var(--lift);
  cursor: pointer;
  /* hidden until scrolled: visibility as well as opacity, so a hidden
     control is not reachable by keyboard */
  opacity: 0; visibility: hidden; transform: translateY(0.5rem);
  transition: opacity 260ms var(--ease), transform 260ms var(--ease),
              visibility 0s linear 260ms, background 200ms var(--ease);
}
.floats.on .float {
  opacity: 1; visibility: visible; transform: none;
  transition-delay: 0s;
}
.float svg { width: 1.35rem; height: 1.35rem; display: block; }
.float:hover { background: var(--brand-lift); }
.float:active { transform: translateY(1px); transition-duration: 120ms; }
.float-top { border: 0; }

/* The reveal is the only animated part, and it is the part that can go. The
   controls still appear and disappear, they just do it without travelling. */
@media (prefers-reduced-motion: reduce) {
  .float { transform: none; transition: opacity 120ms linear, visibility 0s linear 120ms; }
  .floats.on .float { transform: none; transition-delay: 0s; }
  .float:active { transform: none; }
}


/* ---------------------------- image lightbox ----------------------------
   Opened from .gal-zoom, which wraps every gallery surface in a real <a href>
   to the image file so the whole thing degrades to "the link opens the
   photograph" with the script blocked.

   The tile crops: .gal-surface is 3/2 with object-fit: cover over a 760x570
   frame. Here the image is object-fit: contain so the full frame shows, which
   is the point of the interaction.

   Capped at 960px on purpose. The source photographs are 760x570, so 960 is
   already a 1.26x upscale and anything more starts to look soft. If
   higher-resolution originals ever arrive this is the number to raise.

   The caption sits on its own opaque paper strip below the image rather than
   over it, so its contrast is a normal token pair the walker can score
   instead of something a scrim has to guarantee.

   z-index is above .site-head (100), which is sticky and would otherwise
   sit over the backdrop. */
.lb {
  position: fixed; inset: 0; z-index: 200;
  display: grid; place-items: center;
  padding: clamp(1rem, 4vw, 3rem);
  background: rgba(11, 13, 16, 0.82);
  /* NO OPENING FADE, deliberately, and do not add one back.
     Two attempts failed the same way. First a class toggled in
     requestAnimationFrame: rAF does not fire in a background or non-painting
     tab, so the dialog unhid at opacity 0 and stayed invisible with focus
     trapped on its close button. Then a CSS animation with fill-mode `both`:
     animations are starved in the same conditions, and `both` holds the
     `from` keyframe, so it was invisible again for exactly the same reason.
     Measured both times — computed opacity 0 at 400ms after opening.

     A modal that can be invisible while holding focus is a trap, and 200ms of
     polish is not worth that. It is opaque the moment it unhides. If you want
     an entrance, animate the figure inside it, never the layer that carries
     the backdrop and the focus. */
}
.lb[hidden] { display: none; }

.lb-fig {
  margin: 0; max-width: min(94vw, 960px); max-height: 88vh;
  display: flex; flex-direction: column;
  background: var(--paper-hi);
  border-radius: var(--r); overflow: hidden;
  box-shadow: var(--lift-2);
}
.lb-fig img {
  display: block; width: 100%; min-height: 0;
  max-height: calc(88vh - 4.5rem);
  object-fit: contain; background: var(--paper-sub);
}
.lb-fig figcaption {
  flex: none; padding: 0.9rem 1.15rem 1rem;
  font-family: var(--mono); font-size: var(--t-mono);
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--on-light-mute);
  border-top: 1px solid var(--paper-edge);
}

.lb-close {
  position: absolute; top: clamp(0.75rem, 2.5vw, 1.5rem);
  right: clamp(0.75rem, 2.5vw, 1.5rem);
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  padding: 0.7rem 1.15rem; border-radius: var(--r-pill); cursor: pointer;
  background: var(--paper-hi); color: var(--on-light);
  border: 1px solid var(--paper-edge);
}
.lb-close:hover { background: #FFFFFF; }

/* A tile is now interactive, so say so. */
.gal-zoom { display: block; cursor: zoom-in; text-decoration: none; }


