/* =============================================================
   JOSHUA R. BERNARD — Portfolio
   Design tokens → base → components → responsive → a11y
   Change --accent below to re-theme the whole site.
   ============================================================= */

/* =============================================================
   SELF-HOSTED — Casual Human (TOMATSTUDIO), a purchased font, 2026-08-29.
   Joshua supplied the OTFs; they are converted to WOFF2 and live in
   assets/fonts/. It is the ONLY family not served by Google, and the reason
   the "no local font file" note below is no longer strictly true.

   BOTH FACES ARE DECLARED, ONLY ONE IS FETCHED. An @font-face declaration is
   lazy — the browser downloads the file only when a rule actually matches that
   family AND weight. The hero asks for 700, so Regular costs nothing sitting
   here and is one number away if the Bold reads too heavy at hero scale.

   font-display:swap so the rows paint in the fallback immediately rather than
   holding a blank hero for up to 3s on a slow connection; ticker() already
   re-measures on document.fonts.ready, so the marquee re-fills itself when the
   real face lands and the swap does not leave a short row. */
@font-face{
  font-family:"Casual Human";
  src:url("../assets/fonts/CasualHuman-Regular.woff2") format("woff2");
  font-weight:400; font-style:normal; font-display:swap;
}
@font-face{
  font-family:"Casual Human";
  src:url("../assets/fonts/CasualHuman-Bold.woff2") format("woff2");
  font-weight:700; font-style:normal; font-display:swap;
}
/* Stars Soul Brush — trial, 2026-08-29. Superseded by Branders below but kept
   declared; it costs nothing unless --kinetic names it. NOTE its fsType is 4
   ("Preview & Print"), not the 0 the other two carry — check the purchase
   terms before shipping that one. */
@font-face{
  font-family:"Stars Soul Brush";
  src:url("../assets/fonts/StarsSoulBrush-Regular.woff2") format("woff2");
  font-weight:400; font-style:normal; font-display:swap;
}
/* Branders (condensed, handmade) — TRIAL, 2026-08-29. fsType 0. The closest of
   the three trials to the Anton role this slot was built for: cap height .85em
   against Casual Human's .749 and Stars Soul's .720, and condensed, so it
   carries display scale without the width. Ships Light as a SEPARATE family
   name ("Branders Light"), which is why only Regular and Bold are mapped onto
   the one family here. */
@font-face{
  font-family:"Branders";
  src:url("../assets/fonts/Branders-Regular.woff2") format("woff2");
  font-weight:400; font-style:normal; font-display:swap;
}
@font-face{
  font-family:"Branders";
  src:url("../assets/fonts/Branders-Bold.woff2") format("woff2");
  font-weight:700; font-style:normal; font-display:swap;
}
/* Brooks — TRIAL, 2026-08-29. The only one of the four that shipped a Web Font
   folder with WOFF2 already built, so these are the VENDOR'S OWN files copied
   in rather than converted from the OTF here. 440 glyphs, by far the widest
   coverage of the four. Single weight plus a true Oblique, which is mapped to
   font-style:italic rather than to a weight — nothing on this site asks the
   hero for italic, so the Oblique will never be fetched unless something does.
   fsType is 4 like Stars Soul Brush, but the vendor packaging webfonts is a
   fairly clear signal web use is intended; the purchase terms still decide. */
@font-face{
  font-family:"Brooks";
  src:url("../assets/fonts/Brooks-Regular.woff2") format("woff2");
  font-weight:400; font-style:normal; font-display:swap;
}
@font-face{
  font-family:"Brooks";
  src:url("../assets/fonts/Brooks-Oblique.woff2") format("woff2");
  font-weight:400; font-style:italic; font-display:swap;
}

/* =============================================================
   FONTS — the other four come from Google; see the <link> in index.html.

   Four are DESIGN families and are listed below. The fifth, Noto Sans Tamil,
   is a SCRIPT fallback and has no token: none of the four carry Tamil glyphs,
   and one case study sets Tamil inside its body copy. It is scoped to
   `.doc .ta` further down the file and can reach nothing else.

   --display  Archivo, variable "wdth"/"wght". The display type across
              this file drives those axes via font-variation-settings.
   --voice    Bricolage Grotesque. No italic exists, so the accent rules
              set font-style:normal explicitly — <em> is italic by UA
              default and would otherwise be synthesised into an oblique.
   --mono     Space Mono, static 400/700.
   --kinetic  Casual Human, static 400/700, SELF-HOSTED (see the @font-face
              block above). Was Anton until 2026-08-29. ONE component — the
              three hero rows. It is a
              deliberate fourth family in a system built on three: Archivo
              at its narrowest ("wdth" 62) is still far wider than the
              ultra-condensed face the hero needs, and the whole point of
              those rows is how extreme the letterforms are. Scoped to one
              token so swapping it (Bebas Neue, Big Shoulders Display) is a
              one-line change. Do not reach for it anywhere else — the
              moment it appears in a second component the system is four
              families, not three-plus-an-exception.

   HISTORY, so neither is retried blind: SCHABO was trialled as the
   display face and Doto Rounded as the mono, both on 2026-08-09, and
   both reverted. Doto's dots only separate at low weight (grid pitch is
   a fixed .10em, weight sets dot diameter), so no font-size could make
   the supplied ExtraBold read as dots. Both font files were deleted on
   2026-08-10; the reasons above are the part worth keeping.

   NOTE on axes: body sets font-variation-settings, and that value is
   INHERITED. An inherited axis beats the font-weight property, so any
   variable font used here must set "wght" inside font-variation-settings
   rather than relying on font-weight. Space Mono is static, so its rules
   need nothing.
   ============================================================= */
:root{
  /* ---- Colour ---- */
  --paper:      #F2F0EA;
  --paper-2:    #E7E4DA;
  --ink:        #0C0C0C;
  --ink-2:      #1A1A1A;
  --accent:     #D6FF3F;   /* acid lime — single point of change */
  /* The accent darkened until it survives PAPER. Lime itself measures 1.01:1
     on bone and is unusable as text there — it only ever works as a FILL with
     ink on top (.foot__top, the nav pill). This is the same hue taken down to
     5.2:1 on --paper, so the accent can finally be text on a light ground.
     Used by the contact address. Do not use it on the ink bands: it is far too
     dark against them, where plain --accent is already 16.99:1. */
  --accent-ink: #5A6B00;
  /* 4.54:1 on --paper. Was #8A877C (3.16:1), which failed AA at every size it
     was used: tag pills, contact headings, studio details, footer, and the
     three bracketed section labels. 21% toward ink — still reads as grey. */
  --muted:      #706D64;

  /* ---- Hero letter paint ----
     The eight shades the cursor deals out across the hero rows; see
     .hero__ch further down and heroPaint() in js/main.js.

     GREYSCALE, Joshua's call 2026-08-20 — an even warm-grey ramp from
     near-paper down to mid, all warm-toned to sit with bone/--muted rather
     than a neutral computer grey. Every step is clearly lighter than the ink
     the letters rest at, so a lit letter reads as the letterform LIFTING out
     of the row; the lightest steps flash like a ghost, the darkest barely
     whisper. Decorative only (the rows are aria-hidden; the h1 carries the
     real text), so no contrast floor applies.

     His eight COLOURS from earlier the same day, kept because there is no
     git history to recover them from:
     #F5042E #FFBAFF #FE5802 #FEE300 #02D8EE #5400BC #A7FD5A #085E55

     LENGTH IS LOAD-BEARING: LIT in ticker() must match the number of
     --lit-* tokens here and the number of .hero__ch--n rules below. */
  --lit-1:      #D6D3CA;
  --lit-2:      #C2BFB6;
  --lit-3:      #AEABA2;
  --lit-4:      #9A978E;
  --lit-5:      #86837A;
  --lit-6:      #726F66;
  --lit-7:      #5E5B53;
  --lit-8:      #4A4840;

  /* ---- Type ----
     Fallbacks are load-bearing: if a font fails to load the layout still holds.

     --voice was called --serif until 2026-08-09. It no longer holds a serif —
     Bricolage Grotesque is a grotesque sans — and a token named --serif
     containing a sans is exactly the kind of thing that misleads a later
     session. It is the accent voice against the display face, hence the name.
     It is the one family still served by Google rather than self-hosted. */
  --display: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --voice:   "Bricolage Grotesque", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --mono:    "Space Mono", ui-monospace, "SF Mono", Menlo, monospace;
  /* Casual Human replaced Anton here on 2026-08-29 (Joshua's call). Anton is
     no longer requested from Google — see the <link> in index.html.
     Archivo stays the fallback rather than Helvetica: if the local file fails,
     the hero should degrade to the site's own display face, not a system sans. */
  --kinetic: "Brooks", "Branders", "Casual Human", "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif;

  /* ---- Glass ----
     Used ONLY by the overlay layer — things that float above content: the nav
     pills, the mobile menu and the case-study close button. Deliberately not
     applied to in-flow surfaces (tag pills, accordion rows, logo cells, tiles).
     Three reasons, all mechanical rather than aesthetic:
       1. backdrop-filter blurs what is behind it. Blur a flat colour and you
          get the same flat colour — on this mostly-flat site it would cost a
          GPU pass and buy nothing.
       2. Glass is a hierarchy signal. Applied everywhere, nothing reads as
          elevated and the effect cancels itself out.
       3. Each backdrop-filter element forces its own compositing layer and a
          blur pass per frame. The 26 continuously-animating logo cells, 10
          tiles and 6 accordion rows are where scroll jank would appear.

     --glass-bg is a CONTRAST FLOOR, not a taste setting. Over the bone
     sections .62 mixes to #636360, giving paper text 5.29:1. The .38 tried
     first mixed to #9A9996 and measured 2.49:1 — far below AA, on primary
     navigation. Over the ink bands any alpha passes (17.17:1), so bone sets
     the limit. Re-measure before lowering it. */
  --glass-bg:     rgba(12,12,12,.62);
  --glass-bg-max: rgba(12,12,12,.86);   /* full-screen surfaces read over arbitrary content */
  --glass-blur:   blur(16px) saturate(1.5);
  --glass-edge:   1px solid rgba(242,240,234,.16);

  /* ---- Layout ---- */
  --pad:  clamp(1.15rem, 4vw, 4.5rem);
  --gap:  clamp(1rem, 2.2vw, 2rem);
  --rule: 1px solid rgba(12,12,12,.16);
  --rule-inv: 1px solid rgba(242,240,234,.18);

  /* ---- Motion ---- */
  --ease: cubic-bezier(.22,1,.36,1);
  --dur:  .8s;
}

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

html{
  -webkit-text-size-adjust:100%;
  scroll-behavior:smooth;
  /* RESERVE THE SCROLLBAR GUTTER ALWAYS. body.is-locked is overflow:hidden, so
     opening any overlay used to take the page scrollbar away and hand its
     ~15px back to the content — every centred thing on the page jumped ~8px
     sideways at the moment a case study opened, and jumped back on close.
     Invisible for years because the wipe covered the page as it happened; the
     zoom open in js/main.js made it obvious, because it measures a tile rect
     on one side of that jump and animates to it on the other.
     Costs nothing in the normal state: the gutter is already occupied by a
     real scrollbar on every page here. It only changes the LOCKED state, by
     not letting it reflow.
     Not supported before Safari 18.2, so openCase()/closeCase() still measure
     symmetrically — both in the unlocked state — and degrade to the old jump
     rather than to a mis-registered zoom. */
  scrollbar-gutter:stable;
}

body{
  margin:0;
  background:var(--paper);
  color:var(--ink);
  font-family:var(--display);
  font-variation-settings:"wdth" 100,"wght" 400;
  font-size:clamp(.95rem,1.05vw,1.0625rem);
  line-height:1.5;
  /* CLIP, NOT HIDDEN — and this is load-bearing for Safari.
     `overflow-x:hidden` here made Safari compute `overflow-y:auto` on the body
     (the spec turns a `visible` axis into `auto` when the other axis is neither
     `visible` nor `clip`), which makes the body a scroll container. The pinned
     Featured Work head then resolved its stickiness against the BODY's scroll
     box instead of the viewport, and since the body is not what scrolls, it
     never pinned at all — it scrolled away and the giant wordmark vanished from
     behind the tiles. Measured 2026-08-24: Safari had the head 888px above the
     viewport where Chrome had it at 0.
     Chrome hid the bug by propagating body overflow to the viewport and keeping
     the document as the sticky container, so it looked correct there for months.
     `clip` is exempt from that computed-value rule, so overflow-y stays
     `visible`, no scroll container is created, and sticky survives. It still
     clips horizontal overflow, which is all this was ever for.
     Do NOT put it back to `hidden`. See .sec-head--work. */
  overflow-x:clip;
  -webkit-font-smoothing:antialiased;
}
body.is-locked{ overflow:hidden; }

img{ display:block; max-width:100%; height:auto; }
h1,h2,h3,p,dl,dd,figure{ margin:0; }
ul,ol{ margin:0; padding:0; list-style:none; }
a{ color:inherit; text-decoration:none; }
button{ font:inherit; color:inherit; background:none; border:0; padding:0; cursor:pointer; }

::selection{ background:var(--accent); color:var(--ink); }

:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:3px;
  border-radius:2px;
}

.skip-link{
  position:fixed; top:-100px; left:var(--pad); z-index:9999;   /* focus pos below clears --ruler-h */
  background:var(--ink); color:var(--paper);
  padding:.75rem 1.25rem; font-family:var(--mono); font-size:0.7rem;
  text-transform:uppercase; letter-spacing:.12em;
  transition:top .3s var(--ease);
}
.skip-link:focus{ top:calc(var(--ruler-h) + .6rem); }   /* clears the rule */

/* Visually hidden, still announced. Used by the hero <h1>, which carries the
   only occurrence of the full name in the document — the rows above it are
   aria-hidden roles and the nav mark is an image. display:none or
   visibility:hidden would remove it from the accessibility tree as well and
   leave the page with no accessible name at all.
   No :focus escape hatch here, unlike .skip-link — nothing inside is
   focusable, and adding one would drop an unstyled heading into the layout. */
.u-sr{
  position:absolute; width:1px; height:1px;
  margin:-1px; padding:0; border:0;
  overflow:hidden; clip-path:inset(50%); white-space:nowrap;
}

/* ---- Shared type helpers ---- */
.label,
.hero__scroll,
.about__label,
.exp__label,
.clients__label,
.contact__label,
.foot{
  font-family:var(--mono);
  font-size:0.68rem;
  text-transform:uppercase;
  letter-spacing:.14em;
}

/* =============================================================
   GRAIN
   ============================================================= */
.grain{
  position:fixed; inset:-50%;
  z-index:9000; pointer-events:none;
  opacity:.32;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
  mix-blend-mode:multiply;
  animation:grain 6s steps(6) infinite;
}
@keyframes grain{
  0%,100%{ transform:translate(0,0) }
  20%{ transform:translate(-3%,2%) }
  40%{ transform:translate(2%,-3%) }
  60%{ transform:translate(-2%,-2%) }
  80%{ transform:translate(3%,1%) }
}

/* =============================================================
   PRELOADER
   ============================================================= */
.preloader{
  position:fixed; inset:0; z-index:9500;
  background:var(--ink); color:var(--paper);
  display:flex; align-items:flex-end;
  padding:var(--pad);
  /* .525s, halved from 1.05s with the rest of the preloader sequence. The
     removal timeout in preloader() (js/main.js) is 550ms and must stay ABOVE
     this number — shorten the slide freely, but never past that. */
  transition:transform .525s var(--ease);
}
.preloader__inner{
  width:100%;
  display:flex; align-items:baseline; justify-content:space-between;
  gap:1rem;
  border-top:var(--rule-inv);
  padding-top:1.1rem;
}
.preloader__name{
  font-family:var(--mono); font-size:0.7rem;
  letter-spacing:.2em; text-transform:uppercase;
  opacity:0; transform:translateY(.6em);
  /* Halved too — at .7s this intro ran nearly the whole of the shortened
     count, so the name was still arriving as the counter hit 100. */
  animation:plIn .35s var(--ease) .075s forwards;
}
.preloader__count{
  font-weight:900; font-variation-settings:"wdth" 125,"wght" 900;
  font-size:clamp(4rem,17vw,15rem);
  line-height:.78; letter-spacing:-.04em;
  color:var(--accent);
}
@keyframes plIn{ to{ opacity:1; transform:none } }
.preloader.is-done{ transform:translateY(-101%); }
html.is-loading{ overflow:hidden; }

/* =============================================================
   CURSOR
   Removed on 2026-08-20 and RESTORED the same day with the missing half:
   the dot used to be drawn while the NATIVE pointer was still visible, so it
   read as two cursors. .has-cursor below is what fixes that.
   ============================================================= */
/* HIDING THE SYSTEM POINTER. Scoped to html.has-cursor, and that scope is the
   safety: cursor() in js/main.js adds the class only AFTER its own guards pass
   (element present, not reduced-motion, not a coarse pointer). So a touch
   device, a reduced-motion visitor and anyone whose JS never runs all keep the
   system cursor. Hiding the native pointer with no replacement drawn is the
   one genuinely broken state available here, and no code path reaches it.
   The universal selector is needed, not belt-and-braces: `cursor` is not
   inherited for this purpose — every element resolves its own, and
   `button{cursor:pointer}` further up plus the UA's pointer on <a> would
   punch the system cursor back through on exactly the things you hover most.
   No !important: `html.has-cursor *` is 0-1-1 and outranks both. */
html.has-cursor, html.has-cursor *{ cursor:none; }
.cursor{
  position:fixed; top:0; left:0; z-index:9200;
  width:11px; height:11px; margin:-5.5px 0 0 -5.5px;
  border-radius:50%; background:var(--ink);
  pointer-events:none;
  display:grid; place-items:center;
  transition:width .35s var(--ease), height .35s var(--ease),
             margin .35s var(--ease), background .35s var(--ease);
}
/* Specular highlight — what makes the active state read as a lens rather than
   a disc. On a pseudo-element rather than in `background` because that property
   is transitioned, and a colour cannot interpolate to a gradient: putting the
   sheen there would snap instead of fade. Absolutely positioned, so it is out
   of flow and never becomes a grid item beside the label. */
.cursor::before{
  content:""; position:absolute; inset:0; border-radius:50%;
  background:radial-gradient(120% 120% at 30% 22%,
             rgba(242,240,234,.20), rgba(242,240,234,0) 52%);
  opacity:0; transition:opacity .35s var(--ease);
}
/* The label carries its OWN backing, and that is the whole trick. The tint used
   to live on the 78px disc, which is what made the button underneath
   unreadable — a .62 veil over the ink Projects pill is dark-on-dark. Moving
   the solid onto a chip (measured 65x21px against a 4778px² lens) keeps the
   label at 17.17:1 on any backdrop while leaving ~70% of the lens clear, and
   more importantly leaves the RIM clear, which is where you read the shape of
   whatever you are pointing at. */
.cursor__label{
  font-family:var(--mono); font-size:0.6rem; font-weight:700;
  letter-spacing:.1em; text-transform:uppercase;
  color:var(--paper); background:var(--ink);
  padding:.32em .6em; border-radius:100px;
  opacity:0; white-space:nowrap;
  position:relative;            /* above the sheen */
  transition:opacity .25s var(--ease);
}
/* No text, no chip. setCursor() in js/main.js writes `t.dataset.cursor || ''`,
   so every plain <a> and <button> — the whole nav — gets an EMPTY label. Once
   the chip gained a background and padding, empty still painted: a small ink
   blob parked in the middle of the lens, over the link you were reading.
   :empty is exact here because the span holds a single text node and nothing
   else; textContent = '' leaves it genuinely childless. */
.cursor__label:empty{ display:none; }
/* GLASS LENS, not a lime disc (2026-08-16). The solid --accent fill covered
   whatever it was pointing at — worst over the Projects pill, where it hid the
   label you were about to click. Lime moves to the RIM; the fill goes
   translucent so the thing underneath stays visible.

   NO FILL, and NO BLUR — both were tried and both hid the thing being pointed
   at (2026-08-16, second pass). A --glass-bg veil is dark-on-dark over the ink
   Projects pill, and blur softens exactly the letterforms you are trying to
   read. The disc is clear; only the label chip is solid, so the veil's old job
   of keeping that label legible is done by the chip instead. That is what frees
   the fill to disappear without breaking the contrast floor.

   contrast(1.12) saturate(1.35) rather than a brightness shift: brightness
   MULTIPLIES, so near-black stays near-black and the effect is invisible on the
   ink sections. A contrast lift pushes light and dark apart instead, which
   widens the gap between the pill's paper label and its ink ground — it makes
   the text under the lens measurably crisper, not just prettier.

   backdrop-filter is on .is-active ONLY. This element is re-positioned every
   frame by a rAF loop in js/main.js, and backdrop-filter costs a filter pass
   per frame at the new position — the most expensive case there is. Confining
   it to the hover state keeps that cost to the moments it is visible.
   Desktop-only anyway; the coarse-pointer query below removes the cursor. */
.cursor.is-active{
  width:78px; height:78px; margin:-39px 0 0 -39px;
  background:transparent;
  -webkit-backdrop-filter:contrast(1.12) saturate(1.35);
          backdrop-filter:contrast(1.12) saturate(1.35);
  border:1.5px solid rgba(214,255,63,.9);
  box-shadow:0 6px 20px rgba(12,12,12,.22);
}
.cursor.is-active::before{ opacity:1; }
.cursor.is-active .cursor__label{ opacity:1; }

/* OVER AN INK SURFACE the dot goes lime. Toggled by cursor() in js/main.js,
   which hit-tests [data-ink] under the dot each frame — see the note there,
   and the list of what carries the attribute in index.html.
   Lime rather than bone (--paper), even though bone is what .case flips --ink
   to: the lens the dot expands into already wears a lime rim, so lime keeps
   the resting dot and its active state reading as one object. Bone would have
   the dot change identity every time it grew.
   :not(.is-active) is REQUIRED, not tidiness. This selector is (0,3,0) against
   (0,2,0) for .cursor.is-active above, so without it the lime would outrank
   that rule's background:transparent and the lens would go back to being a
   solid disc covering what it points at — the exact thing removed on
   2026-08-16. Scoped out, the active lens keeps its clear fill on ink too. */
.cursor.is-over-ink:not(.is-active){ background:var(--accent); }
/* The chip flips with it. It is ink-on-paper by default and the cursor lives
   OUTSIDE .case, so its --ink stays #0C0C0C there: on a dark band the chip's
   pill shape disappeared and left the label floating as bare bone text. */
.cursor.is-over-ink .cursor__label{ background:var(--accent); color:var(--ink); }
@media (pointer:coarse),(hover:none){ .cursor{ display:none } }

/* =============================================================
   NAV
   ============================================================= */
/* HISTORY, so neither is retried blind. mix-blend-mode:difference was the
   original treatment and was removed on 2026-08-10: .nav is position:fixed +
   z-index and therefore a stacking context, which ISOLATES blending, so a
   blended CHILD renders against the nav's own empty backdrop and disappears —
   while blending .nav itself stops backdrop-filter sampling the page and the
   pill comes out flat grey. Glass replaced it. Glass in turn was replaced by
   the solid lime below on 2026-08-16 (Joshua's call), which sidesteps the whole
   problem: an opaque fill needs neither a blend nor a backdrop sample to stay
   legible over the bone sections and the ink bands alike.

   The .nav__mark logo went in the same change. ~35 lines of CSS died with it:
   the grid that crossfaded two files in one cell, the on-bone/on-ink opacity
   swap, the .is-fallback JRB wordmark, and .nav__dot. navLogo() in js/main.js
   drove all of it and is gone too — .is-over-ink now has no reader anywhere. */
/* =============================================================
   RULER
   A designer's rule across the very top, after ohhmydesign.com (2026-08-22).
   Ticks only, edge to edge — no logo zone, no clock.

   --ruler-h IS THE CONTRACT. The bar is fixed and therefore out of flow, so
   anything that also pins to the top has to be told how tall it is: .nav
   below offsets by it, and the ticks canvas reads it back when it sizes
   itself. One token, three readers — change it here and they all follow.

   z-index 7500 puts it UNDER the nav pill (8000) and under everything above
   that, but over every section. It never overlaps the pill in practice —
   .nav is offset past it — but if the two ever meet, the pill wins, which is
   the right way round.
   ============================================================= */
:root{ --ruler-h:26px; }
.ruler{
  position:fixed; top:0; left:0; right:0; z-index:7500;
  height:var(--ruler-h);
  /* Translucent paper over whatever scrolls beneath, so the rule reads as an
     instrument laid on the page rather than a band cut out of it. The
     backdrop-filter is a nicety here, not a legibility requirement — the
     ticks are ink on near-paper either way — so there is no @supports
     fallback, unlike the nav pill which needs one. */
  background:rgba(242,240,234,.82);
  -webkit-backdrop-filter:blur(10px);
          backdrop-filter:blur(10px);
  border-bottom:var(--rule);
  pointer-events:none;   /* an instrument, not a control */
}
/* z-index 2 puts the marks ABOVE the fill (2026-08-22). They were painted
   under it, so the fill tinted every tick and numeral it passed and the rule
   went muddy behind the read portion. Stacked this way the fill is a band of
   colour BEHIND the instrument and the marks stay crisp at full strength,
   which also means the fill can carry real colour instead of being held down
   to a wash faint enough to see through. */
.ruler__ticks{
  position:absolute; inset:0; z-index:2;
  width:100%; height:100%;
  display:block;
}
/* Scroll progress. INK, not the lime it started as (2026-08-22, Joshua's
   call). A low-alpha ink wash so the numerals stay readable through it, and a
   solid ink edge at the head so the position reads precisely — the fill is
   the soft part, the leading edge is the measurement.
   width is written by ruler() only when the value changes; the transition
   smooths the gap between rAF samples rather than animating the scroll. */
.ruler__fill{
  position:absolute; top:0; bottom:0; left:0; width:0; z-index:1;
  /* Light lime yellow (Joshua's call). --accent at .45 over warm paper
     composites to about rgb(229,247,157) — the site's own acid lime, thinned
     until it reads as a pale band rather than a highlighter stripe. A token
     at low alpha rather than a new hex, so it still tracks --accent if that
     ever moves. The leading edge stays INK: the colour marks how far you
     have come, the black line marks exactly where you are. */
  background:rgba(214,255,63,.45);
  box-shadow:inset -2px 0 0 var(--ink);
  transition:width .08s linear;
}
/* The readout rides the fill's leading edge. Ink on paper at 17:1, so it
   stays legible sitting on top of the tick marks it covers.
   LEFT, not right + transform: ruler() clamps it against both ends of the bar
   so it never hangs off the edge, and clamping needs one authoritative
   coordinate. Square, like everything else here — the pills elsewhere on the
   site are controls, and this is a readout. */
.ruler__pct{
  position:absolute; top:50%; left:0; z-index:3;
  transform:translateY(-50%);
  background:var(--ink); color:var(--paper);
  font-family:var(--mono); font-size:.5rem; font-weight:700;
  letter-spacing:.08em;
  padding:.12rem .3rem;
  white-space:nowrap; pointer-events:none;
  transition:left .08s linear;
}

.nav{
  position:fixed; inset:0 0 auto 0; z-index:8000;
  display:flex; align-items:center; justify-content:center;
  /* The top pad clears the fixed rule above — see --ruler-h. Written as a
     calc rather than a bigger literal so the two cannot drift apart. */
  padding:calc(var(--ruler-h) + .7rem) var(--pad) 1rem;
  /* The strip spans the viewport but the pill is a small centred island —
     without this the invisible flanks would eat clicks on whatever scrolls
     under them. The pill turns pointer-events back on. */
  pointer-events:none;
}
/* ---- The bar ----
   Ink glass, not the lime pills this replaced (2026-08-22, after
   jingjinghan.com): one dark capsule centred at the top, paper text, lime on
   hover. The same four-part glass recipe as before — a blur, a saturation
   lift so colour survives it, a hairline, and an inset top highlight that
   fakes a lit edge — but on an ink ground, which is why the fallback below
   goes SOLID INK rather than the old solid lime: this pill carries paper
   text, and lime under paper is nothing.

   IT NO LONGER COLLAPSES. There was a name on the left and an icon rail that
   slid in on scroll, animated by transitioning max-width between two fixed
   ceilings; Joshua dropped both on 2026-08-22, and with nothing to swap
   between, the max-width machinery, the .nav--min class, the desktop
   hover-to-reopen block and notch() in js/main.js all went with them.

   No overflow:hidden here any more either — it existed to crop the slide, and
   left in place it would clip the focus ring on the first and last link. */
.nav__pill{
  pointer-events:auto;
  display:flex; align-items:center;
  color:var(--paper);
  background:rgba(12,12,12,.82);
  -webkit-backdrop-filter:blur(14px) saturate(1.6);
          backdrop-filter:blur(14px) saturate(1.6);
  border:1px solid rgba(242,240,234,.14);
  box-shadow:0 4px 18px rgba(12,12,12,.18),
             inset 0 1px 0 rgba(255,255,255,.08);
  border-radius:100px;
  padding:.55rem 1.25rem;
}
.nav__links{
  display:flex; align-items:center;
  gap:clamp(1rem,2vw,1.5rem);
  white-space:nowrap;
}
/* Hover is a COLOUR change, not the weight change the old lime pills used:
   lime survives on ink at 16.99:1, and the mono-weight trick only mattered
   while the pill was lime with ink text. */
.nav__links a{
  font-family:var(--mono); font-size:.68rem;
  text-transform:uppercase; letter-spacing:.12em;
  padding:.55rem 0;
  transition:color .3s var(--ease);
}
.nav__links a:hover,
.nav__links a:focus-visible{ color:var(--accent); }

/* No backdrop-filter (older Firefox, some Linux builds): the translucent fill
   alone would leave the notch half-legible over the ink bands, so go opaque.
   SOLID INK, not the lime the old pills fell back to — this surface carries
   paper text. */
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
  .nav__pill{ background:var(--ink); }
  /* INVERTED GROUND: near-black on near-black left only the hairline to find.
     --paper-2 is the one step off the ground the overlay already uses. */
  .case__close{ background:var(--paper-2); }
}

/* =============================================================
   HERO
   ============================================================= */
/* The hand-measured hero lived here until 2026-08-16. It sized "JOSHUA" and
   "R. BERNARD" off four numbers tied to Archivo's letterforms (13.4vw/11.85vw,
   and ÷6.9/÷7.78 in a sub-460px block) and had to be re-measured in the browser
   on any change to the name or the display face. Both it and the sub-460px
   clipping bug those numbers existed to fix are gone with it. Nothing below
   depends on a measured em-width. */
.hero{
  /* position + clip are for the wave below: it is wider than the viewport by
     design and would otherwise add horizontal scroll. clip, NOT hidden —
     hidden would make this a scroll container and break the sticky ruler and
     anything else pinned above it. */
  position:relative;
  overflow-x:clip;
  min-height:100svh;
  padding:calc(var(--pad) + 3.5rem) var(--pad) var(--pad);
  display:flex; flex-direction:column;
  gap:clamp(1.25rem,3vw,2rem);
}
/* ---- THE THREE ROWS ----
   2026-08-29, Joshua's call, from a reference he supplied. Three marquee rows
   carrying his three roles, with his own artwork sitting between them.

   THIS IS A RESTORATION, not a new idea. A portrait sandwich stood here until
   2026-08-20 and the note that replaced it is worth reading if this ever comes
   apart: rows behind, portrait centred, one row pulled in front. It was pulled
   because the artwork then was a PHOTOGRAPH and the front row read as
   letterforms sweeping across a face. What sits here now is a drawing made for
   this slot, so the overlap is the point rather than an accident.

   WHAT WENT, so nobody hunts for it:
     - the lime wave (.hero__wave) and its 820px media query
     - the single cycling typewriter slot (.hero__role) and the sub-copy
       (.hero__copy), along with the per-letter machinery they fed:
       .hero__u, .hero__ch, .hero__ch--1..8 and the caret keyframes
     - the calls to typeRoles() and heroPaint() in js/main.js
   Both functions are still ON DISK and still work; only their calls are gone,
   because this project has no git and deleting them is unrecoverable. */

/* Full-bleed: the rows run edge to edge while the foot below keeps the page
   padding, so this cancels .hero's own padding rather than .hero dropping it.
   position:relative is the containing block for the portrait. */
.hero__kinetic{
  flex:1; min-height:0;
  display:flex; flex-direction:column; justify-content:center;
  /* SCALE LIVES HERE, not on the track (2026-08-29). Two things need to be in
     the same units as the type: the gap between rows, and the line box. Setting
     font-size on this block lets both be written in em and track it, so one
     value moves the whole stack. .hero__row .hero__track just inherits it.
     See the line-height note there for why 20.5vh and not the 26vh it was. */
  font-size:clamp(2.5rem,min(24vh,34vw),18rem);
  gap:.05em;
  position:relative;
  margin-inline:calc(var(--pad) * -1);
  opacity:0; animation:fadeIn .8s var(--ease) .1s forwards;
  /* AN EXPLICIT GROUND, and it is load-bearing for .hero__row--over below.
     That row blends in `difference`, which composites against the backdrop
     inside THIS element's stacking context — and the opacity animation above
     makes this a stacking context. Without a background painted here the
     backdrop is transparent wherever the artwork is not, difference against
     nothing resolves to white, and the row turns invisible on the page.
     Same value as the page, so it changes nothing visually. */
  background:var(--paper);
}
@keyframes fadeIn{ to{ opacity:1 } }

/* overflow:hidden is what makes a marquee a marquee — every track is far wider
   than the viewport by design. z-index:0 puts the rows UNDER the portrait;
   .hero__row--over below lifts the last one back over it. */
.hero__row{ display:flex; overflow:hidden; position:relative; z-index:0; }

/* Beats .ticker__track (0,1,0) on specificity, so only the type is restated —
   the slide animation, its direction and --marquee-dur all still come from
   there, and ticker() in js/main.js drives these rows with no change to it. */
.hero__row .hero__track{
  /* 700 = the Bold face declared at the top of this file. Casual Human ships
     two static weights and no axes; font-variation-settings is INHERITED from
     body, and an inherited axis beats font-weight, so it has to be reset here
     or the row synthesises against a font that has neither.
     Drop to 400 for the Regular cut — the file is already declared. */
  font-family:var(--kinetic); font-weight:400; font-variation-settings:normal;
  /* min() so a short window is governed by height and a narrow one by width;
     raising either term alone gets swallowed by the other. */
  font-size:inherit;                 /* the scale is on .hero__kinetic */
  /* .94, MEASURED FOR BROOKS (trial, 2026-08-29).
     .hero__row is overflow:hidden for the marquee, so it clips vertically too
     and any ink outside the line box is cut.

     RE-MEASURE ON EVERY FONT SWAP — wrong three times running when inherited:
                        natural box   ink asc   ink desc   min L
         Anton              1.51em      .867      .091      1.05
         Casual Human       0.95em      .749      .030      0.81
         Stars Soul Brush   1.28em      .720      .102      0.91
         Branders           1.00em      .901      .028      0.95
         Brooks             1.00em      .700      .000      0.90
     Do NOT take cap height from the OS/2 table. Branders declares
     sCapHeight 850 but its ink measures .901em, so the declared figure puts
     the safe value at .90 and it clips. Measure the rendered ink, per string.

     BROOKS IS THE ODD ONE: its uppercase has NO ink below the baseline at all
     (ink desc .000), yet the font reserves a quarter em of descender in its
     metrics, so at .98 it was leaving ~50px of dead space under every row.
     .94 reclaims most of that and the size goes 23vh -> 24vh with it. The
     ascender still governs the top, as it does for every face here except
     Anton and Casual Human. */
  line-height:.94; letter-spacing:0; text-transform:uppercase;
  color:var(--ink);
  /* An em gap, not the 1.6rem .ticker__track uses: the space between repeats
     has to scale with the type or it closes up at hero sizes. */
  gap:.28em; padding-right:.28em;
  align-items:baseline;
}

/* The middle row is a different LAYER, not a different colour. --ink-2 against
   --ink is about one step apart and would not read on its own; .85 over bone
   lifts it to roughly #3A3A37 and opens a real gap. Carried over verbatim from
   the row treatment that stood here before — it was written for the earlier
   sandwich and survives because it is a row rule, not a portrait rule.
   nth-child(2) and not a modifier class, matching how this was always written;
   the children are row, row, figure, row. */
.hero__kinetic .hero__row:nth-child(2) .hero__track{ color:var(--ink-2); opacity:.85; }

/* Joshua's artwork, 2026-08-29. Centred, and the last row passes over it —
   that overlap is the whole depth effect and is what the reference shows.
   pointer-events:none so it never intercepts the hero's pointer. */
.hero__portrait{
  position:absolute; left:50%; top:50%;
  transform:translate(-50%,-50%);
  width:clamp(150px,20vw,300px);
  margin:0; z-index:1;
  pointer-events:none; user-select:none;
  /* NO BACKGROUND HERE. A --paper-2 panel sat here to mask the rows running
     behind the drawing, back when the artwork was line work on transparency.
     The updated SVG carries its own full-bleed ground, so a panel underneath
     it would be painted over in full and never seen. If the artwork is ever
     swapped for a transparent one again, this is the line to bring back. */
}
.hero__portrait img{ display:block; width:100%; height:auto; }

/* ---- Pointer paint ----
   heroPaint() in js/main.js hit-tests under the cursor each frame and puts
   .is-lit on whichever .hero__ch it finds; heroChars() made those spans.

   THE ASYMMETRY IS THE EFFECT. Lighting is instant (transition:color 0s on
   .is-lit) and cooling is slow, so dragging the pointer across a row leaves a
   trail of letters fading back to ink behind it rather than a single lit
   character tracking the mouse. Nothing in JS tracks the cooling letters —
   the class is simply removed and the transition below does the rest, so any
   number of them can be mid-fade at once.

   Because the rows scroll, a STILL pointer also paints: letters light as they
   pass under it and cool once they are by.

   The greys run lighter than --ink, so a touched letter recedes toward the
   paper rather than darkening. Five shades, assigned per character and fixed
   for the life of the page, so a letter always lights the same value instead
   of flickering a new one each pass. Warm-neutral to sit with the bone ground
   rather than reading as cold screen grey. */
.hero__ch{ transition:color 2.4s var(--ease) .06s; }
.hero__ch.is-lit{ color:var(--hg); transition:color 0s; }
.hero__ch--g1{ --hg:#2E2E2B; }
.hero__ch--g2{ --hg:#4A4A45; }
.hero__ch--g3{ --hg:#67675F; }
.hero__ch--g4{ --hg:#85847A; }
.hero__ch--g5{ --hg:#A3A196; }

/* The row that crosses the artwork. Above it, and above the row above that. */
/* NO BLEND HERE ANY MORE (2026-08-29, second pass). This row carried
   mix-blend-mode:difference with white text, because the artwork was then
   black line work on TRANSPARENCY and a black row crossing it vanished into
   it. Joshua's updated HeroSec.svg carries its own opaque ground — a
   full-bleed #d4f723 rect with the portrait reversed out in white — so the
   problem the blend solved no longer exists.
   It had to go rather than merely being unnecessary: difference against that
   lime resolves to rgb(43,8,220), a violet that is nowhere in this palette.
   Plain ink over the lime is high contrast and is the same relationship the
   reference has, where dark type crosses an opaque block. */
.hero__row--over{ z-index:2; }

/* NO hover pause here, deliberately — Joshua's call, 2026-08-16. The hero is
   full-bleed, so a pointer resting anywhere in the top screenful froze all
   three rows; that is not a control, it is the rows stopping at random.
   .ticker and .logos keep theirs: they are narrow bands you have to aim at.
   Consequence: the hero has no pause affordance at all. Noted in HANDOFF
   under the WCAG 2.2.2 gap. */

/* Flex, not the old auto/1fr/auto grid — the location and availability lines
   that filled the first two tracks are gone, and a single grid item would sit
   in column one on the left. */
/* Centred 2026-08-19 (Joshua's call, web and mobile). It sat flush right from
   when the foot still carried the location and status blocks in the first two
   tracks; with only the one button left, right-aligned read as leftover
   rather than as placement. */
.hero__foot{
  display:flex; justify-content:center; align-items:end;
  gap:var(--gap);
  border-top:var(--rule); padding-top:1.1rem;
  opacity:0; animation:fadeIn .9s var(--ease) .95s forwards;
}
/* .hero__where (location + live IST clock) and .hero__status (green dot +
   "available for work") were removed on 2026-08-16 along with their markup and
   clock() in js/main.js. The foot now carries only the Projects button. */
.hero__scroll{
  display:inline-flex; align-items:center; gap:.55rem;
  border:1px solid var(--ink); border-radius:100px;
  padding:.75rem 1.15rem; white-space:nowrap;
  transition:background .4s var(--ease), color .4s var(--ease);
}
.hero__scroll:hover{ background:var(--ink); color:var(--paper); }
.hero__scroll svg{ animation:bob 1.9s var(--ease) infinite; }
@keyframes bob{ 0%,100%{ transform:translateY(-2px) } 50%{ transform:translateY(2px) } }

/* =============================================================
   TICKER
   ============================================================= */
.ticker{
  background:var(--ink); color:var(--paper);
  padding:1.15rem 0; overflow:hidden;
  display:grid; gap:.35rem;
}
.ticker__row{ display:flex; overflow:hidden; }
.ticker__track{
  display:flex; align-items:center; gap:1.6rem;
  padding-right:1.6rem;
  flex:0 0 auto;
  font-weight:800; font-variation-settings:"wdth" 108,"wght" 800;
  font-size:clamp(1.4rem,3.6vw,3rem);
  line-height:1; letter-spacing:-.025em; text-transform:uppercase;
  white-space:nowrap;
  animation:slide var(--marquee-dur,34s) linear infinite;
  will-change:transform;
}
.ticker__row--rev .ticker__track{
  animation-direction:reverse;
  color:transparent; -webkit-text-stroke:1px rgba(242,240,234,.55);
}
/* Drawn, not typed. Added while SCHABO was the display face (it has no ●,
   U+25CF) and KEPT after the revert: Archivo does have the glyph, but a drawn
   dot renders identically and cannot break if the display font changes again.
   The markup still carries the ● for copy-paste and no-CSS reading. */
.ticker__sep{
  display:inline-block; vertical-align:middle;
  width:.24em; height:.24em; border-radius:50%;
  background:var(--accent);
  /* The ● itself never paints and never affects the box. Deliberately NOT
     font-size:0 — that would collapse the em units below to zero. em here
     resolves against the track's own size, so the dot scales with the type. */
  color:transparent;
  overflow:hidden;
}
/* Parity with the logo wall, which has paused-on-hover. Survives the
   reduced-motion exception below only because that block re-asserts
   *longhands* — the `animation` shorthand would expand all eight longhands as
   !important and silently kill animation-play-state. */
.ticker:hover .ticker__track{ animation-play-state:paused; }
@keyframes slide{ to{ transform:translateX(-50%) } }

/* =============================================================
   SECTION HEADS
   ============================================================= */
/* Single column since the section descriptions were deleted — the second
   track had nothing to hold. */
.sec-head{
  display:grid; grid-template-columns:1fr;
  align-items:end; gap:var(--gap);
  border-bottom:var(--rule); padding-bottom:1.1rem; margin-bottom:2.6rem;
}
/* Bracketed label above the title, matching About / Experience / Contact.
   --inv sits on the ink band, where lime measures 16.99:1. */
.sec-head__label{
  font-family:var(--mono); font-size:0.68rem;
  text-transform:uppercase; letter-spacing:.14em;
  color:var(--ink); margin-bottom:1.4rem;
}
.sec-head--inv .sec-head__label{ color:var(--accent); }
.sec-head__title{
  font-weight:900; font-variation-settings:"wdth" 122,"wght" 900;
  font-size:clamp(2.6rem,8.4vw,8rem);
  line-height:.85; letter-spacing:-.04em; text-transform:uppercase;
}
.sec-head--inv{ border-color:rgba(242,240,234,.2); }

/* ---- Work head: centred, pinned, and UNDER the tiles ----
   It takes a full screen of flow at the top of .work, then sticks at top:0 for
   the rest of the section while .work__list scrolls up over it. The overlap is
   pure paint order — .work__list is later in the DOM and both are z-index auto,
   so it wins without either needing a z-index. Do NOT add one to .sec-head--work
   thinking it will "sit behind properly": any positive value lifts it over the
   tiles and the effect inverts.
   The rule and bottom margin go — a divider is for a head that sits above its
   content, and this one sits behind it. */
.sec-head--work{
  position:sticky; top:0;
  min-height:100svh;
  display:flex; flex-direction:column;
  align-items:center; justify-content:center;
  gap:1.1rem;
  border-bottom:0; padding-bottom:0; margin-bottom:0;
  text-align:center;
  /* Full ink on arrival, when it is the only thing on screen and reads as a
     real section title. It drops back to a ground layer once the rows start
     covering it — .is-behind is toggled by workHead() in js/main.js off the
     list's own position, not off a scroll distance, so it stays right at any
     viewport height. Without that fade, full ink under the transparent text
     column of a row makes both unreadable. */
  color:var(--ink);
  transition:color .45s var(--ease);
  pointer-events:none;          /* never steals a click meant for a tile */
}
/* .07, not .16 — at 13vw this word is enormous, and alpha that reads as a faint
   tint on a line of body copy reads as a solid grey slab at that scale. Judge
   it against the rows sitting on top of it, never on its own. */
.sec-head--work.is-behind{ color:rgba(12,12,12,.035); }
/* Taken from .07 to .035 on 2026-08-19, Joshua's call, web and mobile alike.
   At .07 the wordmark was legible enough to compete with the tiles crossing
   it, so an occluded word read as a mistake rather than as texture. Halved,
   it reads as tone. */
/* Two lines, always — "FEATURED WORK" on one line forces the size down to fit
   the measure, and the whole point of it is scale. */
.sec-head--work .sec-head__title{
  font-size:clamp(2.8rem,13vw,13rem);
  max-width:none;
}
.sec-head--work .sec-head__title span{ display:block; }
/* Inherits so it fades with the title rather than needing its own is-behind
   rule; opacity keeps it subordinate at both ends of that transition. */
.sec-head__note{
  font-family:var(--mono); font-size:0.68rem;
  text-transform:uppercase; letter-spacing:.14em;
  color:inherit; opacity:.5;
}
/* .work must not clip the pin. overflow-x:clip is safe — it does not create a
   scroll container, so the sticky element still resolves against the viewport —
   but overflow:hidden here WOULD break it. See the .work rule below. */

/* =============================================================
   WORK LIST
   Was a uniform two-column grid until 2026-08-16. One project per row now,
   arriving one at a time, odd rows entering from the left and even from the
   right — the alternation is what turns ten equal tiles into a sequence.
   Add or remove projects freely: the sides come from :nth-child, and the
   entry direction from a class buildGrid() assigns off the index.
   ============================================================= */
/* The rest transforms on .reveal--l / --r sit outside the viewport before they
   settle. clip, not hidden: hidden would make this a scroll container, which
   silently breaks position:sticky for any descendant added later. */
.work{ position:relative; padding:clamp(4rem,9vw,8rem) var(--pad); overflow-x:clip; }
/* position:relative + later-in-DOM is the whole mechanism that puts the list in
   front of the pinned head. Leave both alone. */
.work__list{
  position:relative;
  display:grid;
  gap:clamp(3rem,7vw,7rem);
}
/* Narrower than the measure so there is empty counter-space beside each row —
   that gap is what makes the stagger read as intentional rather than as a
   ragged edge. */
/* Odd rows: image LEFT, text right, nudged RIGHT of centre.
   Even rows: image RIGHT, text left, nudged LEFT of centre.

   CENTRED then nudged, not pushed to the page edges. justify-self:start/end
   was the earlier attempt and it threw each row flush against the padding —
   ±209px from centre at 1278, so the eye ping-ponged wall to wall. Centring
   first and offsetting by a small --row-nudge keeps the zigzag but holds the
   whole section around the middle of the page.

   The nudge is `left`, NOT a transform. .reveal--l/--r already own transform on
   these elements and reset it to `none` on arrival, which would wipe a nudge
   written there. position:relative + left survives that untouched.

   Four things key off this parity — the nudge direction, the internal order,
   the text alignment, and the entry direction in buildGrid(). They must move
   together: a row that settles left of centre has to arrive from the left, or
   the motion argues with the placement. */
/* THE TILE ROW'S THREE WIDTHS, declared once here because FOUR things need
   them: the frame, the gap, the text column — and .tile itself, whose width is
   their sum (see the note on it below). Before this they were three literals
   at their points of use, and the tile had no width at all. A fourth copy of
   each formula is exactly the thing that would rot silently, so they are
   variables rather than a comment asking three rules to move together. */
.work__list{
  --row-nudge:70px;
  --tile-frame:clamp(200px,30vw,420px);
  --tile-gap:clamp(1.2rem,3vw,3rem);
  --tile-text:23rem;
}
/* DEFINITE WIDTH — the Safari fix, 2026-08-20, measured rather than guessed.
   This tile used to have NO width, only `max-width:min(64rem,80%)` plus
   justify-self:center, which makes a grid item shrink-to-fit. Its contents are
   a flex row, and the two browsers sized that row differently:

     Chrome  every tile 830.75px  = 420 frame + 42.75 gap + 368 text
     Safari  309.7 / 335.6 / 351.9 / 372.5 / 375 / 399.8 / 401.6 / 419.1 /
             479 / 558.9 — a DIFFERENT width per tile, every one of them
             narrower than the contents it holds

   Safari was sizing the tile nearer min-content while Chrome used max-content.
   Because justify-self:center then centres whatever width it arrived at, each
   row landed at a different x — which is the "TVS Eurogrip tile looks shifted
   left in Safari" report that sat open in HANDOFF from 2026-08-19. It was
   never TVS: all ten were wrong, by ten different amounts, and tile 10 was
   merely the one Joshua's eye caught.

   The note on .tile__text records two earlier attempts at this. Both fixed the
   CHILDREN — making the frame and the text column definite — and both left the
   TILE itself intrinsic, which is the circularity that actually matters. The
   children were already definite when this was measured (frame 420 and text
   368 in BOTH browsers) and Safari still got the sum wrong.

   So: state the sum. Nothing here is content-derived, font-derived or
   engine-derived any more.
   max-width was min(64rem,80%) and is now 100%: the old cap never bound (the
   sum is 830.75 at 1440 and 836 at 1920, both under 64rem), but below ~900px
   80% of the column drops UNDER the sum, and clamping there would overflow the
   contents — the very thing this rule exists to stop. */
.work__list .tile{
  width:calc(var(--tile-frame) + var(--tile-gap) + var(--tile-text));
  max-width:100%;
  justify-self:center;
  position:relative;
}
.work__list .tile:nth-child(odd){  left:var(--row-nudge); }
.work__list .tile:nth-child(even){ left:calc(var(--row-nudge) * -1); }

/* THE HIT AREA IS THE IMAGE, NOT THE WHOLE ROW (Joshua's call, 2026-08-20).
   This element is the flex container for the image AND the text, so as a
   <button> it took clicks — and opened the VIEW lens — right across the row:
   over the title, over the tags, and over the empty band above the text
   column that belongs to neither child and is the button's own box showing
   through. pointer-events moves the hit area onto the frame and changes no
   layout value at all; this row's geometry was made browser-deterministic in
   an earlier pass after Safari and Chrome disagreed, and it is not worth
   re-deriving to solve a hit-testing problem.

   Nothing else has to change to follow it:
   - the CLICK still reaches the button. The frame's click bubbles, and the
     delegated handler in buildGrid() resolves it with closest('.tile__btn').
   - :hover still reaches it. An ancestor of a hovered element is itself
     hovered, so .tile__btn:hover .tile__img below keeps zooming.
   - the KEYBOARD is untouched. pointer-events has no bearing on focus, so the
     button still tabs, still shows its ring and still fires on Enter — which
     is what keeps the image-only hit area from costing anyone the case study.
   This replaced a [data-cursor-off] opt-out on .tile__text, which only ever
   silenced the lens over the TEXT and left the band above it live. */
.tile__btn{
  display:flex; align-items:center;
  gap:var(--tile-gap);
  width:100%; text-align:left;
  pointer-events:none;
}
.tile:nth-child(even) .tile__btn{
  flex-direction:row-reverse; text-align:right;
}
.tile__frame{
  pointer-events:auto;          /* re-enables the half of .tile__btn that clicks */
  position:relative; overflow:hidden;
  background:var(--paper-2);
  /* 16:9 is the asset spec — assets/img/work-01.jpg … work-10.jpg are cut to
     it. Change the width below freely; do NOT change the ratio without
     recutting every image. ~384px at a 1280 viewport, against the 567px the
     two-column grid gave each tile. */
  flex:0 0 var(--tile-frame);
  aspect-ratio:16/9;
}

/* CONTAIN, not cover (Joshua's call, 2026-08-31: "fit all to the container,
   dont adapt the container"). The frame stays 16:9 and the artwork is fitted
   whole inside it.

   WHY IT CANNOT BE `cover`. The thumbnails are now frames taken from the case
   studies themselves, and this is social-first work: of the seven folders that
   needed a thumbnail, FIVE contain no landscape image at all — everything is
   1:1 or 9:16. `cover` on a 16:9 frame throws away 44% of a square and 68% of
   a 9:16, taken off the top and bottom, which is where the type and the
   product usually are. It cropped the point out of the picture.

   THE BACKGROUND IS LOAD-BEARING, not decoration. .tile__ph sits BEHIND this
   image at z-index 0 and is a dashed box with a 45-degree hatch and the
   filename in it. Under `cover` the image covered it completely; under
   `contain` it would show through the bars either side of every portrait and
   square thumbnail. Painting the element's own box hides it. This does NOT
   defeat the placeholder — the img carries onerror="this.remove()" (see
   buildGrid in js/main.js), so a missing file deletes the element rather than
   leaving a blank ground over the top of the thing meant to replace it. */
.tile__img{
  position:relative; z-index:1;
  width:100%; height:100%; object-fit:contain;
  background:var(--paper-2);
  transition:transform 1.05s var(--ease), filter .6s var(--ease);
}
.tile__btn:hover .tile__img{ transform:scale(1.055); }

/* placeholder — sits BEHIND the image, revealed if the file is missing */
.tile__ph{
  position:absolute; inset:0; z-index:0;
  display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:.6rem;
  background:
    repeating-linear-gradient(45deg,
      rgba(12,12,12,.045) 0 12px, transparent 12px 24px),
    var(--paper-2);
  border:1px dashed rgba(12,12,12,.28);
  color:var(--muted);
  font-family:var(--mono); font-size:0.66rem;
  letter-spacing:.14em; text-transform:uppercase;
  text-align:center; padding:1.5rem;
}
.tile__ph b{
  font-family:var(--display); font-weight:900;
  font-variation-settings:"wdth" 125,"wght" 900;
  font-size:clamp(2.5rem,5vw,4.5rem); line-height:1;
  letter-spacing:-.04em; color:rgba(12,12,12,.16);
}
.tile__ph code{
  font-family:var(--mono); font-size:0.62rem;
  background:var(--ink); color:var(--accent);
  padding:.3rem .5rem; letter-spacing:.06em; text-transform:none;
}

/* The text column beside the thumbnail. min-width:0 is load-bearing — without
   it a flex item refuses to shrink below its content's intrinsic width and the
   longest project title pushes the row past the viewport. */
/* flex:0 1 auto, NOT 1 1 auto. Growing to fill was what shoved the copy to the
   far edge and left a canyon between it and the thumbnail; sized to its content
   the pair reads as one object with only the gap between them. max-width is the
   measure cap that grow used to impose for free. */
.tile__text{
  /* DETERMINISTIC WIDTH (2026-08-19). Was `flex:0 1 auto; max-width:38ch`,
     which made this column the ONLY font-metric-dependent value in the tile
     row: everything else here is arithmetic (.tile is min(64rem,80%),
     .tile__frame is clamp(200px,30vw,420px), the gap is a rem clamp).
     `ch` resolves against the "0" advance of the ACTIVE font at layout time,
     so before Archivo finishes loading it resolves against the fallback, and
     Safari does not re-resolve it afterwards the way Chrome does. Safari
     therefore capped this column well under Chrome's 330.79px, which is why
     the same tile showed "K7 ANTIVIRUS" on one line in Chrome and two in
     Safari, wrapped the meta line, and stacked the tag pills vertically.
     flex:1 1 0 sizes the column from the space left over rather than from its
     own content. The cap is 23rem (368px), NOT the old 38ch (330.79px): once
     the tag pills stopped wrapping internally they got wider, and the widest
     tag row on the site (S51 Agency: Brand Identity, Visual Systems, Art
     Direction) needs 357px to stay on one line. 336px broke four tiles onto a
     second tag row. If you add a longer tag, re-measure and raise this.
     Do not put a ch, em or ex value back here. */
  /* DEFINITE, not intrinsic. flex:0 0 23rem gives this column a fixed basis
     with no grow and no shrink, so NOTHING in the tile row is content- or
     font-derived any more: .tile__frame is clamp(200px,30vw,420px), the gap is
     a rem clamp, and this is 368px flat. The tile's own intrinsic width is
     then just their sum.
     Two earlier attempts failed because they left an intrinsic-sizing
     circularity in place. .work__list .tile has justify-self:center and only a
     max-width, so the tile has NO definite width and is sized from its
     content. `max-width:38ch` made that content measurement depend on a font
     metric Safari resolves against the fallback and never re-resolves; then
     `flex:1 1 0` made this column contribute ZERO to the tile's intrinsic
     width, which Chrome resolves via max-content and Safari via something
     nearer min-content, collapsing the column to about the longest word.
     That is why Safari stacked every tag pill and broke ATLYS's meta over
     three lines while Chrome kept them on one.
     Do not reintroduce a ch/em value or a 0 flex-basis here. */
  flex:0 0 var(--tile-text); min-width:0;
  display:flex; flex-direction:column; align-items:flex-start;
  gap:.5rem;
}
.tile:nth-child(even) .tile__text{ align-items:flex-end; }

.tile__idx{
  font-family:var(--mono); font-size:0.64rem;
  letter-spacing:.14em; color:var(--muted);
}
/* Was a baseline row with a top rule when title and meta sat under a full-width
   thumbnail. Beside one, they stack. */
.tile__bar{
  display:flex; flex-direction:column; gap:.3rem;
}
.tile__title{
  min-width:0;
  font-weight:800; font-variation-settings:"wdth" 112,"wght" 800;
  font-size:clamp(1.35rem,2.6vw,2.15rem);
  line-height:1.05; letter-spacing:-.03em; text-transform:uppercase;
}
.tile__tags{
  display:flex; flex-wrap:wrap; gap:.4rem; margin-top:.2rem;
}
.tile:nth-child(even) .tile__tags{ justify-content:flex-end; }
/* nowrap added 2026-08-19. A pill breaking "MARKETING / DESIGN" inside its own
   outline reads as a cramped two-line block rather than one tag, and it also
   made the section render differently in Safari and Chrome: at ~130px the pill
   sat exactly on its wrap threshold, and the two engines disagree by a
   fraction of a pixel on whether the trailing letter-space of a .1em-tracked
   uppercase string counts toward element width. Forcing nowrap removes the
   sub-pixel decision, so both engines lay the row out identically.
   .tile__tags is already flex-wrap:wrap, so pills still wrap BETWEEN
   themselves onto a new row; only the break inside a pill is suppressed. */
.tile__tags li{
  white-space:nowrap;
  font-family:var(--mono); font-size:0.6rem;
  text-transform:uppercase; letter-spacing:.1em;
  border:var(--rule); border-radius:100px;
  padding:.25rem .6rem; color:var(--ink);
}
.tile__meta{
  font-family:var(--mono); font-size:0.64rem;
  letter-spacing:.12em; text-transform:uppercase; color:var(--ink);
}
.work__noscript li{ padding:.4rem 0; border-bottom:var(--rule); }

/* =============================================================
   ABOUT
   ============================================================= */
/* Inverted band (Joshua's call, 2026-08-16) — same treatment as .caps and
   .clients. Note the ticker directly above is also ink, so the two merge into
   one dark region and the hairline below is the only seam between them; that
   is why it is --rule-inv and not --rule, which would be ink-on-ink. */
.about{
  background:var(--ink); color:var(--paper);
  padding:clamp(4rem,9vw,8rem) var(--pad);
  border-top:var(--rule-inv);
}
/* Trimmed from the full section padding (116px at 1293) because the Clients
   band below adds its own 78px top padding and BOTH are ink — the two merge
   into one surface, so the paddings stack into a ~194px void under the
   Experience toggle instead of reading as a division between sections.
   Bottom only: the top padding still separates About from the bone Work
   section above it, where the same reasoning does not apply. */
.about{ padding-bottom:clamp(1.25rem,2.4vw,2.5rem); }
.about__label,.exp__label,.clients__label,.contact__label{ margin-bottom:2rem; }
/* Centred to the page (Joshua's call, 2026-08-16), matching Featured Work.
   The heads only — the accordion rows below stay full-width and left-aligned,
   because their year/role/plus sit on a three-track grid whose plus is pinned
   right; centring that text would pull it away from its own control. */
.exp__label{ text-align:center; }
.caps .sec-head{ justify-items:center; text-align:center; }
/* Grey, not lime. These three sit on paper, where --accent measures 1.01:1 —
   which is the only reason they ever carried a -webkit-text-stroke crutch to
   be legible at all. Grey needs no crutch, so the stroke goes with it.
   (Selected Clients & Teams) keeps the lime: it sits on the ink band, where
   the same colour measures 16.99:1. */
/* Ink, not grey (2026-08-09). --muted was tuned for Space Mono's solid
   strokes; Doto's dotted strokes cover far less area at the same colour, so
   the same grey read much lighter than its 4.54:1 would suggest. Ink takes
   these to ~19:1. (Selected Clients & Teams) keeps lime — it sits on ink. */
.exp__label,.contact__label{ color:var(--ink); }
/* (About) left that group when the section went ink — ink-on-ink is invisible.
   Lime, following (Selected Clients & Teams) and the Capabilities label: it is
   the site's convention for a label on a dark band, and it measures 16.99:1
   there. If it competes with the now-lime statement below it, --paper is the
   swap — but change it here, not by re-joining the rule above. */
.about__label{ color:var(--accent); }
.about__grid{
  display:grid; grid-template-columns:1.55fr 1fr;
  gap:clamp(2rem,5vw,5rem);
}
/* The grid stretches both columns to the portrait's height, but the left
   column's content used to stop ~142px short, leaving an L-shaped void.
   Pinning the body copy to the bottom lands both columns on the same line and
   turns the leftover space into deliberate air under the statement.
   No-op below 1024px, where the grid stacks and the column is content-sized. */
.about__statement{ display:flex; flex-direction:column; }
.about__body{ margin-top:auto; }
/* Sized so each authored line sits on one typographic line — the longest
   is 15.73em, against a column measure of ~52.9vw. */
.about__lead{
  font-weight:800; font-variation-settings:"wdth" 108,"wght" 750;
  font-size:clamp(1.4rem,3.3vw,3.9rem);
  line-height:1.06; letter-spacing:-.035em;
  margin-bottom:2.4rem;
  /* Lime is only usable because this band is ink — 16.99:1 here, against
     1.01:1 on paper, which is why every lime-on-bone attempt elsewhere on the
     site needed a -webkit-text-stroke crutch. If this section ever goes back
     to bone, this colour has to go with it. */
  color:var(--accent);
}
.about__lead .ln{ display:block; overflow:hidden; }
.about__lead .ln > *{ display:block; }
/* No font-style:italic anywhere on --voice: Bricolage Grotesque ships no
   italic, so the browser would synthesise a mechanical oblique. The accent is
   carried by its real axes instead — light weight and full width against the
   heavy condensed display face. Currently unused; kept as the accent hook. */
.about__lead em{
  font-family:var(--voice); font-weight:300; font-stretch:100%;
  font-style:normal;   /* <em> is italic by UA default — reset or it obliques */
  letter-spacing:-.01em;
}
.about__body{
  display:grid; gap:1.1rem; max-width:52ch;
  /* Bricolage, not the Archivo body default (Joshua's call, 2026-08-10) —
     matches the hero blurb, so the two prose voices agree. */
  font-family:var(--voice);
  font-size:clamp(.95rem,1.15vw,1.075rem); line-height:1.62;
  /* Was #2E2C26, a near-ink tuned for the bone background. Full --paper on ink
     is 17.17:1 — no need to soften it the way .caps__panel-in does at .62,
     because this is the section's main prose, not a disclosure panel. */
  color:var(--paper);
}
/* Capped 2026-08-16. The portrait used to fill its whole grid column, so on a
   wide screen it was ~690px square and IT set the row height — the section ran
   past a screenful and pushed Experience and Clients out of view. It is
   supporting material, not the subject, so it gets a ceiling and the copy
   decides how tall About is.
   width:100% is load-bearing, not padding. An auto margin on a grid item makes
   it shrink-to-fit, and with the img at width:100% the only thing left with an
   intrinsic width is the figcaption — so the cap was ignored and the portrait
   collapsed to the width of "JOSHUA R. BERNARD, CHENNAI" (191px). An explicit
   width stops the shrink-to-fit, leaving the auto margin to do only what it is
   here for: push the capped box to the right of its column. */
.about__side{
  width:100%; max-width:clamp(180px,20vw,320px); margin-left:auto;
  display:flex;
}
/* THE PORTRAIT FILLS ITS COLUMN rather than staying square (2026-08-20,
   Joshua's call). The cap above still decides how WIDE it is; this decides how
   tall. Above 1024px the grid row is set by the statement column, and a square
   portrait measured 47-75px short of it across 1100-1600px, which left a wedge
   of bare band under the caption.

   .about__side was already the full row height — grid items stretch by default
   and nothing here opts out — so the short element was the <figure> inside it,
   which is content-sized. Hence two flex columns rather than a height on
   anything: .about__side stretches the figure, the figure grows the image into
   whatever is left after the caption. No JS, and nothing to keep in sync when
   the copy changes length.

   object-fit:cover does the cropping, so the square 800x800 source the markup
   asks for still works — the box lands between 1:1.14 and 1:1.34 at these
   widths, which crops the sides, not the face.

   The square comes back at 1024px, where the grid stacks and there is no
   second column left to match. */
.about__portrait{
  position:relative; flex:1; min-width:0;
  display:flex; flex-direction:column;
}
.about__portrait img,
.about__portrait .about__ph{
  width:100%; flex:1; min-height:0; object-fit:cover;
  background:var(--paper-2);
}
/* portraitFallback() in js/main.js swaps the <img> for a .tile__ph when the
   file is missing. That class is position:absolute for the work tiles, where it
   sits behind the image; here it IS the flow element, so it re-grounds. */
.about__portrait .about__ph{ position:static; }
.about__portrait figcaption{
  font-family:var(--mono); font-size:0.62rem;
  text-transform:uppercase; letter-spacing:.13em;
  color:var(--paper); margin-top:.6rem;   /* ink here would vanish on the band */
}
/* =============================================================
   EXPERIENCE
   ============================================================= */
/* NESTED INSIDE .about SINCE 2026-08-16, and .about is an INK band. Everything
   here was drawn for bone, so the colours below are all inverted from what they
   were: --rule became --rule-inv, and the three greys that used --muted or
   #2E2C26 now use paper at an alpha. --muted (#706D64) measures 3.78:1 on ink
   and fails AA for text at these sizes — do not put it back.
   The section padding and top rule went with the move: About supplies both. */
.exp{ margin-top:clamp(2.5rem,5vw,4rem); }

/* The drawer. Same 0fr→1fr grid trick as .exp__panel / .caps__panel, one level
   further out: this opens the whole list, each row inside still opens on its
   own. An auto-height grid resolves 1fr to max-content, so a row opening inside
   grows the drawer with it — no JS height maths anywhere. */
.exp__toggle{
  width:100%; display:flex; align-items:center;
  gap:clamp(1rem,3vw,2.5rem);
  padding:clamp(1rem,2.2vw,1.6rem) 0; text-align:left;
  border-top:var(--rule-inv); border-bottom:var(--rule-inv);
}
.exp__toggle-label{
  flex:1;
  font-weight:800; font-variation-settings:"wdth" 110,"wght" 780;
  font-size:clamp(1.25rem,2.7vw,2.15rem);
  line-height:1.05; letter-spacing:-.03em; text-transform:uppercase;
  transition:transform .5s var(--ease), color .4s var(--ease);
}
.exp__toggle-meta{
  font-family:var(--mono); font-size:0.64rem;
  letter-spacing:.12em; text-transform:uppercase;
  color:rgba(242,240,234,.62); white-space:nowrap;
}
/* Lime on hover/open — legal here for the same reason it is on .caps__name and
   was NOT on bone: this band is ink, where lime measures 16.99:1. */
.exp__toggle:hover .exp__toggle-label{
  transform:translateX(clamp(.3rem,1vw,.9rem)); color:var(--accent);
}
.exp__toggle[aria-expanded="true"] .exp__toggle-label{ color:var(--accent); }
.exp__toggle[aria-expanded="true"] .exp__plus::after{ transform:rotate(0); }
/* These three rules were a bare .exp__plus shared with the row heads. The rows
   are cards now and carry no glyph, so this is scoped to the toggle rather
   than left as a class nothing else can reach. Same geometry as .caps__plus —
   the two disclosures should agree on the one signal they still share.
   The [aria-expanded] rule above outranks the rotate(90deg) below on
   specificity, which is what un-rotates the bar into a minus when open. */
.exp__toggle .exp__plus{ position:relative; width:16px; height:16px; }
.exp__toggle .exp__plus::before,
.exp__toggle .exp__plus::after{
  content:""; position:absolute; inset:50% 0 auto 0;
  height:1.5px; background:currentColor;
  transition:transform .45s var(--ease);
}
.exp__toggle .exp__plus::after{ transform:rotate(90deg); }

.exp__drawer{
  display:grid; grid-template-rows:0fr;
  transition:grid-template-rows .6s var(--ease);
}
.exp.is-open .exp__drawer{ grid-template-rows:1fr; }
.exp__drawer-in{ overflow:hidden; }

/* ---- The rows are CARDS now (2026-08-22) ----
   Was a single-open accordion nested inside the drawer: an .exp__head button
   per row, a collapsing .exp__panel under it, and a plus glyph. Joshua asked
   for the card treatment from amirmohseni.com, where every role is open at
   once, so the second level of collapse is gone and every description is
   simply on show. The OUTER drawer stays — eight open cards is a long block,
   and it should still cost a click to ask for it.

   Nothing in here is a button any more. accordion('#expList','exp') no longer
   runs, .exp__head / .exp__panel / .exp__plus are all deleted rather than left
   styled-but-unreachable, and the hover and [aria-expanded] rules that moved
   the role title went with them.

   THE LIME RAMP. The reference lights each card in its own hue; this site has
   exactly one accent, so the cards vary in INTENSITY instead of hue. --k is
   the row index, written inline by the markup, 0 for the current role. --w
   turns it into a weight from 1 down to .125, and every alpha below is
   derived from it — so the list reads as a timeline that cools as it goes
   back in time, and the newest role is the brightest thing in the block.
   Add or remove a row and only the 8 in the --w divisor needs touching. */
.exp__list{
  --accent-rgb:214,255,63;      /* var(--accent) as a triplet, for rgba() */
  display:grid;
  gap:clamp(.6rem,1.3vw,1rem);
}

.exp__item{
  --w:calc((8 - var(--k, 0)) / 8);
  display:grid;
  /* Definite left column, 1fr right — the same reasoning as the work row:
     state the geometry rather than letting content negotiate it. */
  /* 44%, not a fixed max around 20rem: the reference splits its card almost
     evenly, and a narrow key column left the description a ~935px track to
     hold text already capped at 60ch — a third of the row was empty. The
     34rem ceiling stops the block sprawling on a very wide screen. */
  grid-template-columns:clamp(13rem,44%,34rem) 1fr;
  gap:clamp(1rem,2.4vw,2rem);
  align-items:stretch;
  padding:clamp(.5rem,1vw,.7rem);
  /* 5px, Joshua's call: enough to read as a card rather than a slab, well
     short of the reference's near-pill. It is the only radius on the site, so
     if anything else ever gets one it should match this. */
  border-radius:5px;
  border:1px solid rgba(var(--accent-rgb), calc(.10 + .32 * var(--w)));
}

/* GRADIENT REMOVED (Joshua's call, 2026-08-29). This carried a 96deg lime
   wash that ran into the card from the left, weighted by --w so the newest
   role was the brightest. The key column is plain now.

   --w IS STILL LIVE — do not delete it from .exp__item above. It also drives
   that card's border alpha, which still fades by recency; only the fill went.
   Restore by putting back:
     background:linear-gradient(96deg,
       rgba(var(--accent-rgb), 0) 0%,
       rgba(var(--accent-rgb), calc(.03 + .13 * var(--w))) 55%,
       rgba(var(--accent-rgb), calc(.06 + .30 * var(--w))) 100%); */
.exp__key{
  display:flex; flex-direction:column; justify-content:center;
  gap:.45rem;
  padding:clamp(1.2rem,2.6vw,1.9rem);
  border-radius:4px;
}

.exp__when{
  /* Taken back up from 0.33rem (Joshua: "mono a bit bigger"). 0.33 was a
     straight 50% of the original 0.66 and landed at ~5px, small enough that
     the year stopped reading. 0.5rem is ~8px — still a hard cut from where it
     started, but legible. The pair below moves with it: they were 0.66/0.64
     and keep that same half-step apart. */
  font-family:var(--mono); font-size:0.6rem;
  letter-spacing:.14em; text-transform:uppercase;
  font-variant-numeric:tabular-nums;   /* years stack in a column */
  color:rgba(242,240,234,.7);
}
/* A step down from the accordion's clamp(1.25rem,2.7vw,2.15rem): the title
   now sits in a ~20rem column rather than spanning the row, and the old top
   end wrapped every two-word role onto three lines. */
.exp__role{
  min-width:0;
  font-weight:800; font-variation-settings:"wdth" 110,"wght" 780;
  font-size:clamp(1.15rem,2vw,1.7rem);
  line-height:1.05; letter-spacing:-.03em; text-transform:uppercase;
}
/* Bricolage Grotesque — the same accent voice as .about__lead em, set a step
   heavier than that one because it sits small and needs to hold against the
   role title above it. text-transform:none keeps "ampersand" in the brand's
   own lowercase.
   Paper at .66 = 7.9:1 on ink. Not --muted, which is only 3.78:1 here. */
.exp__co{
  display:block; margin-top:.3rem;
  font-family:var(--voice); font-weight:500; font-stretch:92%;
  font-size:clamp(.9rem,1.35vw,1.1rem);
  line-height:1.15; letter-spacing:-.01em; text-transform:none;
  color:rgba(242,240,234,.66);
}

/* No left indent any more. The old rule pushed this clear of the year column
   because both shared one row; they are separate columns now and the padding
   is the card's own. */
.exp__body{
  display:flex; flex-direction:column; justify-content:center;
  padding:clamp(1rem,2.2vw,1.6rem) clamp(1rem,2vw,1.4rem);
}
.exp__body > p{
  max-width:60ch;
  line-height:1.6; font-size:clamp(.9rem,1.1vw,1.02rem);
  color:rgba(242,240,234,.72); text-wrap:balance;
}
/* max-width:none — a 60ch measure is right for the prose above but far too
   tight for this single mono line. The hyphens in Full-time / On-site are
   U+2011 (non-breaking) in the markup, so it breaks only at the middots.
   No padding-bottom: the card's own padding closes it now. */
/* BULLETS (2026-08-29). Each role was a single <p> until Joshua supplied
   per-role bullet copy; the body now holds a <ul class="exp__points">.

   The marker is a pseudo-element, not list-style. A native ::marker cannot
   take the accent colour independently of the text in every browser, and
   list-style-position:outside would hang the dot in .exp__body's padding
   where the card's left edge clips it. A padded li with an absolutely
   positioned dot puts it inside the card and makes it stylable.

   Inherits its type from .exp__body > p above rather than restating it, so
   the two stay in step if that block is ever retuned. */
.exp__points{
  list-style:none; margin:0; padding:0;
  display:flex; flex-direction:column; gap:.55rem;
  max-width:62ch;
}
.exp__points li{
  position:relative;
  padding-left:1.05rem;
  line-height:1.55; font-size:clamp(.88rem,1.05vw,1rem);
  color:rgba(242,240,234,.72);
}
.exp__points li::before{
  content:"";
  position:absolute; left:0; top:.62em;
  width:5px; height:5px; border-radius:50%;
  /* Weighted by recency like the card border, so the newest role's bullets
     read a touch brighter. --w is inherited from .exp__item. */
  background:rgba(var(--accent-rgb), calc(.35 + .45 * var(--w)));
}

/* .exp__body > .exp__meta, not a bare .exp__meta: this is a <p> inside
   .exp__body, so `.exp__body > p` above (0,2,0) outranked the plain class
   (0,1,0) and silently held the line at clamp(.9rem,1.1vw,1.02rem) — every
   value below except max-width was being ignored. Matching that specificity
   and sitting later in the file is what makes these win. */
.exp__body > .exp__meta{
  max-width:none;
  /* Up from 0.32rem with .exp__when above — see the note there. */
  font-family:var(--mono); font-size:0.58rem;
  letter-spacing:.12em; text-transform:uppercase;
  color:rgba(242,240,234,.55); margin-top:.9rem;
}

/* The "currently here" mark. The 1px ink ring it used to carry separated lime
   from bone; on this band the ring would be the background colour, so it is
   dropped rather than left in doing nothing. */
.exp__item--now .exp__when::before{
  content:""; display:inline-block; vertical-align:middle;
  width:.4rem; height:.4rem; border-radius:50%;
  background:var(--accent);
  margin-right:.5rem;
}

/* One column below 820. The left block keeps its wash and sits above the
   description, which is the reference's own small-screen fallback. */
@media (max-width:820px){
  /* These four links are the ENTIRE nav here — there is no toggle and no
     mobile menu behind one — so they have to fit the narrowest phone. Tighter
     tracking and gap rather than a smaller face: below ~.6rem the mono stops
     being readable, and the tracking is what actually costs the width on a
     .12em uppercase string. Vertical padding is untouched, so the 44px hit
     area survives the squeeze. */
  /* Keep clearing the rule — this overrides the desktop padding wholesale,
     so it has to restate the --ruler-h offset or the pill rides over it. */
  .nav{ padding:calc(var(--ruler-h) + .4rem) .6rem .75rem; }
  /* Vertical padding moves OFF the pill and ONTO the links, rather than being
     split between them: the pill ends up the same height either way, but only
     padding on the <a> counts toward its tap target. At .62rem the mono line
     box is ~13px, so ~15.5px a side is what reaches 44. */
  .nav__pill{ padding:0 .8rem; }
  .nav__links{ gap:.62rem; }
  .nav__links a{
    font-size:.62rem; letter-spacing:.06em;
    padding:.97rem 0;
  }   /* 18px glyph + 2x.82rem ≈ 44px target */

  .exp__item{ grid-template-columns:1fr; gap:.4rem; }
  .exp__body{ padding-top:.4rem; }
}

/* =============================================================
   CAPABILITIES
   ============================================================= */
.caps{
  background:var(--ink); color:var(--paper);
  padding:clamp(4rem,9vw,8rem) var(--pad);
}
/* NO RULES BETWEEN THE ROWS (2026-08-22, Joshua's call). They used to carry
   border-bottom:var(--rule-inv), plus a border-top on the first, so the six
   capabilities read as a ruled table. The rows are far apart and set large;
   the spacing separates them on its own, and the hairlines were the only
   grey on an otherwise ink-and-paper band.
   The rows still need their vertical rhythm, which the borders were quietly
   contributing nothing to — .caps__head owns that padding — so nothing
   replaces them. */
/* Centred to the page, and the +/− is gone (2026-08-16, Joshua's call).
   justify-content:center with .caps__name no longer flex:1 — while the name
   grew to fill the row, centring its TEXT still left the index pinned to the
   far left. Sized to content, the index and name centre together as one group.
   Losing the plus means the only remaining cue that a row opens is the lime
   hover/open colour. aria-expanded still carries the state to screen readers,
   so this costs sighted users an affordance, not assistive ones. */
/* No `gap` here any more — the space between the index and the name lives on
   .caps__idx as margin-right instead. A flex gap belongs to the container and
   cannot be animated per item, and collapsing the index without also
   collapsing its gap would leave the name parked off-centre. */
.caps__head{
  width:100%; display:flex; align-items:center; justify-content:center;
  padding:clamp(1rem,2.4vw,1.9rem) 0; text-align:center;
}
/* width in ch, not em or a guess: Space Mono is monospaced, so two digits are
   exactly 2ch, plus the .14em tracking that follows each of them. That makes
   the collapsed state land on precisely zero with no clipping at any size. */
.caps__idx{
  font-family:var(--mono); font-size:0.66rem; letter-spacing:.14em;
  color:var(--accent); flex:0 0 auto;
  width:calc(2ch + .28em);
  margin-right:clamp(1rem,3vw,3rem);
  overflow:hidden; white-space:nowrap;
  transition:opacity .3s var(--ease),
             width .45s var(--ease),
             margin-right .45s var(--ease);
}
/* Open: the number folds away and the name slides to the true centre of the
   page. Both the width and the margin have to go, or it only moves halfway. */
.caps__head[aria-expanded="true"] .caps__idx{
  opacity:0; width:0; margin-right:0;
}
.caps__name{
  flex:0 1 auto;
  font-weight:800; font-variation-settings:"wdth" 112,"wght" 780;
  font-size:clamp(1.5rem,4.4vw,3.4rem);
  line-height:1; letter-spacing:-.035em; text-transform:uppercase;
  transition:color .4s var(--ease);
}
/* No translateX on hover any more — a centred row sliding sideways reads as
   drift rather than as a response. Colour alone carries it. */
.caps__head:hover .caps__name{ color:var(--accent); }
.caps__head[aria-expanded="true"] .caps__name{ color:var(--accent); }
.caps__panel{
  display:grid; grid-template-rows:0fr;
  transition:grid-template-rows .55s var(--ease);
}
.caps__item.is-open .caps__panel{ grid-template-rows:1fr; }
.caps__panel-in{ overflow:hidden; }
/* Centred with the rows above it. The old padding-left indented the prose to
   hang off the row's text — meaningless now the row is centred, so it is
   replaced by an auto margin that centres the 60ch measure instead. */
.caps__panel-in p{
  max-width:60ch; padding:0 0 1.6rem;
  margin-inline:auto; text-align:center;
  color:rgba(242,240,234,.62); line-height:1.6;
  font-size:clamp(.9rem,1.1vw,1.02rem);
  /* Bricolage, not the inherited Archivo. Stated 2026-08-26 — until then this
     rule named no family at all, so body's --display won by inheritance and
     these six panels were the one run of prose on the site set in the display
     face. The type note at the foot of this file already said prose belongs to
     --voice; this makes the rule agree with it.
     The axes are .hero__copy's, deliberately: that is the site's only other
     block of running Bricolage prose, and two prose blocks in the same face at
     different widths would read as a mistake rather than a choice. */
  font-family:var(--voice); font-weight:400; font-stretch:96%;
  /* These panels are 2-3 lines, so the rag is very visible and a single-word
     last line looks broken. `balance` evens the lines and, unlike `pretty`
     (which changed nothing here), it cannot leave an orphan. It recalculates
     per viewport, so it holds at every width — which hand-placed &nbsp; alone
     does not. Two &nbsp; in index.html handle the phrase splits balance leaves
     behind. Unsupported browsers just fall back to normal wrapping. */
  text-wrap:balance;
}

/* =============================================================
   CLIENTS
   ============================================================= */
/* Inverted ink band, matching the ticker at the top of the page. Padding is
   vertical only so the marquee can bleed to both edges; the label re-adds its
   own horizontal padding. */
.clients{
  background:var(--ink); color:var(--paper);
  padding:clamp(3rem,6vw,5.5rem) 0;
}
.clients__label{
  padding:0 var(--pad);
  color:var(--accent);
}

/* Two counter-scrolling rows. Cells are fixed-width flex items rather than
   grid cells, so a track can run past the viewport edge. The duplication and
   the animation-duration (constant px/sec regardless of how many clients) are
   both handled by marquee() in js/main.js — same mechanism as the ticker. */
.logos{ display:grid; gap:clamp(.5rem,1vw,.9rem); }
.logos__row{ display:flex; overflow:hidden; }
.logos__track{
  display:flex; align-items:center; flex:0 0 auto;
  gap:clamp(.5rem,1vw,.9rem);
  padding-right:clamp(.5rem,1vw,.9rem);
  animation:slide var(--marquee-dur,40s) linear infinite;
  will-change:transform;
}
.logos__row--rev .logos__track{ animation-direction:reverse; }
/* Runs straight through the pointer (2026-08-22, Joshua's call). It used to
   pause on hover so a visitor could stop and read a mark; the stutter as the
   cursor crossed the strip cost more than the read was worth. The word .ticker
   above still pauses — that one is text, and this one is not. */
/* The duplicated half is display:contents so its cells stay flex items of
   the track while the wrapper carries aria-hidden. */
.logos__half{ display:contents; }
/* Nothing sits in normal flow inside a cell — logo, tag and fallback name are
   all absolutely positioned. That keeps a wide logo's intrinsic width out of
   grid track sizing (otherwise a 600px wordmark inflates its column) and lets
   the percentage caps below resolve against a definite box. */
.logo{
  flex:0 0 auto;
  width:clamp(108px,11vw,158px);
  position:relative;
  aspect-ratio:3/2;
  border:var(--rule-inv);          /* light rule — the band is ink */
  transition:background .4s var(--ease), border-color .4s var(--ease);
}
.logo:hover{ background:var(--accent); }

/* The rectangle IS the placeholder. Once a real logo loads, js/main.js adds
   .has-logo and the outline goes transparent so the mark sits free on the
   paper. Transparent rather than border:0 — keeps the box geometry identical
   so nothing reflows as logos are added one at a time. */
.logo.has-logo{ border-color:transparent; }
.logo.has-logo:hover{ border-color:transparent; }

/* The image box is a FIXED share of the cell and the artwork is contained
   inside it — not sized from the file's own intrinsic dimensions. SVGs
   without width/height attributes report unreliable intrinsic sizes, which
   made wide wordmarks render far smaller than their cap allowed. This way a
   wordmark fills the box's width, a compact mark fills its height, and both
   land on a comparable optical area whatever the source file looks like.
   Tune a single logo with style="--scale:1.15". */
.logo__img{
  position:absolute; z-index:1;
  top:50%; left:50%; transform:translate(-50%,-50%);
  width:calc(74% * var(--scale,1));
  height:calc(50% * var(--scale,1));
  object-fit:contain;
  /* Silhouette to pure white — the band is ink, so brightness(0) alone would
     make every logo invisible. On hover the cell fills lime, so the mark
     flips back to ink to stay legible. */
  filter:brightness(0) invert(1);
  opacity:.7;
  transition:opacity .4s var(--ease), filter .4s var(--ease);
}
.logo:hover .logo__img{ opacity:1; filter:brightness(0); }

/* Escape hatch: brightness(0) silhouettes the whole file, so a logo supplied
   as white-on-a-coloured-block flattens to a solid bar. Supply transparent
   single-colour marks where possible; where you can't, add class="logo--raw"
   to that cell to keep the artwork as-is. */
.logo--raw .logo__img{ filter:none; opacity:.85; }
.logo--raw:hover .logo__img{ opacity:1; }

/* Fallback: shown when the logo file is missing (the img removes itself). */
/* Solid rather than outlined — outline type reads well at 72px, but goes
   fuzzy at the ~20px this cell allows. */
.logo__name{
  position:absolute; inset:0; z-index:0;
  display:grid; place-items:center;
  padding:.5rem;
  text-align:center; overflow-wrap:anywhere;
  font-weight:800; font-variation-settings:"wdth" 100,"wght" 750;
  font-size:clamp(.58rem,.95vw,.78rem);
  line-height:1.2; letter-spacing:-.01em; text-transform:uppercase;
  color:rgba(242,240,234,.6);
  transition:color .4s var(--ease);
}
.logo:hover .logo__name{ color:var(--ink); }
/* Once the logo loads the name is redundant — and it would otherwise show
   through around the mark, since the image doesn't cover the whole cell.
   The alt text carries the name for screen readers. */
.logo.has-logo .logo__name{ display:none; }

/* =============================================================
   CONTACT
   ============================================================= */
/* No top rule — the ink clients band above already provides the division. */
.contact{
  padding:clamp(4rem,9vw,8rem) var(--pad) clamp(3rem,6vw,5rem);
}
/* Name and ask left, address bar and links right — the reference's split. */
/* ONE COLUMN (2026-08-22, Joshua's call). Was a two-column split with the name
   and ask left, the address bar and links right. The bar and links now sit
   BELOW "Let's talk." and run the full measure, which is what gives the pill
   its length. */
.contact__grid{
  display:grid;
  grid-template-columns:1fr;
  /* Tightened 2026-08-22: was clamp(2rem,4.5vw,3.5rem). Stacked in one
     column the pitch, the pill and the links are one thought, and a
     56px trough between them read as three separate blocks. */
  gap:clamp(.9rem,1.8vw,1.4rem);
}
/* Same scale as .sec-head__title, so this reads as a section head like every
   other one rather than as a one-off. */
.contact__title{
  font-weight:900; font-variation-settings:"wdth" 122,"wght" 900;
  font-size:clamp(2.6rem,8.4vw,8rem);
  line-height:.85; letter-spacing:-.04em; text-transform:uppercase;
  margin-bottom:1.6rem;
}
/* Was the section's big type at clamp(2rem,6.4vw,5.5rem). The wordmark above
   holds that role now, so this drops to a supporting line — still the only
   sentence here that asks for anything, which is why it survived the redesign. */
.contact__pitch{
  font-weight:500; font-variation-settings:"wdth" 105,"wght" 500;
  font-size:clamp(1.1rem,2.1vw,1.65rem);
  line-height:1.25; letter-spacing:-.02em;
  /* margin-bottom:0 — the grid gap above owns the space under this now.
     Leaving the old 2rem here stacked on top of the gap. */
  max-width:22ch; margin-bottom:0;
}
.contact__pitch em{
  font-family:var(--voice); font-weight:300; font-stretch:100%;
  font-style:normal;   /* <em> is italic by UA default — reset or it obliques */
  letter-spacing:-.01em;
  color:var(--muted);
}

/* The pill and the links are siblings in here, and .contact__grid's gap only
   separates .contact__lead from .contact__side — not these two. Without this
   they sat flush. Same value as the grid gap so the three blocks are evenly
   spaced down the column. */
.contact__side{
  display:grid;
  gap:clamp(.9rem,1.8vw,1.4rem);
}

/* ---- The address ----
   The pill is gone (2026-08-22, Joshua's call): no border, no lime gradient,
   no radius. What was a lit capsule with a separate Copy button is now just
   the address, set in the accent, doing the copying itself.

   The old two-child flex row and its whole mobile variant went with the box —
   there is one child left and nothing to space it against. */
.contact__mailbar{ display:block; }

/* A <button> reset to look like text. text-align:left because a button
   centres its label by default, which would have pulled the address off the
   left edge every other block in this column sits on. */
.contact__mail{
  display:inline-block; max-width:100%;
  background:none; border:0; padding:0; margin:0;
  text-align:left; cursor:pointer;
  overflow-wrap:anywhere;
  /* Lowercase — the markup was always lowercase and it was CSS uppercasing it,
     so the address you copy never changed. */
  font-family:inherit;
  font-weight:800; font-variation-settings:"wdth" 110,"wght" 780;
  font-size:clamp(.95rem,2vw,1.6rem);
  line-height:1.1; letter-spacing:-.02em; text-transform:none;
  color:var(--accent-ink);
  transition:opacity .35s var(--ease);
}
.contact__mail:hover{ opacity:.62; }
/* The confirmed state. copyBtn() swaps the label to "Copied" for ~1.6s and
   back; on touch, where there is no cursor lens to read COPY, this label swap
   is the ONLY feedback that anything happened, which is why it is on the
   element itself rather than in a toast somewhere else. */
.contact__mail.is-done{ color:var(--ink); }

/* ---- The links ----
   2x2 rather than the reference's 3x2: four links, and a 3-wide grid would
   leave a hole in the second row. */
/* One row, not the old 2x2 grid (Joshua's call). flex-wrap rather than
   repeat(4,auto): at 11px these four fit a desktop row easily, but
   "Curriculum Vitae" is long and a fixed four-column track would overflow a
   phone instead of wrapping. margin-top is small because the grid gap above
   already separates this from the pill. */
.contact__links{
  display:flex; flex-wrap:wrap;
  gap:clamp(.7rem,2.4vw,2.2rem);
  margin-top:0;
}
.contact__links li{ min-width:0; }
.contact__links a{
  display:inline-flex; align-items:baseline; gap:.4rem;
  font-weight:800; font-variation-settings:"wdth" 110,"wght" 780;
  /* 40% of the previous clamp(1.05rem,2.1vw,1.75rem), Joshua's call — these
     read as utility links now rather than as headings. */
  font-size:clamp(.42rem,.84vw,.7rem);
  line-height:1.15; letter-spacing:-.02em;
  transition:color .4s var(--ease);
}
/* Inherits the link colour. Lime here was 1.01:1 on paper and needed a
   -webkit-text-stroke to be visible at all. */
.contact__links a span{
  color:currentColor;
  transition:transform .4s var(--ease);
}
.contact__links a:hover span{ transform:translate(3px,-3px); }
.contact__links a:hover{ color:var(--muted); }

/* One column below 820: the two halves stack, and the links stay 2x2 because
   four short labels still fit two-up on a phone. */
@media (max-width:820px){
  .contact__pitch{ max-width:none; }
  /* text-align cannot centre flex items — the row needs its own rule. */
  /* The address is an inline-block button, so centring it means centring it
     INSIDE its block parent — text-align on .contact__mail alone would only
     move the label within a box already the width of its own text. The
     button's own text-align follows so a wrapped address stays centred too. */
  .contact__mailbar{ text-align:center; }
  .contact__mail{ text-align:center; overflow-wrap:anywhere; }
  .contact__links{ justify-content:center; }
}

/* =============================================================
   FOOTER
   ============================================================= */
/* Two stacked bands: the copyright/⌘+Top row, then a centred sign-off under it.
   The flex row moved down to .foot__row so the sign-off can be a full-width
   block and centre against the PAGE rather than against whatever space the
   other two items happen to leave. */
.foot{
  display:grid; gap:.9rem;
  padding:1.2rem var(--pad);
  border-top:var(--rule); color:var(--ink);
}
.foot__row{
  display:flex; justify-content:space-between; align-items:center;
  gap:1rem; flex-wrap:wrap;
}
/* text-transform:none — .foot is in the shared mono/uppercase helper, which
   would render this "MADE WITH CLAUDE :P". Keep the sentence case it was
   written in; the :P does not survive shouting. */
.foot__made{
  text-align:center; text-transform:none;
  color:var(--muted);
}
/* Space Mono has no emoji glyph, so this falls through to the system emoji
   font anyway — naming it explicitly just stops the fallback picking something
   monochrome. letter-spacing:0 is the part that matters: .14em is inherited
   from the .foot type helper, and on the LAST character that tracking is added
   as trailing space, which pushes the centred line visibly off-centre. */
.foot__emoji{
  font-family:"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
  letter-spacing:0; font-size:1.05em; vertical-align:-.08em;
}
/* Was a bare 16px line box. 44px min target without changing where it sits. */
.foot a{
  display:inline-flex; align-items:center; gap:.45em; min-height:44px;
}
/* Lime pill. Ink on lime is 16.99:1, the same pairing as the nav — but SOLID
   here, not the nav's translucent glass: this sits on flat bone with nothing
   behind it worth sampling, so a blur would cost a compositing layer and show
   nothing. min-height on .foot a already covers the 44px target, so the padding
   below is shape rather than hit area. */
.foot__top{
  background:var(--accent); color:var(--ink);
  border-radius:100px; padding:.5rem 1.1rem;
  transition:background .35s var(--ease), color .35s var(--ease);
}
.foot__top:hover{ background:var(--ink); color:var(--accent); }

/* =============================================================
   CASE STUDY OVERLAY
   ============================================================= */
/* ---- INVERTED GROUND  (Joshua's call, 2026-08-20) -----------------------
   Every case study renders on ink, not bone.

   This is a scoped TOKEN SWAP, not a pile of per-element overrides. The whole
   overlay — the hero, the meta rails, the band stack, .doc in full, and the
   placeholder art that shows until the image files land — already takes its
   colour from --paper / --paper-2 / --ink / --muted / --rule. Redefining those
   six here re-grounds all of it at once, and any block added to a case study
   later inherits the flip for free.

   Read the names as ROLES inside this scope, not as literal colours: --paper
   is "the ground", --ink is "type on the ground". That is what they already
   mean everywhere else on the site; only the hexes behind them change here. So
   when something inside the overlay needs the DARK value — ink on a lime
   highlight, say — the token for it is var(--paper). Two of the exceptions
   below do exactly that.

   `color` has to be set alongside `background`. body carries color:var(--ink)
   resolved against :root, so without that line every element that does not
   name its own colour (.case__title, .case__meta dd, .doc__statement,
   .doc__copy p) would inherit near-black and disappear into the ground.

   --muted is NOT the site value carried over. #706D64 measures 3.02:1 here and
   would have failed AA on the deck, the figcaptions, the rules copy and the
   credits rail — every one of them body-sized. #9A968B measures 6.62:1 on
   --paper and 5.89:1 on --paper-2, which are the only two grounds it is ever
   set on. Re-measure before darkening it.

   Six things could not follow the swap and are overridden in place, each one
   tagged `INVERTED GROUND` so `grep "INVERTED GROUND" css/style.css` lists
   them: the close button (3 states), the lime inline highlight, the swatch
   keyline, ::selection, and the shared .tile__ph placeholder. Extend that list
   rather than sprinkling background: onto individual case elements.
   ------------------------------------------------------------------------ */
.case{
  --paper:    #0C0C0C;
  --paper-2:  #1A1A1A;
  --ink:      #F2F0EA;
  --ink-2:    #E7E4DA;
  --muted:    #9A968B;
  --rule:     1px solid rgba(242,240,234,.18);
  --rule-inv: 1px solid rgba(12,12,12,.16);

  position:fixed; inset:0; z-index:9100;
  background:var(--paper);
  color:var(--ink);
  clip-path:inset(100% 0 0 0);
  transition:clip-path .8s var(--ease);
}
/* INVERTED GROUND: the global rule pairs lime with var(--ink), which is cream
   in here — lime on cream is 1.01:1. Selected text takes the ground colour
   instead, the same 17.0:1 pairing the rest of the site gets. */
.case ::selection{ background:var(--accent); color:var(--paper); }
.case[hidden]{ display:none; }
.case.is-open{ clip-path:inset(0 0 0 0); }
.case__scroll{
  height:100%; overflow-y:auto; overscroll-behavior:contain;
  padding:0 0 clamp(3rem,6vw,6rem);
  /* Only ever animates on the way OUT — see .case.is-closing below. Declared
     here rather than on that class so the property has a value to start from;
     a transition declared only on the end state does not run on the way in. */
  transition:opacity .26s var(--ease);
}
/* CLOSING — the box shrinks to the tile, the content does not come with it.
   .case only animates a clip-path, so without this the case study stays at
   full size, still scrolled wherever the reader left it, and the shrinking
   box reads as a porthole sliding shut over an arbitrary mid-document crop
   rather than as the page collapsing into its tile. Fading the content over
   the first third of the 800ms leaves a plain dark box to land on the tile.

   overflow:hidden is the other half, and it is about the RIGHT EDGE. At the
   first frame of a close there are briefly two scrollbars side by side: this
   container's own, and the page's, which reappears the instant closeCase()
   unlocks the body in order to measure. That is ~30px of scrollbar furniture
   sitting over the animation. This kills one of them; closeCase() re-locks
   the body for the duration of the animation to kill the other. */
.case.is-closing .case__scroll{ opacity:0; overflow:hidden; }
/* Genuine floating chrome — it sits above the case-study content and will pass
   over the detail imagery once real files land, which is where the blur earns
   its keep. The lime hover fill is opaque and overrides the glass. */
.case__close{
  position:fixed; top:1rem; right:var(--pad); z-index:2;
  width:52px; height:52px; border-radius:50%;
  /* INVERTED GROUND: --glass-bg is a dark tint, which was a visible disc on
     bone and is nothing on ink. The lens lifts off the ground instead of
     sinking into it, and the glyph takes the light value. */
  background:rgba(242,240,234,.14); color:var(--ink);
  -webkit-backdrop-filter:var(--glass-blur);
          backdrop-filter:var(--glass-blur);
  border:var(--glass-edge);
  display:grid; place-items:center; font-size:1.6rem; line-height:1;
  transition:transform .45s var(--ease), background .35s var(--ease);
}
/* INVERTED GROUND: dark glyph on the lime fill — var(--ink) is cream in here. */
.case__close:hover{ transform:rotate(90deg); background:var(--accent); color:var(--paper); }

.case__hero{ padding:clamp(4.5rem,8vw,7rem) var(--pad) 0; }
.case__num{
  font-family:var(--mono); font-size:0.68rem; letter-spacing:.16em;
  /* Grey, not lime (Joshua's call). The stroke went with it: it existed only
     to hold acid lime together at 0.68rem, and --muted is 4.54:1 unaided.
     Shared by all ten case studies, not just apollo-hospital. */
  text-transform:uppercase; color:var(--muted); margin-bottom:1rem;
}
.case__title{
  font-weight:900; font-variation-settings:"wdth" 125,"wght" 900;
  font-size:clamp(2.4rem,9.5vw,9rem);
  line-height:.86; letter-spacing:-.045em; text-transform:uppercase;
  margin-bottom:1.5rem;
}
.case__meta{
  display:grid; grid-template-columns:repeat(3,1fr); gap:var(--gap);
  border-top:var(--rule); border-bottom:var(--rule);
  padding:1rem 0; margin-bottom:clamp(2rem,4vw,3.5rem);
}
.case__meta dt{
  font-family:var(--mono); font-size:0.62rem; letter-spacing:.14em;
  text-transform:uppercase; color:var(--ink); margin-bottom:.35rem;
}
.case__meta dd{ margin:0; font-size:.95rem; line-height:1.45; }

.case__figure{
  position:relative; background:var(--paper-2);
  margin:0 var(--pad) clamp(2rem,4vw,3.5rem);
  aspect-ratio:16/9; overflow:hidden;
}
.case__figure img{
  position:relative; z-index:1;
  width:100%; height:100%; object-fit:cover;
}
.case__figure--tall{ aspect-ratio:3/2; }

/* ---- Sliced case study: seamless band stack ----------------------------
   A design cut into artboards and exported band by band. The bands ARE the
   layout, so this deliberately does the opposite of .case__figure above:
   no aspect-ratio, no object-fit:cover, no margins. Each band renders at
   whatever height its own ratio gives it, edge to edge, butted against its
   neighbours. Any cropping or gap here would tear the artwork apart.

   Full-bleed is also what makes the type legible on a phone: it takes a
   375px viewport from 338px of image to the full 375px, which is the
   difference between a 0.24 and a 0.27 downscale of the baked-in text.
   See claude-handoff/case-study-spec.md for the type sizes that depends on. */
.case__bands{ margin-bottom:clamp(2rem,4vw,3.5rem); }
.case__band{
  position:relative; display:block; margin:0;
  background:var(--paper-2);
  /* Fallback height for the placeholder only — an <img> that 404s is removed
     by its onerror, leaving nothing to hold the box open. --band-ratio comes
     from each block's w/h so the reserved space matches the real artboard. */
  aspect-ratio:var(--band-ratio,1400/900);
}
/* Once the image is in, it sets the height and the ratio above is irrelevant. */
.case__band:has(img){ aspect-ratio:auto; }
/* Shown inside a band placeholder when its `alt` is empty. Deliberately the
   one loud red thing on an otherwise bone-and-ink site — it should not be
   possible to ship a case study without noticing it. */
.tile__ph-warn{
  margin-top:.3rem; padding:.2rem .5rem;
  background:#B4231F; color:#fff;
  font-size:.58rem; letter-spacing:.1em;
}
.case__band img{
  position:relative; z-index:1;
  display:block; width:100%; height:auto;
}
/* INVERTED GROUND: .tile__ph is shared with the work tiles, which are still on
   bone, so it cannot simply be re-tokened at source. Every part of it is drawn
   in ink alpha — the 45° hatch, the dashed edge, the ghost numeral — and all of
   it disappears against this ground. The `code` chip inverts twice over: it
   pairs lime with var(--ink), which is cream in here.
   Eight of the ten case studies are band stacks still awaiting artwork, so this
   is most of what a reader currently sees. */
.case .tile__ph{
  background:
    repeating-linear-gradient(45deg,
      rgba(242,240,234,.05) 0 12px, transparent 12px 24px),
    var(--paper-2);
  border:1px dashed rgba(242,240,234,.26);
}
.case .tile__ph b{ color:rgba(242,240,234,.18); }
.case .tile__ph code{ background:var(--paper); color:var(--accent); }

.case__body{
  display:grid; grid-template-columns:1fr 1.4fr;
  gap:clamp(1.5rem,4vw,4rem);
  padding:0 var(--pad) clamp(2.5rem,5vw,4rem);
}
.case__body h3{
  font-family:var(--mono); font-size:0.66rem; font-weight:400;
  text-transform:uppercase; letter-spacing:.14em; color:var(--ink);
}
.case__prose{ display:grid; gap:1.15rem; max-width:60ch;
  font-size:clamp(1rem,1.35vw,1.2rem); line-height:1.6; }
.case__prose .todo{
  font-family:var(--mono); font-size:0.7rem; line-height:1.6;
  text-transform:uppercase; letter-spacing:.08em;
  color:var(--muted); border-left:2px solid var(--accent);
  padding:.5rem 0 .5rem .85rem;
}
.case__next{
  display:flex; align-items:center; justify-content:space-between;
  gap:1rem; flex-wrap:wrap;
  margin:0 var(--pad); border-top:var(--rule); padding-top:1.2rem;
}
.case__next-label{
  font-family:var(--mono); font-size:0.64rem;
  text-transform:uppercase; letter-spacing:.14em; color:var(--ink);
}
.case__next-title{
  font-weight:900; font-variation-settings:"wdth" 118,"wght" 880;
  font-size:clamp(1.6rem,5vw,3.6rem);
  line-height:1; letter-spacing:-.04em; text-transform:uppercase;
  transition:color .35s var(--ease);
}
.case__next:hover .case__next-title{ color:var(--muted); }

/* =============================================================
   CASE STUDY — DOCUMENT FORMAT  (.doc)

   Two projects use this: apollo-hospital and atlys. The other eight are
   `blocks` band stacks with every word baked into the artwork; these two are
   live text — around 32 page renders of a printed guideline document, and
   around 77 social frames. The reasoning is at the top of each of
   js/case-apollo-hospital.js and js/case-atlys.js — read it before converting
   anything else to this format, or either of these back.

   It renders INSIDE the .case overlay, below .case__hero and above
   .case__next, so it inherits the overlay's --paper ground and its chrome.
   Everything here is flush-left inside var(--pad) with the measure capped on
   the text itself — the same way .about__grid, .case__body and .case__next do
   it. No centred content column: a centred column would start further in than
   the hero title above it and the misalignment is visible at any width above
   1024px.

   ---- Type -------------------------------------------------------------
   The source build is set in Helvetica Neue and SF Mono. Nothing of that
   survives here. Three families, all already served by the site:
     Archivo   (--display)  section titles and the opening statement
     Bricolage (--voice)    all prose, decks, captions in sentence case
     Space Mono(--mono)     eyebrows, run labels, figcaptions, credits labels
   Every Bricolage rule that wants a weight sets it through
   font-variation-settings, not font-weight: body pins "wght" 400 and an
   inherited axis beats the property. See the FONTS note at the top of the file.

   ---- Colour -----------------------------------------------------------
   Apollo teal and gold are the SUBJECT of that case study, not its interface.
   Teal on bone measures 4.22:1 — under AA for body text — so neither colour is
   ever type here. They appear only as the brand's own devices: the
   teal-over-gold foot rule the whole stationery system closes on, the
   craft-rule keylines, and the swatch fills where the hexes have to be
   checkable. Everything readable is --ink or --muted.

   Inline emphasis is the site's acid lime instead, as a highlight band behind
   ink type (17.0:1). One accent, used the way the rest of the site uses it.
   To hand the emphasis back to the project, swap --accent for --doc-teal in
   the .doc__intro/.doc__copy em rule — but check the size first: teal only
   clears AA above 24px, and body copy here is nowhere near that.
   ============================================================= */
.doc{
  /* Apollo's pair, lifted off pages 15 and 16 of the guideline, not sampled
     from a render. These two names are the block's colour interface, not a
     statement that every document is teal and gold: a second document declares
     its own hexes under the same two names as an inline style on its wrapper
     (js/case-atlys.js sets Atlys periwinkle). Renaming them to something
     project-neutral would mean touching every doc file, so they keep the
     names the first one gave them. */
  --doc-teal:#007C9D;
  --doc-gold:#FDB813;
  /* Every image run and the swatch rail render at 40% of the width they used
     to (Joshua's call). Expressed against the padded content box rather than
     the viewport so "40%" means 40% of what was actually on screen before.
     Reset to 100% at the 820px breakpoint — 40% of a phone is 140px, which is
     narrower than any page of a printed guideline can be read at. */
  --doc-run:calc((100% - 2 * var(--pad)) * .4);
  font-family:var(--voice);
  font-size:clamp(.95rem,1.15vw,1.075rem);
  line-height:1.62;
  /* Centred throughout (Joshua's call, replacing the flush-left setting this
     block was first written to). Every measure-capped element below therefore
     needs margin-inline:auto, or a 60ch column would sit centred inside itself
     but hard left inside the page. text-wrap:pretty on the prose stops the
     ragged-both-sides setting ending a paragraph on a single word. */
  text-align:center;
  text-wrap:pretty;
  margin-bottom:clamp(2rem,4vw,3.5rem);   /* same gap .case__bands leaves */
}
.doc em{ font-style:normal; }   /* <em> is italic by UA default; Bricolage has none */

/* ---- opening statement ---- */
.doc__lead{ padding:0 var(--pad) clamp(2rem,4vw,3.25rem); }
.doc__client{
  font-family:var(--mono); font-size:0.62rem; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted); margin-bottom:1.25rem;
}
/* Same geometry as .contact__pitch — the two big statements on the site should
   be set identically, so this reads as a section of the site rather than a
   headline imported from somewhere else. */
.doc__statement{
  font-family:var(--display);
  font-weight:800; font-variation-settings:"wdth" 110,"wght" 780;
  font-size:clamp(2rem,6.4vw,5.5rem);
  line-height:1; letter-spacing:-.04em; text-transform:uppercase;
  max-width:14ch; margin:0 auto 1.4rem; text-wrap:balance;
}
/* The Bricolage-light accent, exactly as .contact__pitch em and .about__lead em
   use it. NOT the lime highlight the body copy gets — a highlight band behind
   88px type is a block of colour, not an emphasis. */
.doc__statement em{
  font-family:var(--voice); font-variation-settings:"wdth" 100,"wght" 300;
  letter-spacing:-.02em; color:var(--muted);
}
.doc__role{
  font-size:clamp(1.05rem,1.9vw,1.4rem); line-height:1.4;
  max-width:46ch; margin:0 auto clamp(1.5rem,3vw,2.25rem);
}
.doc__role strong{ font-variation-settings:"wdth" 100,"wght" 700; }
.doc__intro{ display:grid; gap:1.1rem; max-width:60ch; margin-inline:auto; }

/* The one device the whole printed system closes on: a 2px teal hairline with
   a 2px gold hairline under it. Letterhead, envelope, note pad, prescription
   pad and report file all end on this pair, so the page about them does too. */
.doc__rule{
  height:5px; margin:0; border:0;
  background:
    linear-gradient(var(--doc-teal) 0 2px,transparent 2px) 0 0/100% 100% no-repeat,
    linear-gradient(var(--doc-gold) 0 2px,transparent 2px) 0 3px/100% 100% no-repeat;
}

/* ---- numbered sections ---- */
.doc__s{ padding:clamp(2.75rem,6vw,5rem) 0 clamp(1.75rem,3.5vw,3rem); }
.doc__head{ padding:0 var(--pad); }
/* Rectangular cells divided by verticals, not a pill — every label in the
   source document is a rectangle. */
.doc__eyebrow{
  display:flex; align-items:stretch; justify-content:center;
  border-top:var(--rule);
  font-family:var(--mono); font-size:0.62rem; letter-spacing:.16em;
  text-transform:uppercase; color:var(--muted); margin-bottom:1.25rem;
}
.doc__eyebrow span:not(.doc__chip){ display:flex; align-items:center; padding:.6rem .9rem; }
.doc__chip{
  display:flex; align-items:center; justify-content:center;
  min-width:2.75rem; padding:.6rem .75rem; flex:none;
  border-right:var(--rule); color:var(--ink);
  font-weight:700; letter-spacing:.1em;
}
.doc__title{
  font-family:var(--display);
  font-weight:800; font-variation-settings:"wdth" 108,"wght" 800;
  font-size:clamp(1.7rem,4.4vw,3.4rem);
  line-height:1.02; letter-spacing:-.035em; text-transform:uppercase;
  max-width:18ch; margin:0 auto .85rem; text-wrap:balance;
}
.doc__deck{
  font-variation-settings:"wdth" 100,"wght" 300;
  font-size:clamp(1.05rem,1.8vw,1.45rem); line-height:1.35;
  max-width:38ch; margin-inline:auto; color:var(--muted);
}

.doc__copy{ padding:clamp(1.5rem,3vw,2.5rem) var(--pad) 0; }
.doc__copy p{ max-width:60ch; margin:0 auto 1.1em; }
.doc__copy strong{ font-variation-settings:"wdth" 100,"wght" 700; }
/* Full-height highlight: the band covers the whole inline box rather than
   sitting under the text as a half-height marker stripe (changed on Joshua's
   call). Ink on lime is 17.0:1, so nothing about legibility changes with it. */
.doc__intro em,.doc__copy em{
  background:var(--accent);
  /* INVERTED GROUND: the band is still lime, so the type on it is still the
     dark value — which is var(--paper) in here. Ink on lime stays 17.0:1. */
  color:var(--paper);
}

/* ---- quoted brief ----
   Added 2026-08-18 for tvs-eurogrip, whose first section is built entirely on
   a one-sentence brief from the art director. The source set it as a
   blockquote and nothing in this vocabulary carried a quotation; .doc__deck is
   a standfirst and .doc__statement is the opening headline, so neither says
   "someone else said this". Same precedent as .doc__pubs below, which
   case-k7-antivirus.js added for its press links.

   Rules take the project's own colour because a brand colour may be a device
   here but never type — Eurogrip blue measures 7.63:1 on bone and would in
   fact pass, but the rule does not bend for a colour that happens to clear AA.
   Weight is set as a variation axis, not font-weight: body sets an inherited
   "wght" 400 that beats the property outright. */
.doc__quote{
  /* min() rather than a bare 34ch: this sits directly under .doc__s, which has
     no padding of its own (unlike .doc__head, which protects .doc__deck), so a
     bare cap goes full-bleed below ~460px and runs the text and both rules into
     the screen edges. */
  max-width:min(34ch, calc(100% - 2 * var(--pad))); margin-inline:auto;
  padding:clamp(.9rem,2vw,1.4rem) 0;
  border-top:2px solid var(--doc-teal);
  border-bottom:2px solid var(--doc-teal);
  font-family:var(--voice);
  font-variation-settings:"wdth" 100,"wght" 300;
  font-size:clamp(1.15rem,2.2vw,1.5rem);
  line-height:1.34;
}
.doc__quote p{ margin:0; }

/* ---- image runs ---- */
.doc__runlabel{
  font-family:var(--mono); font-size:0.62rem; letter-spacing:.16em;
  text-transform:uppercase; color:var(--muted);
  padding:clamp(1.5rem,3vw,2.25rem) var(--pad) .75rem;
}
.doc__full,.doc__grid{ width:var(--doc-run); margin-inline:auto; padding:0; }
/* Gap scales with the runs — var(--gap) unchanged would be a third of the
   column width once the run is at 40%. */
.doc__grid{ display:grid; gap:calc(var(--gap) * .4); grid-template-columns:repeat(2,1fr); }
.doc__grid--three{ grid-template-columns:repeat(3,1fr); }
/* Five-up, for a carousel authored as a five-frame sequence (cover, three
   cards, shared payoff). Unlike every other run this one is NOT capped to
   --doc-run: at 40% of the content box five cells land near 100px each, which
   is below the width the type baked into those cards survives. Full padded
   width gives ~250px. Same expression the 820 breakpoint uses to undo the 40%. */
.doc__grid--five{
  width:calc(100% - 2 * var(--pad));
  grid-template-columns:repeat(5,1fr);
}
/* Wide run, for film players, film-frame grids and eBook pages. Escapes the
   40% --doc-run for the same reason --five does: at 40% a two-up cell lands
   near 220px, and product screens, node networks and print-scale body copy
   are not readable there. The 1280px cap is the source video's own width, so
   past it the films would upscale for nothing; the frame grids share the cap
   so film and frames sit on one measure instead of stepping. Below 820 the
   block there resets --doc-run to the full padded width and this resolves to
   the same value, so it needs no addition. */
.doc__run--wide{ width:min(calc(100% - 2 * var(--pad)), 1280px); }
/* Three-up, for the fifty-two-board Content Operating System frame run inside
   the merged S51 document. A width decision, not a taste one: at two-up that
   run is twenty-six rows of scroll, and at four-up the cell drops to about
   310px inside the 1280 cap, which six of the fifty-two do not survive — they
   are live product screens (the content-strategy page, the four-pillar chart,
   the posting calendar, the draft panel, the document spread) whose type goes
   at that size. Three-up lands near 415px, which holds them, in eighteen rows.
   Needs no breakpoint edit: the 820 block collapses .doc__grid to one column
   later in this file and wins on source order, so --sheet folds with it. */
.doc__grid--sheet{ grid-template-columns:repeat(3,1fr); }
/* Four-up, for a set authored as four: each eBook is four pages and each of
   the three ad tracks is four ads. Four is the unit the work came in, and
   splitting it across rows would read as eight or twelve loose pieces. Paired
   with --run--wide in the documents, which caps it at 1280 and lands a cell
   near 310px. Steps to two at 1024 and one at 820, like --three. */
.doc__grid--four{ grid-template-columns:repeat(4,1fr); }

/* ---- BOOK COVERS, for the OneMagnify eBooks section -------------------
   The section used to show four pages of each book, sixteen stills. Joshua's
   call 2026-08-28: show the four covers only and let the cover open the whole
   book, which is a truer offer than three pages someone else chose. The reader
   is ebook.html and opens in its own tab.

   The <a> wraps the image, so the whole cover is the hit area rather than a
   link under it. Lift and cue on hover; on a touch screen there is no hover,
   so the cue is always faintly there and the caption says what it does. */
/* justify-content:flex-end so the books STAND ON ONE LINE. The grid stretches
   every cell to the tallest, and the 3D mockup is a taller shape than a flat
   cover (532x833 against 1200x1697). Left to top-align, the shorter covers
   would hang from the top edge and the four captions would sit at four
   different heights. Bottom-aligning puts every book on the same floor and
   every caption on the same line, whether the row is all mockups, all flat
   covers, or the mix it is today. */
.doc__fig--book{ display:flex; flex-direction:column; justify-content:flex-end; gap:.6rem; }
.doc__book{
  display:block; position:relative; overflow:hidden; background:#fff;
  transform:translateY(0); transition:transform .35s cubic-bezier(.2,.7,.2,1), box-shadow .35s;
  box-shadow:0 0 0 rgba(0,0,0,0);
}
.doc__book img{ display:block; width:100%; height:auto; background:#fff; }
/* ---- 3D book mockup variant ----
   A render of the cover as a printed book, on a TRANSPARENT ground, standing
   on the section's own ink. Three things have to come off for that to read:
   the white backing behind the image, the white backing on the link box, and
   the rectangular box-shadow — a box shadow around a non-rectangular cut-out
   draws the bounding box, which is exactly the giveaway. drop-shadow follows
   the alpha instead, so the shadow is the book's own shape.

   Only `revenue` has a mockup so far. Nothing here is specific to that book:
   add the class to the other three as their renders arrive. */
.doc__book--mock{ background:none; }
/* .doc__fig img sets background:#fff and sits LATER in this file at equal
   specificity, so it was winning and painting a white rectangle behind the
   cut-out. Qualified with .doc__fig to outrank it rather than moved, because
   the white ground on .doc__fig img is right for every other figure here. */
.doc__fig .doc__book--mock img{ background:none; }
.doc__book--mock{ overflow:visible; filter:drop-shadow(0 .5rem 1rem rgba(0,0,0,.45)); }
.doc__book--mock:hover,.doc__book--mock:focus-visible{
  box-shadow:none;
  filter:drop-shadow(0 1rem 1.75rem rgba(0,0,0,.55));
}
/* The cue chip is positioned against the link box, and a mockup leaves
   transparent margin inside that box, so on this variant it would float off
   the book. Tuck it in to sit on the cover itself. */
.doc__book--mock .doc__book-cue{ left:1.4rem; bottom:1.1rem; }
.doc__book:hover,.doc__book:focus-visible{
  transform:translateY(-4px); box-shadow:0 .75rem 1.5rem rgba(12,12,12,.28);
}
.doc__book:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }
/* The cue rides the bottom edge so it never covers the cover's own type. */
.doc__book-cue{
  position:absolute; left:.6rem; bottom:.6rem;
  font-family:var(--mono); font-size:.5rem; letter-spacing:.18em;
  text-transform:uppercase; color:var(--paper); background:var(--ink);
  padding:.35rem .6rem; border-radius:999px;
  opacity:.85; transform:translateY(0); transition:opacity .3s, transform .3s;
}
.doc__book:hover .doc__book-cue,.doc__book:focus-visible .doc__book-cue{ opacity:1; }
@media (hover:hover){
  .doc__book-cue{ opacity:0; transform:translateY(.4rem); }
}
.doc__book-cap{
  font-size:.78rem; line-height:1.45; color:var(--ink);
}
.doc__book-cap span{
  display:block; font-family:var(--mono); font-size:.52rem; letter-spacing:.16em;
  text-transform:uppercase; color:var(--muted); margin-top:.2rem;
}
@media (prefers-reduced-motion:reduce){
  .doc__book,.doc__book-cue{ transition:none; }
  .doc__book:hover,.doc__book:focus-visible{ transform:none; }
}
.doc__fig{ margin:0; }
/* No border (Joshua's call — it read as a dotted line under each page). The
   white ground stays and is not decoration: these renders carry transparent
   margins, and without it they would sit on bone rather than on paper. */
.doc__fig img{
  display:block; width:100%; height:auto;
  background:#fff;
}

/* ---- motion figures ----
   TWENTY-SIX figures are motion in the original, across SEVEN case files —
   tvs-eurogrip 9, the-modern-village 6, hungry-turtle 4, atlys 2,
   onemagnify-films 2, s51-brand-film 2, purva-windermere 1 (count corrected
   2026-08-31; it had said "eight across atlys, hungry-turtle and tvs-eurogrip"
   since before four of those files existed). Re-derive rather than trust it:
     grep -c 'class="doc__play"' js/case-*.js Each stays the poster <img> it always was, wrapped in a .doc__play
   button; on click the button is replaced by a <video> in the same figure. So
   these runs cost exactly what they cost before, and the layout is untouched —
   the button carries no box of its own beyond the image, and the video that
   replaces it inherits the image's width/height. */
.doc__play{
  display:block; position:relative;
  width:100%; padding:0; margin:0; border:0;
  background:none; color:inherit; font:inherit;
  cursor:pointer;
}
/* The badge is the only thing that says "this moves". It has to read on a
   bright poster and a dark one, hence the ink disc under the lime glyph
   rather than a bare glyph. */
.doc__play::after{
  content:"";
  position:absolute; left:50%; top:50%;
  width:clamp(2.75rem,18%,4.25rem); aspect-ratio:1;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:rgba(12,12,12,.72) no-repeat center;
  /* a play triangle, drawn rather than shipped as a file */
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 6.5v11l9-5.5z' fill='%23D6FF3F'/%3E%3C/svg%3E");
  background-size:52%;
  -webkit-backdrop-filter:blur(2px);
          backdrop-filter:blur(2px);
  transition:background-color .25s ease, transform .25s ease;
}
.doc__play:hover::after,
.doc__play:focus-visible::after{
  background-color:var(--accent);
  transform:translate(-50%,-50%) scale(1.08);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 6.5v11l9-5.5z' fill='%230C0C0C'/%3E%3C/svg%3E");
}
.doc__play:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }

/* ---- the player, in place ----
   Joshua's call 2026-08-23: the video plays where the picture is, at the size
   the picture is. It replaces the .doc__play button in the same figure, so it
   inherits that figure's box exactly and the run never reflows on play.
   Anyone who wants it bigger uses the player's own fullscreen button.

   A lightbox was built first and removed. Worth knowing why, so it is not
   rebuilt: sizing a video against a container rather than its own frame is
   where every bug in this feature came from — a browser draws its native
   controls against the ELEMENT box, so any gap between element and picture
   scatters the controls into the corners. Here the element IS the figure and
   there is no gap to get wrong.

   Black ground, not the #fff the stills sit on: a video letterboxes inside its
   box (the TVS reel is a 9:16 cut in a square figure) and white bars either
   side of a photographic frame read as a mistake. */
.doc__fig video,
.doc__vid{
  display:block; width:100%; height:auto;
  background:#000;
}
/* Wrapper around a playing video. Carries no box of its own — the figure's
   dimensions still come from the video, which still comes from the poster. */
.doc__player{ display:block; position:relative; }
/* Our own fullscreen control. See the note in main.js: below ~250px a browser
   hides its native one in an overflow menu, and the TVS feed reels are 184px.
   Sits top-right, clear of the control bar along the bottom. */
.doc__fs{
  position:absolute; top:.4rem; right:.4rem;
  width:1.85rem; height:1.85rem; padding:0;
  border:0; border-radius:.25rem;
  background:rgba(12,12,12,.55) no-repeat center;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23F2F0EA' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5'/%3E%3C/svg%3E");
  background-size:58%;
  cursor:pointer; opacity:.75;
  transition:opacity .2s ease, background-color .2s ease;
}
.doc__fs:hover,
.doc__fs:focus-visible{ opacity:1; background-color:var(--accent); }
.doc__fs:hover{
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230C0C0C' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5'/%3E%3C/svg%3E");
}
/* In fullscreen the browser's own controls take over, so ours would just be a
   stray square floating over the picture. */
.doc__vid:fullscreen + .doc__fs{ display:none; }
/* ---- swatch rail ----
   The palette drawn in CSS rather than only photographed, so the hexes on this
   page are checkable against the ones printed in the document. The 1px grid
   gap over a rule-coloured ground draws every divider including the ones that
   appear when a row wraps — a per-cell border cannot, because which cell ends
   a row is not knowable in CSS. */
.doc__swatches{
  display:grid; gap:1px; width:var(--doc-run); margin-inline:auto;
  grid-template-columns:repeat(3,1fr);
  /* INVERTED GROUND: an ink keyline over an ink ground draws nothing. */
  background:rgba(242,240,234,.16); outline:1px solid rgba(242,240,234,.16);
}
.doc__swatches--sec{ grid-template-columns:repeat(4,1fr); }
.doc__sw{ background:var(--paper); }
.doc__sw i{ display:block; height:clamp(3.25rem,7vw,6.5rem); }
.doc__sw span{
  display:block; font-family:var(--mono); font-size:0.58rem; letter-spacing:.1em;
  color:var(--muted); padding:.5rem .6rem; border-top:var(--rule);
}

/* ---- craft rules ---- */
.doc__rules{
  display:grid; gap:1.75rem var(--gap); grid-template-columns:repeat(3,1fr);
  padding:clamp(1.5rem,3vw,2.5rem) var(--pad) 0;
}
.doc__rules h4{
  font-family:var(--display);
  font-weight:800; font-variation-settings:"wdth" 100,"wght" 750;
  font-size:0.95rem; line-height:1.15; text-transform:uppercase;
  border-top:2px solid var(--doc-teal); padding-top:.7rem; margin:0 0 .6rem;
}
.doc__rules p{ margin:0 auto; font-size:.95rem; line-height:1.55; color:var(--muted); max-width:52ch; }
/* Four rules instead of three. Added 2026-08-19 for the-modern-village, whose
   craft section runs four where every other document runs three; four cells in
   the 3-column grid above leaves one orphan sitting left under two empty
   columns, which reads as a mistake in a block that is centred throughout. Two
   columns is also the shape the source uses.

   Deliberately carries NO media queries. The .doc__rules rules in the 1024 and
   820 blocks below have the same specificity and come later in the file, so
   they still win: 2-up at 1024, 1-up at 820. Only the top breakpoint changes. */
.doc__rules--four{ grid-template-columns:repeat(2,1fr); }

/* ---- Tamil ----
   A SCRIPT FALLBACK, not a fourth design family. Archivo, Bricolage Grotesque
   and Space Mono carry no Tamil glyphs, and js/case-the-modern-village.js sets
   தூக்கு inside its body copy with the sentence after it explaining that the
   line ends in Tamil, so the script cannot be dropped and something has to
   render it. Joshua's call, 2026-08-19, over letting the browser fall back to
   whatever Tamil face the device happens to have.

   Scoped to `.doc .ta` on purpose. It cannot reach anything outside a
   case-study document, and inside one it only reaches text explicitly marked
   as Tamil. The .94em matches the source build: Noto Sans Tamil runs large
   against a Latin face at the same nominal size. Requested in the Google
   Fonts link in index.html; the comment above it says the same thing. */
.doc .ta{
  font-family:"Noto Sans Tamil",var(--voice),sans-serif;
  font-size:.94em;
}

/* ---- publication links ----
   THE ONE COMPONENT IN THIS BLOCK THAT IS NOT IN THE TEMPLATE'S VOCABULARY.
   Added 2026-08-18 for k7-antivirus. Section 4 of
   claude-handoff/case-study-template.md says a source component with no class
   here must be flagged rather than invented silently, so it was flagged and
   Joshua chose it: the two publication features that ran the K7 project become
   clickable logo cells.

   There was nothing to reuse. apollo-hospital and atlys contain ZERO anchors
   between them, so .doc had no link treatment at all, and the global
   a{color:inherit;text-decoration:none} would have rendered a link as
   indistinguishable body text.

   THE LOGOS LANDED 2026-08-20 (pub-potw.webp, pub-wbds.webp). Three things
   still make the cell degrade cleanly if either file ever goes missing:
     - the <span> is the accessible name AND the visible fallback, so the cell
       is never empty and never unlabelled. The <img> therefore takes alt="",
       or a screen reader hears the publication named twice.
     - onerror="this.remove()" on the <img> is the same idiom band() and
       .tile__img already use for artwork that has not been dropped in yet.
     - :not(:has(img)) turns the cell dashed once that removal happens, so a
       pending asset reads as pending rather than as a finished empty box.

   NEITHER CELL CARRIES A GROUND, so both logos sit straight on the overlay's
   ink. That is right for WBDS, which is pure white throughout, and only
   partly right for POTW. Measured against #0C0C0C, the three facets of the
   POTW isometric mark are:
       #231F20   49% of the mark    1.20:1   invisible
       #575759   30%                2.71:1   weak
       #B9BABD   21%               10.08:1   reads
   The same three on bone are 14.30 / 6.33 / 1.70:1 — the mark was drawn for a
   light ground, and the white wordmark beside it for a dark one. So half the
   mark drops out here and it reads as a chevron rather than a solid.
   The fix is a reversed POTW mark, not a plate behind the cell: a light plate
   would take WBDS out entirely. Left as supplied pending Joshua's call —
   altering a publication's own logo is not a call to make unasked.

   data-cursor="Open" and the trailing arrow glyph are the site's existing
   external-link signals, lifted from the contact block rather than invented. */
.doc__pubs{
  display:flex; flex-wrap:wrap; justify-content:center; gap:var(--gap);
  padding:clamp(1.5rem,3vw,2.25rem) var(--pad) 0;
}
.doc__pub{
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:.7rem; min-width:12rem; min-height:6.5rem; padding:1.25rem 1.5rem;
  border:var(--rule);
  font-family:var(--mono); font-size:0.64rem; letter-spacing:.14em;
  text-transform:uppercase; color:var(--muted); line-height:1.5;
  transition:border-color .3s var(--ease), color .3s var(--ease);
}
.doc__pub img{ display:block; max-width:9rem; height:auto; }
.doc__pub:hover,
.doc__pub:focus-visible{ border-color:var(--ink); color:var(--ink); }
.doc__pub:not(:has(img)){ border-style:dashed; }

/* ---- credits ---- */
.doc__credits{
  background:var(--paper-2);
  padding:clamp(2.5rem,5vw,4rem) var(--pad) clamp(2rem,4vw,3rem);
}
.doc__credits .doc__eyebrow{ border-top:0; margin-bottom:1.5rem; }
/* No number cell, so no divider and nothing to sit left of — the label centres
   on its own and takes full ink rather than the --muted every other eyebrow
   uses. Credits is the one that closes the page, so it carries more weight. */
.doc__eyebrow--bare{ color:var(--ink); }
.doc__credits-grid{
  display:grid; gap:1.5rem var(--gap);
  grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));
}
.doc__credits-grid h4{
  font-family:var(--mono); font-size:0.58rem; font-weight:400;
  letter-spacing:.18em; text-transform:uppercase; color:var(--muted);
  margin:0 0 .35rem;
}
.doc__credits-grid p{ margin:0; font-size:.92rem; line-height:1.5; }

/* =============================================================
   REVEAL
   ============================================================= */
.reveal{
  opacity:0; transform:translateY(28px);
  transition:opacity .9s var(--ease), transform .9s var(--ease);
}
.reveal.is-in{ opacity:1; transform:none; }

/* Direction modifiers for the work list: odd rows enter from the left, even
   from the right. buildGrid() assigns them off the project index.
   Written at two-class specificity deliberately. .reveal--l alone would tie
   with .reveal.is-in above and be decided by source order, which makes moving
   this block break the animation in a way that looks like a JS bug. */
.reveal.reveal--l{ transform:translate3d(-56px,18px,0); }
.reveal.reveal--r{ transform:translate3d( 56px,18px,0); }
.reveal.reveal--l.is-in,
.reveal.reveal--r.is-in{ transform:none; }

.about__lead .ln > *{
  transform:translateY(105%);
  transition:transform 1s var(--ease);
}
.about__lead .ln.is-in > *{ transform:none; }

/* =============================================================
   RESPONSIVE
   ============================================================= */
@media (max-width:1024px){
  .about__grid{ grid-template-columns:1fr; }
  /* A three-up run of 16:9 slides is ~300px wide here — under the width the
     baked-in labels on those pages stay legible at. */
  .doc__grid--three{ grid-template-columns:repeat(2,1fr); }
  .doc__grid--four{ grid-template-columns:repeat(2,1fr); }
  .doc__grid--five{ grid-template-columns:repeat(3,1fr); }
  .doc__rules{ grid-template-columns:repeat(2,1fr); }
  /* stacked — one column, so there is no second column to match and the
     portrait goes back to the square it is authored as */
  .about__side{ max-width:26rem; display:block; }
  .about__portrait{ display:block; }
  .about__portrait img,
  .about__portrait .about__ph{ aspect-ratio:1; }
  /* single column — the measure widens, so the statement can grow */
  .about__lead{ font-size:clamp(1.4rem,5.6vw,3.6rem); }
  .case__body{ grid-template-columns:1fr; }
  .case__meta{ grid-template-columns:repeat(2,1fr); row-gap:1rem; }
}

@media (max-width:820px){

  /* CAPABILITIES INDEX ALIGNMENT (fixed 2026-08-19, Joshua spotted it).
     The row is a centred flexbox and the index is a flex item inside it, so
     the number's x position was a function of how wide its name happened to
     be. On mobile "AI-ASSISTED WORKFLOWS" wraps to two lines and is far wider
     than "DIGITAL & UI", which threw 06 much further left than 05 and made a
     column that should read as a ruled list look ragged.
     Lifting the index out of the flow fixes every row at once: it now sits at
     a constant left edge and the name centres in the full row.
     The open state keeps its width here rather than collapsing to 0 — the
     number is no longer in the flow, so there is nothing for a width
     transition to push, and only the fade is wanted. */
  .caps__head{
    position:relative;
    padding-inline:calc(2ch + 1.4rem);
  }
  .caps__idx{
    position:absolute; left:0; top:50%;
    transform:translateY(-50%);
    margin-right:0;
    transition:opacity .3s var(--ease);
  }
  .caps__head[aria-expanded="true"] .caps__idx{
    opacity:0; width:calc(2ch + .28em); margin-right:0;
  }
  /* Single column from here down. These are pages of a printed document with
     6pt dimension callouts on them — half a phone width is not a size any of
     them can be read at. */
  /* The 40% run reduction stops here. Below 820 the runs are already a single
     column, and 40% of a phone is ~140px — narrower than any page of a printed
     guideline can be read at, which is the whole reason this project is a
     document rather than a band stack. */
  .doc{ --doc-run:calc(100% - 2 * var(--pad)); }
  .doc__grid,
  .doc__grid--three,
  .doc__grid--four,
  /* One column like every other run. Two-up would leave a carousel card near
     165px on a 375px screen, and these cards carry live copy. */
  .doc__grid--five{ grid-template-columns:1fr; }
  .doc__rules{ grid-template-columns:1fr; }
  .doc__statement{ max-width:none; }
  .hero__foot{ justify-content:center; gap:1.5rem; }

  /* Stacked and full width: the side-by-side row needs both a thumbnail and a
     readable measure beside it, and below this there is room for one. The
     alternation goes with it — mirroring alignment on a single column reads as
     a mistake, not a rhythm. */
  /* left:0 as well as the stretch — the nudge is a zigzag around a centre that
     no longer exists once every row is full width. */
  /* width:auto as well — the desktop rule states an explicit frame+gap+text
     sum, and below this the row is one stacked column where that sum means
     nothing. Without this the tile would hold a ~650px width inside a 400px
     screen. */
  .work__list .tile,
  .work__list .tile:nth-child(odd),
  .work__list .tile:nth-child(even){
    width:auto; max-width:none; justify-self:stretch; left:0;
  }
  .tile__btn,
  .tile:nth-child(even) .tile__btn{
    flex-direction:column; align-items:stretch; text-align:center;
    gap:.9rem;
  }
  .tile__frame{ flex:none; }
  .tile__text,
  .tile:nth-child(even) .tile__text{
    /* the desktop rule pins this to a definite 23rem; below 820 the row is a
       single column, so it has to give that up and fill the tile instead. */
    flex:0 0 auto; width:100%; align-items:center; max-width:none;
  }
  .tile__tags,
  .tile:nth-child(even) .tile__tags{ justify-content:center; }
  .tile__meta{ white-space:normal; }
  /* No sideways travel on a narrow screen — a 56px horizontal rest transform
     is wider than the page padding here, and .work's overflow-x:clip would be
     hiding a bug rather than preventing one. Vertical only, same as .reveal. */
  .reveal.reveal--l,
  .reveal.reveal--r{ transform:translate3d(0,28px,0); }
  /* Stack year above role; the plus stays pinned right across both rows. */
  /* CENTRED ON MOBILE ONLY (Joshua's call, 2026-08-19). The desktop layout
     earns its left alignment from the alternating Projects rhythm and the
     two-column Contact; below 820 everything collapses to one column and the
     same alignment just reads as a ragged left edge. Desktop is untouched. */
  .about__label,
  .clients__label,
  .contact__label,
  .exp__label,
  .sec-head__label,
  .contact__pitch,
  .contact__title,
  .contact__links,
  .about__body,
  .about__lead,
  .about__side figcaption,
  .foot{ text-align:center; }
  /* .contact__mail is NOT in that list any more. It lives inside the address
     pill now, which is a flex column below 820 and centres its own children —
     text-align on the anchor would be centring it inside a box already the
     width of its own text. .contact__cols / .contact__list went with the
     two-column layout they belonged to. */

}

@media (max-width:560px){
  /* email2jrb@gmail.com measures 16.03em — at 375px only ~21px fits on one
     line, so cap at 5.4vw. break-word stays as a safety net for very narrow
     screens rather than as the normal behaviour. */
  .contact__mail{ white-space:normal; word-break:break-word;
    font-size:clamp(.95rem,5.4vw,2rem); }
  .case__meta{ grid-template-columns:1fr; }
  /* Four hex labels in Space Mono do not fit a 375px row. */
  .doc__swatches--sec{ grid-template-columns:repeat(2,1fr); }
  .preloader__inner{ flex-direction:column; align-items:flex-start; }
  /* --pad hits its 1.15rem floor below 460px, so size off the real measure
     rather than the viewport. FREELANCE ART DIRECTOR is the longest role. */
  .exp__role{ font-size:clamp(1.15rem,6.2vw,1.55rem); }
}

/* A sub-460px block lived here to stop "R. BERNARD" losing its last letter
   behind .hero__line's overflow:hidden — below 460px --pad hits its 1.15rem
   floor, so the old hero's vw sizes stopped tracking the available measure.
   Gone with the hero it patched: the marquee rows are meant to overflow, so a
   line that runs past the edge is the design rather than the bug. */

/* Touch devices get no hover, so resolve the logo wall to full strength
   rather than leaving it permanently in its dimmed rest state. */
@media (hover:none){
  /* Full strength, but still light-on-ink — the band is dark. */
  .logo__name{ color:var(--paper); }
  .logo__img{ opacity:1; }
}

/* =============================================================
   REDUCED MOTION
   ============================================================= */
/* "Reduce Transparency" (macOS/iOS Accessibility, and the Windows equivalent)
   is a real user setting, and translucency is exactly what it asks us to drop.
   Collapsing to solid --ink is not a downgrade: paper on ink is 17.17:1,
   strictly more legible than any glass state. Unlike the marquees — which
   deliberately ignore prefers-reduced-motion at Joshua's request — there is no
   reason to override this one. */
@media (prefers-reduced-transparency:reduce){
  .nav__pill,.case__close,.cursor.is-active{
    background:var(--ink);
    -webkit-backdrop-filter:none;
            backdrop-filter:none;
  }
  /* INVERTED GROUND: var(--ink) above resolves to cream inside the overlay,
     which would erase the cream glyph. Same reasoning as the nav pills below —
     opaque is the point, the colour still has to be the readable one. */
  .case__close{ background:var(--paper-2); }
  /* The lens goes solid with the rest. That does re-cover whatever it points
     at — which is the thing the lens was built to stop — but "no translucency"
     is precisely what this setting asks for, and a solid ink disc with a paper
     label is 17.17:1. The lime rim stays, so it still reads as the cursor.
     The sheen is a translucent gradient, so it goes too. */
  .cursor::before{ display:none; }
}

/* THERE IS NO prefers-reduced-motion BLOCK, AND THAT IS ON PURPOSE.
   ---------------------------------------------------------------------------
   Joshua's explicit call, 2026-08-16: every animation on this site runs for
   every visitor, including one whose operating system has been told to reduce
   motion. He was asked, the cost was stated, and he confirmed. Do not "fix"
   this — it is a decision, not an oversight.

   What used to live here: a blanket
     *,*::before,*::after{ animation-duration:.001ms !important; ... }
   plus resting-state resets for .grain, .hero__track, .reveal, .about__lead
   and .about__foot, and display:none for .cursor and .preloader — with the two
   marquees carved out as exceptions. Only the marquees survive that list now,
   because everything else in it has been removed rather than exempted.

   Two consequences worth knowing before touching anything:

   1. WCAG 2.2.2 (Pause, Stop, Hide) was already unmet — the only way to stop a
      marquee is to hover it, which a keyboard or touch user cannot do. The
      hero made that worse by scale, and this removes the last automatic
      escape. That is the accessibility bill for this decision.
   2. The `--marquee-dur` custom property is no longer load-bearing for the
      cascade. It was written as a property, rather than as an inline
      animation-duration, purely so a JS-measured value could survive the
      !important blanket above. The blanket is gone, so it is now just a tidy
      way for pace() to talk to CSS. Keep it — restoring the block is a
      one-paste job only while that plumbing is still in place.

   TO RESTORE: paste the block back (git history, or the version notes in
   claude-handoff/), and flip HONOUR_REDUCED_MOTION in js/main.js to true —
   CSS alone will not do it, because the JS short-circuits the preloader, the
   scroll reveals and the custom cursor independently of any stylesheet.

   NOTE the neighbouring @media (prefers-reduced-transparency:reduce) block
   above is UNTOUCHED and should stay. It is a different user setting about
   translucency, not motion, and none of the above applies to it. */

/* =============================================================
   PRINT
   ============================================================= */
@media print{
  /* .hero__kinetic goes: three rows of 16rem type would take the first sheet
     on its own, and the <h1> beside it already carries the name in full. */
  .nav,.cursor,.grain,.preloader,.ticker,.case,
  .hero__scroll,.hero__kinetic{ display:none !important; }
  /* The h1 is .u-sr on screen; in print it is the only thing naming the page. */
  .hero .u-sr{
    position:static; width:auto; height:auto; margin:0;
    overflow:visible; clip-path:none; white-space:normal;
    font-size:1.6rem; text-transform:uppercase;
  }
  body{ background:#fff; color:#000; }
  .reveal{ opacity:1; transform:none; }
}
