/* =========================================================================
   PELLMELL-STYLE INTRO — Step 1 (ISOLATED PROTOTYPE)  v=19
   Layout = Figma LrUdy5WZmi3n2gkRz9SEC2 node 1480:1468 (end-pose; "node wins
   on all final positions/sizes"), mapped as % of a 1440×894 reference onto the
   viewport. Companion title-visible frame = 1477:1639.

   Flash-free progressive enhancement via an <html class="js-loading"> flag set
   by an inline head-script ONLY when motion is allowed:
     • no JS / reduced-motion  → no .js-loading → preloader hidden, stage at rest,
                                  hero + card + title statically visible (1480:1468).
     • motion allowed          → .js-loading → preloader shown, stage parked above
                                  the fold (the "white shutter" waiting to descend);
                                  js/pellmell-intro.js drives the three beats.
   ========================================================================= */

/* Self-hosted Inter (variable, latin subset from Google Fonts v20). The user's
   environment blocks external CDNs, so — like the vendored GSAP/Lenis — the font
   is local: no fonts.googleapis.com dependency, no system-sans fallback. One
   variable file carries every weight used (700 subtitle, 800 "Shoon"). */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;              /* variable weight axis */
  font-display: swap;
  src: url('../assets/fonts/inter-latin.woff2') format('woff2');
}

/* Satoshi (Light 300) — self-hosted, mirrors production styles.css. Only the Light
   weight is on disk; the Part-2 title rail (.card-titles) is the sole consumer. */
@font-face {
  font-family: 'Satoshi';
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url('../assets/fonts/satoshi-light.woff2') format('woff2'),
       url('../assets/fonts/satoshi-light.woff') format('woff');
}

/* ---- @property: makes the variant seams RESOLVE TO PX FOR JS ---------------
   js/pellmell-intro.js does parseFloat(getComputedStyle(docEl).getPropertyValue(
   '--gap-3')) to place the card. An UNREGISTERED custom property computes to its
   token stream with var() substituted but calc() NOT EVALUATED — so the rhythm
   variant's `calc(var(--edge-gap) * 1.7127)` came back as the literal string
   "calc(59.62px * 1.7127)", parseFloat returned NaN, and the card parked at y=0
   (observed: card pinned to the viewport top, gap 3 = −571.64). The base only
   worked by luck: `var(--edge-gap)` is plain substitution with no calc in it.

   Registering these as <length> makes the browser evaluate the calc at
   computed-value time, so JS reads "102.11px".

   RULE: any token JS parses with getPropertyValue MUST be registered here, or it
   must be a plain literal. --gap-2 is registered too — JS does not read it today,
   but the next person to reach for it would hit the identical silent NaN. The
   inset tokens are plain numbers/px and need no registration. */
@property --gap-2 { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --gap-3 { syntax: '<length>'; inherits: true; initial-value: 0px; }

/* ---- COMPOSITION TOKENS (Pellmell scale @ 1440) ----
   Pellmell is the SCALE AUTHORITY (user). The wordmark and card share the same
   left edge + width (75px inset, 1290 wide). See Docs/TODO.md for the
   Figma-vs-Pellmell conflicts (incl. the unresolved 1.934 vs 2.15 card ratio).

   NOT a flat list of dials — the VERTICAL tokens form a chain with ONE free
   value at the head of it. Read top to bottom:
       --edge-gap          THE GAP  ← the only vertical number you may change
       --title-cap-top     = --edge-gap   (GAP 1; viewport top is y=0)
       --gap-2 / --gap-3   VARIANT SEAMS — 1× --edge-gap here (equal gaps)
       --cap-inset         MEASURED (Inter 800)
       --title-top         DERIVED  ← do not hand-set; solved for the anchor
       --shoon-ink-inset   MEASURED (Inter 800)
       --browne-cap-inset  MEASURED (Inter 700 + --role-lh)
       --lines-gap         DERIVED  ← solved so GAP 2 == --gap-2; goes NEGATIVE
       --role-bottom       DERIVED
       --role-ink-inset    MEASURED (Inter 700 + --role-lh)
       --role-ink-bottom   DERIVED
       --card-lip-top      = --role-ink-bottom + --gap-3   (GAP 3)
       …visible lip        DERIVED  ← the remainder, never set, only reported
   All THREE gaps spend --edge-gap, so they are equal by construction, not by
   upkeep. The four MEASURED insets exist only to convert element boxes into ink;
   they are font metrics, not design values.

   TWO VARIANTS SHARE THIS FILE — keep it that way:
       index.html         equal gaps  1 : 1 : 1          ← DEFAULT
       pellmell-intro-rhythm.html  base + css/pellmell-intro-rhythm.css,
                                   which overrides --gap-2/--gap-3 ONLY
                                   → 1 : 2.673 : 1.713            ← EXPERIMENT
   An experiment, not a supersede: equal-gaps remains the default until the user
   picks. Edits here land in BOTH — that is the point of the seams.

   The SB mark hangs off the same skeleton, with its own one-token rule:
       --mark-ink-*   MEASURED (the SVG's viewBox slack + letterboxing)
       --mark-clear   the floor on ink → cap-line clearance
       --mark-inset   THE MARK'S GAP  ← spent by BOTH top and right, so they are
                      one value read twice; min()-clamped against the live
                      vertical slack so the ink cannot reach the cap line
   The --mark-ink-* insets do for the SVG what --cap-inset does for the type:
   strip the box padding so the INK lands on the target, not the box.

   Changing a DERIVED token by hand breaks the anchor it exists to satisfy. */
:root {
  --content-left:  5.21%;              /* 75 / 1440  — shared card + wordmark left */
  --content-width: 89.58%;             /* 1290 / 1440 — card width */
  /* "Shoon" sized by HEIGHT (cap-height ≈ % of viewport), NOT stretched to the
     card width — 5 letters at Pellmell's cap-height end up NARROWER than the
     card. 38vh ≈ 37–40% viewport height; 28vw caps it below the card width so it
     never stretches to / overflows the card. */
  --shoon-size:    min(38vh, 28vw);

  /* ---- --edge-gap: THE GAP ------------------------------------------------
     ONE quantity, spent at EVERY vertical gap in the intro. Not three tokens that
     happen to be equal — the same token, read three times, so they cannot drift
     apart later (exactly how .hero-mark and .hero-title both read --title-cap-top).

       GAP 1  viewport TOP EDGE → "Shoon" S ink-top              = --edge-gap
       GAP 2  "Shoon" ink-bottom → "BROWNE" cap ink-top          = --edge-gap
       GAP 3  "UX PRODUCT DESIGN" ink-bottom → card TOP EDGE     = --edge-gap

     There is no fourth. These three are every vertical gap in the composition.

     ALL INK, never the element box — that distinction is the whole reason this
     works. Type boxes are mostly air: the role line's box bottom sits 20.37px
     under its last ink, and "Shoon"'s box bottom sits 43.24px under its ink at
     1440×894 (line-height: 1 on 339.72px type). A box-derived gap measures right
     and looks wrong. Four measured insets (--cap-inset, --shoon-ink-inset,
     --browne-cap-inset, --role-ink-inset) exist solely to strip that air out.

     WHERE THE VALUE COMES FROM: the Pellmell match sets it. Their title's ink-top
     sits 59.62 below the viewport top; the viewport top is y=0, so that distance
     IS gap 1. The equalize rule then spends the same number at gaps 2 and 3. If
     the Pellmell anchor is ever re-measured, ALL THREE follow from this one edit. */
  --edge-gap:      59.62px;

  /* Gaps 2 and 3 spend --edge-gap through these two multiplier seams. In THIS
     file both are 1× — the equal-gaps default is unchanged and this is a no-op.
     They exist so the rhythm VARIANT (css/pellmell-intro-rhythm.css, loaded after
     this file at pellmell-intro-rhythm.html) can restate the composition by
     overriding two tokens and nothing else — everything downstream already derives
     from them. Gap 1 has no seam: it IS the anchor and is not up for variation. */
  --gap-2:         var(--edge-gap);
  --gap-3:         var(--edge-gap);

  /* ---- CAP-TOP ANCHOR (the Pellmell match) --------------------------------
     The top gap, expressed as an absolute y. Identical to --edge-gap because the
     viewport top is y=0 — read it from the same token so the two cannot diverge.

     Pellmell's title is NOT text — it is an inline <svg> (viewBox 0 0 1290 354)
     of 8 glyph paths spelling LOWERCASE "pellmell". It has no capital in it, so
     it has no cap-top; its topmost ink is the "l" ascender. Measured from the
     paths' own getBoundingClientRect() (real geometry, never a screenshot):
     ink top 59.62, x-height top 137.5, baseline ≈341.5.

     VERIFIED A FIXED px CONSTANT ON BOTH AXES — not vh-, not vw-derived:
       vh 700 / 894 / 1000  @1440 → 59.62 / 59.62 / 59.62   (zero drift)
       vw 1280/1440/1600/1920 @894 → 59.66 / 59.62 / 59.58 / 59.56  (0.10px)
     The vw check matters because the rest of their layout IS width-derived
     (svg left tracks 5.21vw, width 89.58vw, and the x-height top slides
     128.93→149.69 as the wordmark scales). Only the svg BOX TOP is pinned —
     at 59.99 ≡ `top: 60px` at every width — and their ink is flush to the
     viewBox top, so the glyphs grow DOWNWARD from a fixed ceiling. The 0.10px
     residual is just that ascender sitting ~0.37px proud of the box, scaling
     with the svg (0.33px @1280 → 0.44px @1920). Sub-pixel; ignore. */
  --title-cap-top: var(--edge-gap);

  /* Distance from .ht-shoon's BOX top down to the "S" INK top, as a fraction of
     --shoon-size. Measured, not derived from spec tables: a zero-height
     inline-block strut (vertical-align: baseline) gives the true baseline, and
     canvas actualBoundingBoxAscent gives the true ink. For Inter 800 at
     line-height 1: baseline sits 0.8631em below the box top, "S" ink ascent is
     0.7373em → 0.1252em.
     SIZE-INVARIANT (this is what makes the anchor hold): 0.12517 → 0.12736 over
     F = 228…403.2px. The ±0.001 is the browser snapping the baseline to whole px
     (raw reads were 197/230/293/328/348 — all integers), which costs ≤0.6px of
     cap-top error across a 2× height range. Value below is exact at 1440×894.

     THIS IS THE "S" INK TOP, which overshoots the flat cap line by ~0.010em —
     round-letter overshoot. Pellmell's topmost ink is an "l", a FLAT stem with
     no overshoot, so aligning ink-to-ink (what we do) leaves our nominal cap
     line 3.32px lower than theirs. For OPTICAL flat-cap-line alignment instead,
     flip this one token to 0.13494. Left as a live knob — a judgement call to
     be made on sight, not in code.

     FONT-COUPLED: this encodes Inter 800's metrics. Re-measure if the face or
     weight of .ht-shoon changes. Nothing else here needs to. */
  --cap-inset:     0.12517;

  /* The title BOX is derived BACKWARDS from the cap-top anchor. The cap-top is
     the fixed quantity; the box floats to satisfy it. So --shoon-size can change
     freely and the cap-top does not move. (Was: --title-top: 7%, which pinned
     the BOX and let the cap-top land wherever the font metrics dropped it —
     105.10 at 1440×894, i.e. 45.48px below Pellmell's.) */
  --title-top:     calc(var(--title-cap-top) - var(--cap-inset) * var(--shoon-size));

  /* Descriptive-line type. Declared here (not just in the rule) so --lines-gap
     and --role-bottom below derive from the SAME numbers the lines actually
     render with, and cannot drift from them. */
  --role-font:     min(2.78vw, 40px);  /* 2.78vw == 40px at 1440 */
  --role-lh:       1.72;               /* row height ≈ 69px per Figma frames */

  /* ---- GAP 2: "Shoon" ink-bottom → "BROWNE" cap ink-top ---------------------
     The third and last gap, and the last dialled number in the composition.
     v=15 left this at a flat 32px, which measured 93.75 to ink at 1440×894 —
     visibly wider than the other two at 59.62. Now solved for --edge-gap like
     everything else, so ALL THREE gaps are one quantity.

     Two more measured insets, mirrors of --cap-inset / --role-ink-inset:
       --shoon-ink-inset   .ht-shoon BOX bottom → its INK bottom, ÷ --shoon-size.
                           "Shoon" has no descenders (S,h,o,o,n) but S and o are
                           ROUND, so its ink runs 0.01023em BELOW the baseline at
                           every size measured — flat "n" gives descent 0 as the
                           control. Baseline is NOT the ink bottom.
       --browne-cap-inset  .ht-browne BOX top → its cap INK top, ÷ --role-font.
                           Baseline sits 1.2em under the box top at --role-lh 1.72;
                           "B" is a flat-top cap at 0.7373em. → 0.4627em.

     gap2 = --lines-gap + --shoon-ink-inset·F + --browne-cap-inset·Frole
     …which reproduces the live measurement exactly (32 + 43.24 + 18.51 = 93.75
     @1440×894), so inverting it for --lines-gap is arithmetic, not fitting.

     THE LEADING DOES NEARLY ALL THE WORK — this is why the value goes negative.
     At --lines-gap: 0 the ink gap is still 61.75 @894: 43.24px of dead leading
     under "Shoon"'s ink inside its own box (line-height 1 at F=339.72) plus
     18.51px above BROWNE's caps. That FLOOR exceeds the 59.62 target, so closing
     to the anchor needs --lines-gap: −2.13. Negative margin-top is fine here — it
     overlaps the BOXES, never the ink (see clearance note below).

     THE NEGATIVE IS BOUNDED, and --shoon-size is why. F = min(38vh, 28vw)
     crosses over at vh = 403.2/0.38 ≈ 1061 and PLATEAUS at 403.2 above it —
     confirmed by measurement, not algebra: floor = 68.67 @vh1040, 69.57 @1061,
     then 69.59 flat at 1080 / 1200 / 1400 / 1600. So the floor maxes at 69.59 and
     --lines-gap bottoms out at −9.97. It cannot run away on a tall display.
     Insets are 1440×894-exact; sub-pixel snap residual elsewhere, as elsewhere.
     FONT-COUPLED: Inter 800 (shoon) and Inter 700 + --role-lh (browne). */
  --shoon-ink-inset:  0.12729;
  --browne-cap-inset: 0.462695;
  --lines-gap:     calc(var(--gap-2)
                        - var(--shoon-ink-inset) * var(--shoon-size)
                        - var(--browne-cap-inset) * var(--role-font));

  /* ---- CARD LIP: DERIVED, NOT DIALLED -------------------------------------
         card top    = role INK-bottom + --edge-gap      ← the rule
         visible lip = viewport height − card top        ← falls out, never set

     REVISION HISTORY — this relationship has been re-cut three times; read the
     shape, not the numbers:
       v=13  --title-top: 7% + CARD_LIP: 203px. Two independent constants that
             happened to balance at 1440×894. The LIP was pinned, the gap fell out.
       v=14  Cap-top anchored to Pellmell; the GAP was pinned (--role-card-gap:
             119.11px, role BOX bottom → card top) and the lip fell out.
       v=15  (this) The gap is no longer its own number at all — it IS --edge-gap,
             the same token the top gap spends, and it is measured to INK.
             119.11 box → 139.48 ink → equalized to 59.62. Card rose 79.86.

     The v=14 box-derived gap was the flaw: 119.11 box vs 139.48 ink differ by the
     20.37px of empty descender space under the role line. The eye sees the ink,
     so the box number was measuring something nobody looks at.

     WHICH QUANTITY DRIFTS (unchanged from v=14, re-verified at v=15): the gap is
     pinned and the LIP falls out — the inverse of v=13, whose "re-dial per device"
     note died with CARD_LIP. The gap is height-independent BY CONSTRUCTION (card
     top derives from the live role bottom, not from a viewport fraction).

     That is a MECHANICAL guarantee, not an aesthetic one. It says the gap cannot
     silently drift — it does NOT claim the composition reads well at every height.
     Foundation defers responsive. Scope here is 1440×894. */
  --role-bottom:   calc(var(--title-top) + var(--shoon-size) + var(--lines-gap)
                        + 2 * var(--role-lh) * var(--role-font));
  /* Role BOX bottom → role INK bottom, as a fraction of --role-font. The mirror
     of --cap-inset at the other end of the type: baseline sits 1.2em below the
     line box top at line-height 1.72, leaving 0.52em of half-leading under it,
     less the 0.0106em the round caps (U/O/D/C/G) overshoot BELOW the baseline.
     So the ink bottom is 0.424px UNDER the baseline at F=40 — "all-caps ends at
     the baseline" is close but not true, and flat-bottomed "X" measures descent 0
     as the control.
     Size-invariant on the same terms as --cap-inset: 0.509332 at F=40, which
     --role-font clamps to at every width ≥1439 (so it is EXACT at 1440 and wider);
     0.500526 at F=35.58 (vw 1280), the ~0.31px being baseline px-snapping.
     FONT-COUPLED to Inter 700 + --role-lh — re-measure if either changes. */
  --role-ink-inset:  0.509332;
  --role-ink-bottom: calc(var(--role-bottom) - var(--role-ink-inset) * var(--role-font));
  /* Both ends now spend the SAME token. --role-card-gap (v=14) is retired: it was
     a second number that had to be kept equal to the first by hand. */
  --card-lip-top:  calc(var(--role-ink-bottom) + var(--gap-3));

  --card-rest-top: 0px;                /* Part III scrolled REST — flush top, fully
                                          visible, covers the (parallax-exited) title.
                                          Does NOT affect the pre-scroll lip (JS lip
                                          = --card-lip-top, independent of this).     */

  /* ---- SB MARK box + its INK ----------------------------------------------
     THE ASSET IS NOT INK-FLUSH — centering the box would not centre the mark.
     shoon-logo-small.svg is viewBox "0 0 23 43" but its 3 paths only span
     x 0.0605→22.6875 and y 0.0605→42.108: 0.3125u of slack on the right and
     0.892u on the BOTTOM. On top of that, the box ratio (22.625/42.047 = 0.5381)
     ≠ the viewBox ratio (23/43 = 0.5349), so preserveAspectRatio="xMidYMid meet"
     fits by HEIGHT and pillarboxes 0.067px per side. Measured as rendered:
       inset  top 0.059 | left 0.127 | right 0.373 | bottom 0.872
       ink    22.125 × 41.115  (vs box 22.625 × 42.047)
     Centering the BOX would leave the ink 0.41px high and 0.12px left. Sub-pixel,
     but it is the same box-vs-ink error the three gaps exist to avoid, and it
     costs one calc term to do right.
     TIED TO --mark-w/--mark-h: the letterbox depends on the BOX ratio, so these
     four are only valid for this exact pair. Re-measure if the mark is resized.
     (Fixing the asset's viewBox would retire them, but shoon-logo-small.svg is
     shared with the live site — out of scope here.) */
  /* ---- THE BUILD'S PINK ---------------------------------------------------
     THERE WAS NO TOKEN FOR THIS. #FF3567 is the most-used red across the INTRO-1
     sheets (the footer sheet says so in its own header) and it appears as a BARE
     LITERAL in ~10 places — .ht-recent-sep here, the footer bullet and outline, and
     several Brandip rules. This is the first token for it.

     *** IT IS NOT CALLED --pink, AND THAT IS DELIBERATE. *** css/pellmell-intro-ef3quiz.css
     already defines `--pink: #da2381` on :root, and it loads AFTER this sheet — so a
     `--pink` here would be silently overridden page-wide by a different pink belonging
     to one EF card. The `--pi-` prefix is this sheet's own namespace (.pi-projtitle,
     .pi-footer) and cannot collide.

     NOT A SWEEP. The other ~10 literals are left alone; converting them touches the
     locked Brandip sheets and is a separate pass. */
  --pi-pink:       #FF3567;

  --mark-w:        22.627px;           /* Figma 1480:1482 */
  --mark-h:        42.047px;
  --mark-ink-w:    22.125px;
  --mark-ink-h:    41.115px;
  --mark-ink-top:  0.059px;
  --mark-ink-right: 0.373px;

  /* ---- --mark-inset: EQUAL top + right spacing for the mark ----------------
     ONE token, spent by BOTH edges of .hero-mark — so top and right are the same
     character and cannot diverge at any viewport. Measured to INK, and to the
     HERO's right edge (the visible white field ends at 1425 @1440; the 15px
     `scrollbar-gutter: stable` reserve beyond it is not field).

     THE ONE FREE NUMBER for the mark. 14px is a design value, not derived — but
     it is GUARDED, which is the part that matters:

       the vertical slack is  --title-cap-top − --mark-ink-h  = 18.51 @894
       min() caps the inset at (slack − --mark-clear)

     so the ink CANNOT cross the title's cap line — not "does not at 14px", but
     cannot, at any viewport, even if --title-cap-top or the mark's size change
     later. The literal only wins while it is the safer of the two (14 < 14.51
     @894). If a future edit shrinks the slack, the clamp takes over silently and
     the composition degrades instead of colliding. Both edges still read the same
     token, so equality survives the clamp too.

     Why not centre-in-gutter (v=16)? The bands are different sizes — the gutter is
     74.27 wide, gap 1 is 59.62 tall — so centring both gives 26.04 beside and 9.25
     above, and can never be equal. Equal spacing means abandoning gutter centring;
     the mark sits 14 from the top and 14 from the right, which moved it DOWN 4.75
     and RIGHT 12.04 from centred. User's explicit call. */
  --mark-clear:    4px;                /* floor on ink → cap-line clearance */
  --mark-inset:    min(14px, calc(var(--title-cap-top) - var(--mark-ink-h)
                                  - var(--mark-clear)));
}

*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* Reserve the scrollbar gutter from the start so clientWidth never changes when
   the page becomes scrollable at lenis.start() — otherwise the % -width card
   snapped ~15px narrower on the right at wipe-completion (scrollbar reflow). */
html { scrollbar-gutter: stable; }
body {
  background: #ffffff;
  font-family: 'Inter', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* ---- Lenis (smooth scroll) recommended base ---- */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }

/* =====================================================================
   PART I/II — Preloader (black). BLACK-WIPE model: the preloader is the TOP
   layer (z 300). It covers the hero during Part I, then WIPES DOWNWARD (JS
   clip-path inset(0)→inset(100% from top)) to uncover the hero + static card
   beneath it. (Earlier white-panel-descends model made "card hidden by black"
   impossible — card>shutter>pl forced the card above the black.)
   ===================================================================== */
.pl {
  position: fixed;
  inset: 0;
  z-index: 300;                       /* TOP — covers the hero, then wipes down */
  display: none;                      /* shown only under .js-loading */
  align-items: center;
  justify-content: center;
  background: #0d0d0d;
}
.js-loading .pl { display: flex; }

.pl-logo {
  display: block;
  /* sized at the MEDIUM hold footprint (Figma 1477:1524 ≈ 105×195);
     JS scales it from LARGE (≈187×347) down to 1.0 and holds. */
  width: 105px;
  height: 195px;
  transform-origin: 50% 50%;
  will-change: transform;
}
.pl-logo img {
  display: block;
  width: 100%;
  height: 100%;
  /* shoon-logo-large.svg is dark; force pure white on the black field */
  filter: brightness(0) invert(1);
}

/* =====================================================================
   Hero — the white GROUND (z 100), BENEATH the preloader. Fixed; holds the
   title, the static card, and the mark. The body is white, so the hero can stay
   transparent — as the black preloader wipes down, this hero (title + card) is
   uncovered. The card is a child here → it sits BENEATH the preloader in Part I
   (hidden) and is revealed in place as the wipe passes. No pre-JS opacity guard
   is needed: the black preloader (z 300) covers the hero until the wipe runs.
   ===================================================================== */
.hero {
  position: fixed;
  inset: 0;
  z-index: 100;
  overflow: hidden;
}

/* ---- Title block (1480:1473): horizontally centred, left-aligned text ---- */
/* Wordmark LEFT-ALIGNED to the card's left edge (Pellmell — shares 75px inset
   + 1290 width with the card; was Figma-centred). */
.hero-title {
  position: absolute;
  left: var(--content-left);          /* 75px — aligned to the card */
  top: var(--title-top);
  width: var(--content-width);        /* 1290 */
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  color: #141d1f;
  line-height: 1;
  pointer-events: none;
  user-select: none;
}
.ht-line { display: block; white-space: nowrap; }

/* "Shoon" — sized by cap-height (--shoon-size), left-aligned at the card edge,
   NARROWER than the card (5 letters ≠ Pellmell's 8). */
.ht-shoon {
  font-size: var(--shoon-size);       /* ≈38% of viewport height */
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1;
}
.ht-sub {
  display: flex;
  flex-direction: column;
  /* DERIVED and NEGATIVE at most heights (−2.13 @1440×894) — not a mistake, and
     not to be "corrected" to a positive value. It is solved so gap 2 lands on
     --edge-gap; see the :root note. The negative pulls .ht-sub's BOX up into the
     dead leading under "Shoon"'s ink, never into the ink itself — measured ink
     clearance holds at 59.62 across the whole height ladder, using at most ~14%
     of the available leading (worst case vh≥1061, where both plateau). */
  margin-top: var(--lines-gap);
  padding-left: 10px;                 /* Figma 10px inset on BROWNE / role */
}
.ht-browne, .ht-role {
  font-size: var(--role-font);        /* shared with --role-bottom (see :root) */
  font-weight: 700;
  letter-spacing: 3.2px;              /* Figma tracking-[3.2px] */
  line-height: var(--role-lh);        /* shared with --role-bottom (see :root) */
  text-transform: uppercase;
}

/* ── "Recent work" — pellmell.fr's "Read More" treatment ────────────────────
   THE MODEL IS .AppButton, NOT THE DATES ROW. An earlier pass built this from
   .SliceHomeShowreels-dates (grey text, a 4x4 separator BETWEEN two years, nudged up
   1px). The years are gone and the reference is now the "Read More" button itself, so
   the bullet LEADS the label and takes that component's own square — 8x8, not 4x4, and
   with no vertical nudge. Values read from their stylesheet:

     .AppButton              display: inline-flex; align-items: center;
                             gap: calc(var(--spacing,.1rem) * 6) = 6px
     .AppButton-title        font-size: var(--text-18, 1.8rem) = 18px  (landscape)
     .AppButton .pl-square   width/height 8px; border-radius: var(--radius-1, 1px)
     .SliceHomeHero-content  gap: calc(var(--spacing,.1rem) * 15) = 15px (landscape)
                             -> the distance their button sits below the copy above it

   TWO DELIBERATE DEPARTURES, both consistent with the footer:
     WEIGHT 500, NOT 650. Their button declares 650, but `season` ships as ONE static
     Medium file, so 650 and 400 both paint Medium. Inter is variable 100-900, so 500
     is a real instance rather than a synthesised weight.
     COLOUR. Their .AppButton is grey (--color-grey-3); this is BLACK at the user's call
     — the title's own #141d1f, so the label reads as part of the wordmark block it
     hangs under. The red is #FF3567, the one already in the INTRO-1 sheets, not their
     --color-red #ff243a.

   THESE SELECTORS ARE NEW, so adding them to this shared base cannot affect
   pellmell-intro-v3.html or -rhythm.html — neither has the markup. */
/* ── SEO / a11y utility (2026-08-25) ────────────────────────────────────────
   Text in the document and in the accessibility tree, painting nothing. Used by the
   H1's em-dash separator and by the four project headings. `position: absolute` is the
   load-bearing part: it takes the element OUT OF FLEX FLOW, so it cannot add a flex
   item, a `gap` or any height to the columns it sits in. NOT `display: none` and NOT
   `visibility: hidden` — both strip the text from the accessibility tree, which is the
   whole point of the element.
   NEW SELECTOR, so adding it to this shared base cannot affect pellmell-intro-v3.html
   or -rhythm.html — the same contract .ht-recent below is added under. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The page <h1>, as a NO-BOX wrapper (2026-08-25). .hero-title is the flex column and
   must keep .ht-shoon and .ht-sub as its own direct flex items; `display: contents`
   makes this element generate no box at all, so the heading can wrap a SUBSET of that
   column (the two title lines, NOT the "Recent work" button) without adding an item or
   reflowing anything. It is still a real <h1> in the DOM — `display` is a box property
   and has no bearing on what a crawler parses. See the markup note in index.html.
   DO NOT give this element a background, border, padding or transform: none of them
   render on a `display: contents` box, and reaching for one is the signal that the
   wrapper has stopped being purely semantic. */
.hero-h1 { display: contents; }

.ht-recent {
  /* MOVED OUT OF THE <h1> (2026-08-25). It is now a direct child of .hero-title rather
     than .ht-sub's last child, so neither .ht-sub's `margin-left: 57px` (set in
     css/pellmell-intro-lipscale.css) nor its `padding-left: 10px` reaches it any more.
     This restates their sum and holds the 2026-08-24 "the bullet is the flush edge"
     call — the square stays on the exact left edge BROWNE and UX PRODUCT DESIGN start
     from.
     67px IS DERIVED, NOT PICKED: 57 + 10, the same "margin-left 57 + padding-left 10 =
     67" the original markup note records, and confirmed by measurement (.ht-sub starts
     57px inside .hero-title). *** IF EITHER OF THOSE TWO VALUES MOVES, THIS MUST MOVE
     WITH IT — there is no live link. ***
     THE VERTICAL NEEDED NO RESTATEMENT: neither .hero-title nor .ht-sub carries `gap`
     or bottom padding, so this element's own `margin-top: 15px` still measures from the
     role line's bottom edge, and .ht-sub simply got shorter by exactly the height this
     element used to add to it (measured 172.84 -> 136.25 = 21.59 + 15).
     VERIFIED pixel-identical against a control built from the pre-SEO stylesheet:
     .hero-title, .ht-shoon, .ht-browne, .ht-role, this element, its square and its
     label, #heroCard, #heroProject, .pi-footer, .bw-screen and the document height all
     match to the hundredth of a pixel. .ht-sub's own HEIGHT is the one intentional
     delta; it paints nothing and nothing reads it (js/pellmell-intro.js touches only
     its marginTop, for a console diagnostic). */
  margin-left: 67px;

  /* IT IS AN <a> AS OF 2026-09-01 (it navigates to about.html — see the markup note).
     Was a <button> from 2026-08-25, whose rationale was specifically that the target was
     an in-page fragment inside the FIXED .hero, where a fragment's document position is
     meaningless. THAT RATIONALE IS RETIRED: the target is now a SEPARATE DOCUMENT, which
     is exactly what an anchor is for, so the scripted scroll it needed is gone with it.

     THE RESET BLOCK IS KEPT WHOLESALE AND THAT IS DELIBERATE. Only `text-decoration` is
     new (an anchor's one UA declaration that the button reset did not already cover, and
     a PAINT change, not a box one). The other seven are no-ops on an <a> — they are kept
     so the box is defended against the TAG, not against one particular tag's UA sheet.
     *** THIS ELEMENT SITS IN AN OPEN DEFECT'S GEOMETRY CHAIN *** (the HELD cold-load lip,
     Docs/RULES.md 2026-09-01): the reported symptom is measured as the gap from THIS box's
     bottom edge to #heroCard's top, so any change to its height or margins would silently
     move the thing under investigation. Height is font-size 18 x line-height 1.2 = 21.6,
     margins 15 top / 67 left — all explicit below, and all unchanged by the tag swap.
     Width is fit-content and DOES shrink with the shorter label; that is horizontal and
     the chain is vertical, so the defect's geometry is untouched. */
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  padding: 0;
  background: none;
  text-align: left;
  cursor: pointer;
  text-decoration: none;        /* <a> UA underline — the ONE new line the tag swap needs */

  display: flex;
  align-items: center;
  gap: 6px;                     /* .AppButton gap */
  /* HUG THE CONTENT (2026-08-25). .ht-sub is `flex-direction: column`, so this
     block-level flex box STRETCHED to the column's full width — measured 476.6px at 1440
     against ~119px of actual ink (8px square + 6px gap + the label). That was already
     wrong before this line became a control: js/pellmell-intro-recent.js binds
     mouseenter/mouseleave to THIS element, so the bullet rolled when the pointer was
     still 350px to the right of the words, with nothing under it. As a button it would
     also have made that whole dead strip clickable. `fit-content` and not
     `align-self: flex-start` — it is parent-agnostic, so it survives .ht-sub ever
     ceasing to be a flex column. Moves nothing: this is .hero-title's LAST child (it left
     .ht-sub in the 2026-08-25 SEO move — this line said ".ht-sub's" until 2026-09-01),
     and cardLipTop derives from .ht-role's rect above it. */
  width: fit-content;
  /* NO OUTDENT — THE BULLET IS THE FLUSH EDGE (user, 2026-08-24).
     This line sits inside .ht-sub, so it starts exactly where BROWNE and
     UX PRODUCT DESIGN start; with nothing pulling it back, the SQUARE lands on that
     shared left edge and the label follows 14px in (its 8 plus the 6px gap).
     SUPERSEDES an earlier `margin-left: -14px`, which hung the square in the margin to
     put the WORDS flush instead. Both readings of "flush left" are reasonable and the
     user has now chosen the bullet; do not reintroduce the outdent. */  /* .hero-title is pointer-events:none, so this subtree has to opt back IN or the
     hover below can never fire. */
  pointer-events: auto;
  /* THE HOVER LIVES IN JS. js/pellmell-intro-recent.js owns the roll and the label
     nudge (GSAP, expo.out) — a CSS transition read mechanical here, and the reference
     drives its own square inline from JS with no transition published at all. Nothing
     below sets transform or transition on these elements; adding either back would
     fight GSAP for the same property. */
  margin-top: 15px;             /* .SliceHomeHero-content gap, landscape */
  font-family: 'Inter', system-ui, sans-serif;
  font-size: 18px;              /* .AppButton-title, landscape */
  /* 650 — .AppButton's DECLARED weight, taken literally (user, 2026-08-24, asked
     repeatedly). An earlier pass put 500 here on the argument that `season` ships as one
     static Medium file, so their 650 "really" paints Medium. That argument was wrong in
     its conclusion: with only a 500 face available, the browser SYNTHETICALLY EMBOLDENS
     for weights >= 600, so their button rasterises heavier than Medium even though
     DevTools still names the Medium file as the rendered font. Reading the file name told
     me which face was loaded, not how it was drawn.
     Inter is variable 100-900, so 650 here is a real instance and needs no faux-bolding
     to reach the same visual weight.
     NOTE the footer deliberately stays at 500 — that was an explicit instruction for
     those two elements. This one is not that; do not "harmonise" them. */
  font-weight: 650;
  line-height: 1.2;
  letter-spacing: normal;       /* .ht-browne/.ht-role's 3.2px must not leak in */
  text-transform: none;
  /* GREY, matching their button (user, 2026-08-24). .AppButton sets
     `color: var(--color-grey-3, #959697)` — this now takes that value rather than the
     title's black. Supersedes the earlier "black" call. The bullet stays #FF3567. */
  color: #959697;
  white-space: nowrap;
}
.ht-recent-sep {
  flex: none;
  display: block;
  width: 8px;                   /* .AppButton .pl-square — 8x8, not the dates' 4x4 */
  height: 8px;
  border-radius: 1px;           /* their --radius-1 */
  background: #FF3567;
  /* NO transform / transform-origin / transition here — see the note above.
     The pivot is set once in js/pellmell-intro-recent.js (transformOrigin
     'right bottom'), because GSAP caches the origin on first write and two
     sources would drift apart. */
}
/* The label's 8px travel and the whole hover pair are in
   js/pellmell-intro-recent.js. That file also carries the gates the CSS used to:
   it returns early on a coarse pointer and on prefers-reduced-motion, so no rule
   is needed here for either. */

/* per-char spans injected by the JS splitter */
.ht-char { display: inline-block; will-change: transform, opacity; }

/* ---- Part III project card + caption UNIT (moves as one on scroll) ----
   Card dims = PELLMELL (scale authority): 1290×667, 75px inset each side @ 1440.
   CONFLICT with Figma 1480:1480 BCK-g (1364×675.4, 38px inset) — Pellmell wins
   per the user's scale-authority call; see report. Shares left edge + width with
   the wordmark. JS parks the unit at the pre-scroll lip — card top = the role
   line's INK bottom + --edge-gap, leaving the remainder visible (328.35 at
   1440×894). Reduced motion derives the same top from --card-lip-top. */
.hero-project {
  position: absolute;
  left: var(--content-left);          /* 75px (Pellmell inset) */
  top: var(--card-rest-top);          /* fully-visible rest (next pass) */
  width: var(--content-width);        /* 1290 */
  will-change: transform, opacity;
}
.hero-card {
  width: 100%;
  aspect-ratio: 1290 / 667;           /* Pellmell proportion (≈1.934) */
  border-radius: 52.8px;              /* Figma rounded-[52.8px] (Pellmell unspecified) */
  overflow: hidden;
  background: #7ba587;                /* CSS fallback under the WebGL canvas */
}
.hero-card .intro-bg {
  display: block;
  width: 100%;
  height: 100%;
}
/* ---- Caption beneath the card ------------------------------------------
   Was an empty dashed placeholder; carries the Matey caption as of 2026-09-02.
   Type is .pi-projtitle, the project-title component defined further down this sheet
   and previously unused — name over a lighter descriptor. */
.hero-caption {
  /* THE GAP IS UNTOUCHED — margin-top stays 16. That is the row's rhythm and it is
     what "don't change the gap spacing" protects.
     THE HEIGHT IS NOT A SPEC AND NEVER WAS. It was a dashed placeholder, and the
     comment above this rule said so: "remove the border when caption content lands."
     Content has landed, so the stand-in goes with it.
     `auto`, NOT A LITERAL 55.1 (user approved growing it to that figure). 55.1 is the
     COMPUTED height of .pi-projtitle at its defined scale —
         name 24 x 1.2 = 28.8  +  gap 6  +  sub 14 x 1.45 = 20.3   ->  55.1
     so writing the number would be transcribing an output as an input, and it would
     go stale the moment either size moved. `auto` yields the same 55.1 today and
     re-solves itself after. This is the derived-over-fixed preference in RULES.
     FREE TO GROW: .hero is fixed and .hero-project absolute, so this subtree is out of
     document flow and nothing sits below the caption inside it. docH, trackH and
     riseEnd cannot see this box. */
  margin-top: 16px;
  height: auto;
}
/* The component carries its own margin-top and the project-row width; neither applies
   inside the caption slot. Type, colour and gap are inherited untouched. */
.hero-caption .pi-projtitle { margin-top: 0; width: 100%; }
/* The caption is one link. It must add NOTHING of its own — the name, descriptor and
   arrow already carry their type and colour, and an anchor's UA underline and link
   colour would repaint all three. `color: inherit` is what keeps the descriptor grey
   and the name ink; the arrow is unaffected either way since it strokes currentColor
   from its own rule. */
.pi-projtitle__link {
  display: flex; flex-direction: column; gap: inherit;
  text-decoration: none;
  color: inherit;
  /* SHRINK TO THE TEXT. As a block-level flex container this filled the full 1290 row, so
     the hover fired — and a click landed — most of a screen right of anything visible.
     `fit-content` resolves against the widest item's max-content, and the two rows are
     73.28px ("Matey", 24/650) against 288.62px (the descriptor 272.62 + 4 margin + 12
     arrow), both measured from assets/fonts/inter-latin.woff2. So the box is the
     DESCRIPTOR ROW, and the title — narrower, and left-aligned with it — sits wholly
     inside it. All three parts stay inside the target; only the empty right-hand run is
     given up, which is the part that should never have been clickable.
     HOVER AND CLICK ARE THE SAME BOX, deliberately: a hover zone smaller than the click
     zone means a click that lands with no affordance ever having been shown. */
  width: fit-content;
}

/* Turn-down-right arrow after the descriptor. SIZED AND ALIGNED SO THE LINE BOX DOES
   NOT GROW, which is what keeps the caption's height unchanged:
     descriptor line box = 14 x 1.45 = 20.3px
     a 12px box on `vertical-align: middle` sits from baseline-0.91 to baseline+11.09,
     inside the text's own ascent/descent (+13.6 / -3.4 for Inter at 14px)
   so it cannot extend the line. No colour is declared — the svg strokes with
   currentColor and inherits the descriptor's. */
.pi-projtitle__turn {
  display: inline-block;
  /* GLYPH SIZE, 12 -> 16 (user, 2026-09-02: "increase the glyph size too"). The swing was
     MEASURED rendering correctly and simply too small: in headless Chrome, 45deg on the
     12px glyph swept rotationX -41.7..4.2 with the transform staying 3D throughout, and
     moved the rendered box just 2.15px. That reads as a ripple, not a fall.
     16 IS THE LARGEST CLEAN VALUE THAT KEEPS THE CAPTION HEIGHT. On `vertical-align:
     middle` the box centres on baseline + x-height/2 = +3.821 (Inter 14px), so size h
     spans +3.821+h/2 .. +3.821-h/2. The line box is the 1.45 strut -- 20.3px against
     Inter's own 17.0 of ascent+descent -- so half-leading puts its floor at baseline-5.05
     and its ceiling at +15.25. Solving 3.821 - h/2 >= -5.05 gives h <= 17.74; the floor
     binds first (the ceiling would allow 22.86). 16 clears it by 0.87px. */
  --pi-turn-size: 16px;
  width: var(--pi-turn-size);
  height: var(--pi-turn-size);
  /* HALF the declared gap — derived, not retyped. 8 / 2 = 4px today, and it re-solves
     if --pi-turn-gap moves. Horizontal only; the component's vertical `gap: 6px` and
     the caption's `margin-top: 16px` are untouched. */
  margin-left: calc(var(--pi-turn-gap) / 2);
  vertical-align: middle;
  /* NUDGE UP 4.5px SO THE HORIZONTAL STEM SITS ON THE TEXT'S OPTICAL CENTRE.
     DERIVED, AND THE DERIVATION NEEDS NO FONT METRIC — which is why it holds if the
     face or size changes. `vertical-align: middle` already puts the BOX CENTRE on the
     text's optical centre (baseline + x-height/2), so the only discrepancy left is
     where the stem sits INSIDE the box:
         stem is at viewBox y 10.5; the box centre is 6.0   (12-unit box at 12px, 1:1)
         offset = 10.5 - 6.0 = 4.5px
     Moving the whole glyph by that puts the stem exactly on the box centre, and
     therefore on the text's centre. The STEM IS NOT MOVED WITHIN THE GLYPH.
     Confirmed against Inter's real metrics (read from the file, upm 2048): x-height
     0.5459em = 7.643px at 14px, half 3.821 — the stem lands at baseline +3.821.
     TRANSLATE, NOT ROTATE. A translation does not swap the source and screen axes the
     way the old rotate(-90deg) did, so nothing here is ambiguous. And transforms do
     not affect layout, so the caption stays 55.1. */
  /* RE-DERIVED FROM THE SIZE, not retyped. The stem sits at viewBox y 10.5 in a 12-unit
     box whose centre is 6.0, so the offset is 4.5 VIEWBOX UNITS -- which equalled 4.5px
     only while the box rendered at 12px. Against --pi-turn-size it stays correct at any
     size: 4.5 x 16/12 = 6px today. */
  transform: translateY(calc(var(--pi-turn-size) * -4.5 / 12));
  /* PINK BY TOKEN, not a literal. The svg strokes `currentColor`, so setting `color`
     here is what the stroke resolves against — no hex appears in the markup and the
     value is spent once, above. */
  color: var(--pi-pink);
  /* NO TRANSFORM AT REST beyond the nudge above. The glyph is now DRAWN in its final
     orientation (user, 2026-09-02: "a shape change, not a transform"), and the rotation
     is removed with it.

     THE HOVER SPIN DOES NOT LIVE HERE. js/pellmell-intro-caption.js turns this glyph on
     rotationX while the caption is hovered, and returns it to 0. GSAP reads the
     translateY below into its own transform cache on first touch and keeps it, so the
     -4.5px optical nudge survives every spin; the RESTING orientation this rule
     describes is what the spin always lands back on.

     REMOVING IT IS THE POINT, not a side effect. rotate(-90deg) mapped SVG-down to
     screen-right and SVG-right to screen-up, so the source's "vertical" segment was
     the screen's HORIZONTAL one. That is exactly why the proportions read reversed:
         SVG vertical   7 units  ->  rendered horizontally  (looked long)
         SVG horizontal 6 units  ->  rendered vertically    (looked short)
     With the path drawn upright, source and screen axes agree and the lengths mean
     what they say.

     THE SHAPE: short horizontal, long vertical, head up — 4 units across, 8 up, a
     clean 1:2. Orientation is unchanged from the rotated version; only the ratio moved.

     OPTICAL ALIGNMENT IS UNCHANGED, which is why nothing is compensated: an unrotated
     square and a square rotated 90 are both axis-aligned, so the half-extent stays
     6.00 and the arrow still spans baseline -0.91 to +11.09 against the text's -3.39
     to +13.57. Layout was never affected either way. */
}

/* ---- Top-right SB mark (1480:1482), dark ---- */
.hero-mark {
  position: absolute;
  /* EQUAL SPACING, top and right, on INK. Nothing here is hand-placed.

     Both lines below spend the SAME token, --mark-inset — that is the whole
     mechanism. Top and right are not two values kept equal by upkeep; they are one
     value read twice, so they stay equal at every viewport by construction, the
     same way the three gaps all spend --edge-gap.

       ink top   = --mark-inset  below the viewport top edge (y=0)
       ink right = --mark-inset  left of the HERO's right edge

     BOTH SUBTRACT AN INK INSET (--mark-ink-top / --mark-ink-right) because the
     asset carries trailing viewBox space + letterboxing — see :root. Position the
     BOX and the ink lands 0.41px high and 0.12px left; these two terms are the fix,
     and they are what makes the two --mark-inset spends land on ink rather than
     on box edges that are padded by different amounts.

     History: v=14–16 sat this flush to the title's ink-top at a bare right: 2.78%
     (12.04 from the card edge, 39.61 from the hero edge — centred in nothing).
     v=16 centred it in both bands, which cannot produce equal spacing because the
     bands differ (gutter 74.27 vs gap 1 at 59.62 → 26.04 beside, 9.25 above).
     v=17 spends one token on both and drops gutter centring. */
  top:   calc(var(--mark-inset) - var(--mark-ink-top));
  right: calc(var(--mark-inset) - var(--mark-ink-right));
  display: block;
  width:  var(--mark-w);
  height: var(--mark-h);
}
/* The SB mark SVGs are authored as a neutral WHITE shape with a genuinely
   transparent counter (no hardcoded ink colour in the asset). CSS owns the
   colour per placement — the preloader recolours to white
   (brightness(0) invert(1)); here the hero mark recolours to dark ink so it
   reads on the white hero, with the open counter showing white through. */
.hero-mark img { display: block; width: 100%; height: 100%; filter: brightness(0); }

/* =====================================================================
   PART III — scroll length. Gives the fixed stage its scrub distance.
   ===================================================================== */
.scroll-track {
  position: relative;
  width: 100%;
  height: 180vh;                      /* ≈ 0.8 viewport of scrub — tunable */
  pointer-events: none;
}
/* No scroll length when motion is off. */
.scroll-track { height: 0; }
.js-loading .scroll-track { height: 180vh; }

/* =====================================================================
   Reduced motion — static 1480:1468 hero, nothing animates.
   (JS also early-returns; this is the CSS belt-and-suspenders.)
   ===================================================================== */
@media (prefers-reduced-motion: reduce) {
  .pl { display: none !important; }   /* no preloader → white hero shows at rest */
  .hero { opacity: 1 !important; }
  .scroll-track { height: 0 !important; }
  .ht-char { opacity: 1 !important; transform: none !important; }
  /* Card at the LIP (not the flush-top rest) so the title stays readable — the
     rest position would cover it. Derived from the same --edge-gap the JS path
     spends, so the two cannot drift apart (was a hardcoded 100vh − 203px
     mirroring the old JS CARD_LIP constant — two copies of one number). */
  .hero-project { top: var(--card-lip-top) !important; }
}

/* ── PROJECT TITLES — the pellmell.fr .Home h2 treatment ────────────────────
   Under each project screen: the project name over a one-line description.
   REFERENCE VALUES, measured by the user in DevTools on pellmell.fr's `.Home h2`
   and its sibling: title 24px / weight 650, sub-line 14px / weight 400, ONE family
   for both, letter-spacing normal, text-transform none. The two restated properties
   are not noise — this build sets `letter-spacing: 3.2px` and `text-transform:
   uppercase` on its other title pair (.ht-browne/.ht-role), so both would otherwise
   be inherited-adjacent surprises waiting for someone to reuse a class here.

   THE FACE IS BRICOLAGE GROTESQUE, NOT season (user, 2026-08-25). pellmell.fr uses
   `season`, which this project does not license; the brief was to match the TREATMENT
   — the 24/14 size ratio, the 650/400 weight contrast, one family across both lines —
   not the face. Bricolage is already self-hosted here (assets/fonts/, declared
   @font-face in css/pellmell-intro-brandip.css) and is VARIABLE 200-800, so 650 and
   400 are both real instances rather than synthesised weights. Lexend is the fallback,
   matching the stack the Brandip screens already use.

   WIDTH IS THE CARD'S PAINTED WIDTH, NOT ITS FRAME. 1205.48 is the box .bw-screen /
   .se-screen / .ef-stack actually paint (the same number the footer is sized to, and
   for the same reason) — .project-row is 1276 but transparent, so aligning to it would
   set the title 35.26px left of the card edge it is captioning. */
.pi-projtitle {
  /* THE DESCRIPTOR -> ARROW GAP, declared once so the arrow can spend a FRACTION of it
     rather than carry a second number. Halving is then structural: change this and the
     arrow follows.
     HONEST PROVENANCE: 8px is not derived from anything. It was a free value chosen
     when the arrow was added, and naming it here does not make it measured — it makes
     it SINGLE. It is deliberately NOT the 6px `gap` below: that one is the vertical
     name->descriptor gap and is untouched by any of this. */
  --pi-turn-gap: 8px;
  width: 1205.48px;
  max-width: 100%;
  margin-top: 28px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-family: 'Bricolage Grotesque', 'Lexend', system-ui, sans-serif;
  letter-spacing: normal;
  text-transform: none;
  color: #141d1f;
}
.pi-projtitle__name {
  margin: 0;
  font-size: 24px;
  font-weight: 650;
  line-height: 1.2;
}
/* THE DESCRIPTOR'S TEXT, WRAPPED SO IT CAN MOVE WITHOUT THE ARROW. The arrow lives
   inside .pi-projtitle__sub, so nudging that element on hover would carry the arrow with
   it — and the arrow has to stand still to be struck. This span is the moving part.

   `inline-block` IS LOAD-BEARING, NOT TIDINESS: transform does not apply to a
   non-replaced inline box, so as a plain <span> the nudge would silently do nothing.

   IT COSTS THE LINE BOX NOTHING, which is what keeps the caption at 55.1px. An
   inline-block's baseline is the baseline of its last line box, so the text sits exactly
   where it did; the box is still 14 x 1.45 = 20.3 tall and still wraps internally if the
   container ever narrows. No width, no padding, no margin — it is a handle, not a box. */
.pi-projtitle__subtext { display: inline-block; }

/* The three non-hero captions. The wrapper is a COLUMN around the project row so the
   caption can sit under it and inherit its width without any width literal: as a flex item
   of the .*-project-screen (row direction, align-items:flex-start, justify-content:center)
   it shrink-wraps to its widest child, which is the row. Nothing about the screens changes
   -- they still hold exactly ONE in-flow child, since the sr-only h2 is position:absolute.
   THE GAP IS MATEY'S, 16px, restated rather than re-derived: .hero-caption spends the same
   margin-top and the two must read as one component. */
.pi-projwrap { display: flex; flex-direction: column; }
/* THE CAPTION TAKES THE PAINTED BOX, NOT THE ROW'S BOX. This is the alignment fix, and
   it is structural rather than a nudge.
   MEASURED (headless, landed state): .project-row is 1276 wide at left 74.5, but the
   artwork inside it -- .ef-stack / .se-screen / .bw-screen -- paints 1205.48 wide and is
   CENTRED, so it starts at 109.8. The caption was stretching to the row, so it sat 35.3px
   left of the thing the eye reads as the container. ALL THREE were affected identically,
   not just EF. Matey is unaffected because its caption is a sibling of .hero-card and
   inherits that card's own edge (measured delta 0.0).
   1205.48 IS THE SHARED PAINTED WIDTH, and the sheets already say so:
   pellmell-intro-brandip.css names it "the same box .ef-stack and .se-screen paint", and
   computes this very inset as "(1276 - 1205.48) / 2 = 35.26". `margin: auto` re-derives
   that centring instead of restating 35.26, so only the width is carried here.
   IT RESTATES A VALUE OWNED BY THREE PER-SCREEN SHEETS. If the painted width moves, this
   must move with it -- flagged rather than hidden. */
.pi-projcaption {
  --pi-art-w: 1205.48px;
  width: var(--pi-art-w);
  margin: 16px auto 0;       /* the 16px gap is unchanged; auto supplies the centring */
}
.pi-projcaption .pi-projtitle { margin-top: 0; width: 100%; }

.pi-projtitle__sub {
  margin: 0;
  font-size: 14px;
  font-weight: 400;
  line-height: 1.45;
  color: rgba(20, 29, 31, 0.72);
}
