/* =====================================================================================
   SITE — experiment 02
   Left menu only, so far. base.css must be linked before this.
   ===================================================================================== */

:root {
    /* INK, NOT BLACK, and PAPER, NOT WHITE. Pure #000 on pure #fff is the one pairing that cannot
       be made warm — every tint layered on top reads as a stain rather than as a material. #111c
       is #111111 at 80%, so body copy sits back off the page instead of stamping onto it, and
       every opacity already in this file compounds against a colour that was already soft.
       These two are load-bearing for the case study's mats: the paper is only "cream" against a
       near-white page, and against #fff it reads as dirty. Changing one without the other undoes
       the reason for either. Blast radius is the whole site — menu, work row, every card. */
    --black: #111c;
    --page:  #fefdfb;

    /* THE site shadow. Two drop shadows, Eoin's spec, used by every page AND the folder.
       DO NOT CHANGE THIS VALUE. */
    --page-shadow:
        0 1px 2px 0 rgba(0, 0, 0, 0.30),
        0 1px 3px 1px rgba(0, 0, 0, 0.15);

    --placeholder: #D9D9D9;

    /* ---- The row ----------------------------------------------------------------------
       Sixteen objects, not five, so the base size came down from 80vh. At 80 only three are
       on screen at once, which is a slideshow rather than a row you can scan.
       (--row-peak, how much the centred object grew, went with the centre scale on 2026-09-15.) */
    --about-folder-h: 72vh;   /* the folder's size on the about page, where there is one of it */
    --card-stock: #F6F3EC;   /* warm off-white — index-card stock reads as a different paper */

    /* ---- The case-study card ----------------------------------------------------------
       Geist is scoped to that card only; see .work-page:is([data-kind="case"], [data-kind="site"]). */
    /* Every card in the row is this tall. Capped at 540px, the height 60vh gives on a 1440x900
       laptop: at 1920x1080 the cards ran 645px and read too large (Eoin, 2026-09-15). Laptops and
       smaller windows are unchanged. */
    /* dvh, not vh (Eoin, 2026-09-15): on a phone the window's height is what is left once the
       browser's own toolbar is taken off. On a desktop the two are the same number. */
    --card-h: min(60dvh, 540px);
    /* THE CARD'S WORDS, as one set (Eoin, 2026-09-15: titles up from 18px). The overline, the gap
       above the title, the cover's figures and the website arrow all scale with the title — 22/18 —
       so the labels still share the overline's line and the figures and the arrow the title's.
       FLUID since the mobile version: 18 on a phone, 22 from 1280 — see THE FLUID SCALE. */
    --wp-title-size: clamp(18px, calc(18px + 4 * var(--ramp)), 22px);
    --wp-title-gap: 10px;
    --wp-kick-size: 10px;
    /* A FALLBACK ONLY — site.js measures the real text block and overwrites this. Two things
       depend on it: each card's width (frame height x ratio, where frame height is card height
       minus this) and the row's optical offset. A guessed 88px against a real 105px squeezed
       every frame and drifted its ratio. */
    --text-h: 105px;
    --font-geist:      "Geist", system-ui, sans-serif;
    --font-geist-mono: "Geist Mono", ui-monospace, monospace;
    /* PAPER, NOT PANEL. #F1F1F1 was a neutral grey: behind a screenshot it read as a UI surface,
       the same material as the thing it framed, which is the opposite of the made-by-hand feel.
       Red and green stay high and blue drops, so it reads as cream against the near-white page.
       Tuned as a pair with --frame-grain — see .cs-frame::before. */
    --frame: #fefcf6;
    /* Jakub's shadow-as-border (make-interfaces-feel-better/surfaces.md): a 1px ring, a lift,
       then ambient depth. Transparent, so it adapts to any background a border could not. */
    --shadow-border:
        0px 0px 0px 1px rgba(0, 0, 0, 0.06),
        0px 1px 2px -1px rgba(0, 0, 0, 0.06),
        0px 2px 4px 0px rgba(0, 0, 0, 0.04);
    --shadow-border-hover:
        0px 0px 0px 1px rgba(0, 0, 0, 0.08),
        0px 1px 2px -1px rgba(0, 0, 0, 0.08),
        0px 2px 4px 0px rgba(0, 0, 0, 0.06);    /* Pure black at 10%. The skill is explicit this is not negotiable — a near-black from a
       palette picks up the surrounding surface colour and reads as dirt on the image edge. */
    --img-line: rgba(0, 0, 0, 0.1);
}

:root {
    --folder-face:  #F1F1F1;   /* front panel */
    --folder-back-c:#EFEFEF;   /* back panel, a shade down */
    --sheet-white:  #FFFFFF;

    /* The same pair as the A4 sheets, so folder and pages sit on the page identically.
       Contact term over penumbra — one Gaussian has one width, so it can render a crisp
       contact edge or a soft body but never both. */
    --cast: var(--page-shadow);

    /* The die-cut silhouette as a MASK rather than a clip-path.

       Chrome antialiases border-radius but rasterises clip-path: url() as a hard 1-bit
       mask, so every curve on the panel came out stair-stepped while the back panel's
       identical corner — done with border-radius — stayed smooth. A CSS mask is an ALPHA
       mask: partial coverage at the edge is preserved, so the curve antialiases.

       preserveAspectRatio='none' plus mask-size 100% 100% stretches the viewBox to the
       element, and the viewBox carries the folder's own ratio, so nothing distorts. */
    --folder-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1378' preserveAspectRatio='none'%3E%3Cpath fill='%23fff' d='M0,8.13 C0,3.64 3.67,0 8.2,0 L975.5,0 C989.03,0 1000,10.91 1000,24.39 L1000,357 C1000,362.9 997.9,367.9 993.7,372 L902.55,461.5 C901.25,462.8 900.6,464.3 900.6,466.2 L900.6,1353.6 C900.6,1364.5 889.63,1378 876.1,1378 L8.2,1378 C4.53,1378 0,1374.4 0,1369.9 Z'/%3E%3C/svg%3E");

    /* The same silhouette INVERTED — everything the front panel does not cover, which on this
       shape is the die-cut notch and nothing else. A full-box subpath in front of the panel's
       own path, with fill-rule evenodd: a point inside both crosses two subpaths and is left
       empty, a point inside only the box crosses one and is filled. So the two masks are exact
       complements of each other, built from ONE path that cannot drift out of step with itself.
       Used by .sheet-inner — see the diffusion rules further down. */
    --folder-mask-inverse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1378' preserveAspectRatio='none'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M0,0 L1000,0 L1000,1378 L0,1378 Z M0,8.13 C0,3.64 3.67,0 8.2,0 L975.5,0 C989.03,0 1000,10.91 1000,24.39 L1000,357 C1000,362.9 997.9,367.9 993.7,372 L902.55,461.5 C901.25,462.8 900.6,464.3 900.6,466.2 L900.6,1353.6 C900.6,1364.5 889.63,1378 876.1,1378 L8.2,1378 C4.53,1378 0,1374.4 0,1369.9 Z'/%3E%3C/svg%3E");

    --ink-red: #992147;

    /* ---- Tracing paper -------------------------------------------------------------------
       The front panel as a translucent sheet rather than a solid one. These are the values
       Eoin arrived at on the dial panel, and they are the SITE's defaults — the shipped
       file:// page never loads the panel, so if these did not match it would render the old
       opaque cover and only look right on localhost. dev/cover-dials.js starts from the same
       numbers. */
    --cover-color:   #ffffff;
    --cover-opacity: 0.4;
    --cover-texture: 0.69;      /* grain, as an opacity */
    --cover-grain:   1.35;      /* the grain's frequency — higher is finer */

    /* The cut edge. Near-black at 7% reads as a crease rather than as a drawn outline. */
    --edge-color:    #000000;
    --edge-width:    2.75;
    --edge-opacity:  0.07;

    /* What is left of the page behind the reader. Lower is fainter — this is how visible the
       background IS, not how faded. Dropped from 0.35 on 2026-08-07: with the blur parked, the
       dim is doing that job alone, and at 0.35 the row's text was still legible enough to read
       as content rather than as context. */
    --behind-opacity: 0.2;
    --behind-blur:    5px;

    /* The recede's timing. ONE duration each way, because the dim and the blur move TOGETHER,
       from the moment the page lifts — see .reader-open below, and the cost of doing it there.

       Two earlier arrangements failed on the eye rather than on the numbers. Dim riding the
       flight over 1600ms while the blur waited for the landing: a 0.65 opacity change spread
       over 1.6s is below the rate at which the eye reads something as moving, so it registered
       as a vague drift and the blur then arrived alone as a puff. Both together but held until
       the landing: cheap and smooth, but the background sits untouched for the 2.6s that the
       page is actually travelling, which is the moment it is supposed to be getting out of the
       way.

       --behind-out USED to be a beat closeIt() held the sheet still for, and it read this token
       to know how long. It no longer does either: with the blur parked there is nothing on
       .menu/.panel that cannot afford to move, so the background now comes back WHILE the page
       scales down and repositions rather than finishing first. This is therefore just a CSS
       duration now, and it wants to be roughly the length of the flight home rather than the
       short "let go" beat it was — at 200ms the background was fully back before the page had
       got anywhere, which is the same two-events-instead-of-one problem the way out had. */
    --behind-in:      420ms;
    --behind-blur-in: 500ms;
    --behind-out:     500ms;

    /* The background steps BACK as well as dimming. This is what carries the recede during the
       flight, and it is why the recede is smooth again: transform and opacity are the only two
       properties that never rasterise, so they cost nothing while the sheet is moving. A dim on
       its own was not perceptible enough to read as movement — scale is. */
    --behind-scale: 0.96;

    /* The cast shadow stays ON. Unset means the rule's own default draws — see
       .folder-cover-cast. The stroked cut edge gives it a solid silhouette to work from, so it
       no longer fades with the translucent fill. */
    --cover-grain:   0.8;       /* the grain's frequency — higher is finer */
    /* --cover-cast-filter is written by the dial: unset for the shadow, `none` for no shadow.
       See .folder-cover-cast. */
}

body { background: var(--page); color: var(--black); }

/* ---- THE FLUID SCALE (mobile version, 2026-09-15) ----------------------------------------------
   Type and spacing slide smoothly from a phone to full size. ONE RAMP drives every token: it is 0px at
   a 375px window and 1px at 1280px, so each token reads "its phone value, plus the difference times the
   ramp", clamped between the two. At 1280 and wider every token is exactly the desktop value it
   replaced, so nothing on a laptop or a monitor moves — harness desktop-geom diffs that, number by
   number. Below 375 the phone values hold.
   THE DIFFERENCE IS WRITTEN AS A BARE NUMBER, and that is not style. (88px - 20px) * var(--ramp) is a
   length times a length, which is invalid — and arriving through var(), it is invalid only at computed
   time, so the property silently becomes nothing rather than showing up as an error.
   REGISTERED AS LENGTHS, so each computes once, to pixels, at the root and inherits as that number —
   which is also what lets site.js and the harness read a value back instead of the clamp() text. And an
   invalid value falls back to the initial-value, which is the desktop size, not to nothing.
   The phone values are a first pass, tuned at 375 with Eoin looking. The two floors are his: reading
   text never under 14px, labels never under 10px. The tokens that already existed are made fluid where
   they are declared — --cs-pad, --mat-pad, --bar-gap, --down-line, --wp-title-size — so their comments
   stay beside them. */
@property --ramp        { syntax: '<length>'; inherits: true; initial-value: 1px; }
@property --body-size   { syntax: '<length>'; inherits: true; initial-value: 16px; }
@property --label-size  { syntax: '<length>'; inherits: true; initial-value: 12px; }
@property --lede-size   { syntax: '<length>'; inherits: true; initial-value: 32px; }
@property --title-size  { syntax: '<length>'; inherits: true; initial-value: 45px; }
@property --h-size      { syntax: '<length>'; inherits: true; initial-value: 22px; }
@property --cs-pad      { syntax: '<length>'; inherits: true; initial-value: 88px; }
@property --down-line   { syntax: '<length>'; inherits: true; initial-value: 28px; }
:root {
    --ramp: calc((100vw - 375px) / 905);

    --body-size:  clamp(14px, calc(14px + 2 * var(--ramp)), 16px);    /* the article's and about page's text */
    --label-size: clamp(10px, calc(10px + 2 * var(--ramp)), 12px);    /* the 12px mono caps; 10 and under stay */
    --lede-size:  clamp(22px, calc(22px + 10 * var(--ramp)), 32px);   /* lead-ins, about statements, the stats' figures */
    --title-size: clamp(30px, calc(30px + 15 * var(--ramp)), 45px);   /* a case study's title */
    --h-size:     clamp(18px, calc(18px + 4 * var(--ramp)), 22px);    /* a finding's heading */
    --stat-d-size: clamp(12px, calc(12px + 1 * var(--ramp)), 13px);   /* a stat's one-line description */
    /* The card's and the carousel's paper above and below the strip — .wp-blk and .cs-fig--lead. */
    --lead-pad:   clamp(12px, calc(12px + 12 * var(--ramp)), 24px);

    /* THE NARROW CHROME, which only exists under 64rem: the top bar holding the name and the menu
       button, and the case study's strip holding its current section and the close button. */
    --bar-h: 56px;
    --strip-h: 60px;   /* 48 until Eoin's phone: the close disc sat 7px off the strip's line */
}

/* ---- FOCUS ---------------------------------------------------------------------------------
   The only blue on this site was Chrome's default focus ring, rgb(0,95,204), because nothing here
   had ever styled focus. It was also the hardest colour to find: it paints on demand rather than
   at rest, so every pixel sweep of the page missed it, and a focused card BEHIND the open reader
   still draws its ring through the document at --behind-opacity.
   :focus-visible, not :focus — a mouse click on a card should not leave a ring behind, and that
   is exactly the case that made the old one look like a bug rather than an affordance. */
:where(a, button, [tabindex]):focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 2px;
    border-radius: 2px;
}

.shell { display: grid; grid-template-columns: 1fr; min-height: 100dvh; }

/* ---- Left menu -------------------------------------------------------------------------
   Three bands: name at the top, menu centred in whatever is left, contact at the bottom.
   grid-template-rows: auto 1fr auto is what makes the middle band take the slack — the
   menu then centres inside it, so it stays optically central no matter how tall the name
   or the contact block grows. */
.menu {
    display: grid;
    grid-template-rows: auto 1fr auto;
    padding: 40px 64px;
    /* FROSTED, NOT TINTED (Eoin, 2026-09-15). It was white at 50% over a 4px blur, and a dark screen in the
       row — Gallery Mode's — read straight through it as the row scrolled past. Now the page's own colour at
       78% over a 20px blur: what passes behind is a soft change of tone, not a picture. The page's colour
       rather than white, so the menu is not a whiter strip down the page. A bigger radius costs nothing
       extra here: a backdrop blur's cost is whether it is there at all, not how far it reaches (see the
       filter rule in CLAUDE.md). */
    background: rgba(254, 253, 251, 0.78);
    /* -webkit- prefix first: Safari still needs it, and without it the menu there would be
       78% paper over whatever is behind, with no blur at all. */
    -webkit-backdrop-filter: blur(20px);
    backdrop-filter: blur(20px);
}

/* ---- Name ---- */
/* RESERVES THE WIDEST PHRASE. The menu is auto-width and site.js publishes that width as
   --menu-w for the row's left padding to clear — so a name that grew from "cat dad" to
   "product designer" would widen the menu and shove the entire row sideways every ten seconds.
   `ch` is exact here because Departure Mono is monospaced: 16ch is sixteen characters, the
   length of the longest phrase. Same problem the menu-nav ghost already solves for the nav. */
.menu-name-text { display: inline-block; min-width: 16ch; }

/* THE NARROW SCREEN'S WRAPPERS HAVE NO BOX HERE (2026-09-15). .menu-bar holds the name and the menu
   button, .menu-body the nav and the contact links; on a wide screen both are display: contents, so the
   name, the nav and the contact block are still this grid's three rows, and the button is not drawn.
   The narrow menu is further down — see THE MENU ON A NARROW SCREEN. */
.menu-bar, .menu-body { display: contents; }
.menu-toggle { display: none; }

.menu-name {
    font-family: var(--font-label);
    font-weight: var(--weight-label);
    font-size: 14px;
    line-height: 1.2;
    color: var(--black);
    text-decoration: none;
}

/* ---- Nav ---- */
.menu-nav {
    display: grid;
    gap: 16px;
    align-content: center;
    justify-items: start;
}

/* THE CURRENT ITEM GROWS BY SCALE, NOT BY FONT SIZE. Every item is laid out at 20px and the
   current one is scaled 1.4x (= 28px) about its left edge. Animating font-size would do the same
   job with a reflow per frame and letters that shimmy as they step through pixel sizes; a scale
   is one composited property and moves as a single piece.
   A scale does not push neighbours apart the way a bigger font did, so the current item also
   takes margin above and below — (grow - 1) x half its line-height — which lands every item
   exactly where the 28px version put it. Measured against the old menu: same tops, same gaps. */
.menu-nav { --nav-grow: 1.4; }
.menu-nav a {
    position: relative;            /* for the bold copy */
    font-family: var(--font-display);
    font-weight: 300;              /* Averia Light */
    font-size: 20px;
    line-height: 1.1;
    color: var(--black);
    text-decoration: none;
    transform-origin: left center;
}

/* Each item reserves the width of its BOLD form, whether or not it is current. The bold copy
   below is wider than the light word, and without this the link's box would be the light
   word's width, leaving the end of the bold one outside it. 20px rather than the 28px it used to
   be: the size now comes from the scale, which never changes layout, so the menu cannot widen
   on selection either way. The ghost is zero-height and hidden, so it contributes width and
   nothing else. */
.menu-nav a::before {
    content: attr(data-nav);
    display: block;
    height: 0;
    overflow: hidden;
    visibility: hidden;
    font-weight: 700;
    /* The link's own size, inherited rather than restated (2026-09-15): the narrow menu sets its links
       larger, and a ghost still at 20px would reserve a bold word half the width of the real one. */
    font-size: inherit;
}

/* THE BOLD COPY, for the crossfade. Averia ships static weights — 300, 400, 700 — so light cannot
   become bold by degrees; asked to, the browser jumps between faces halfway. Instead each item
   carries its bold form on top, invisible until current, and the light word fades out as the
   bold one fades in over the same scale. `/ ""` gives the generated text an empty accessible
   name, or a screen reader would announce every item twice. */
.menu-nav a::after {
    content: attr(data-nav) / "";
    position: absolute;
    top: 0;
    left: 0;
    font-weight: 700;              /* Averia Bold */
    color: var(--black);
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
}

/* The current item. aria-current is the source of truth — a screen reader gets the state
   from the same attribute the stylesheet reads, so the two cannot disagree. */
.menu-nav a[aria-current="page"] {
    scale: var(--nav-grow);
    margin-block: calc((var(--nav-grow) - 1) * 0.55em);
    color: transparent;
}
.menu-nav a[aria-current="page"]::after { opacity: 1; }

/* Only once the first render has painted — site.js sets data-ready two frames in. Without the
   gate the current item would visibly grow as the page opens, since the stylesheet sizes every
   item before the router has marked one current. Both items animate at once, because both
   attribute changes land in the same frame: the new one grows as the old one shrinks. */
.menu-nav[data-ready] a {
    transition: scale 300ms cubic-bezier(0.23, 1, 0.32, 1),
                margin-block 300ms cubic-bezier(0.23, 1, 0.32, 1),
                color 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.menu-nav[data-ready] a::after { transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); }
@media (prefers-reduced-motion: reduce) {
    .menu-nav[data-ready] a,
    .menu-nav[data-ready] a::after { transition: none; }
}

/* ---- Contact ---- */
.menu-contact {
    display: grid;
    gap: 16px;
    justify-items: start;
}
.menu-contact a,
.menu-contact .copy {
    font-family: var(--font-label);
    font-weight: var(--weight-label);
    font-size: 14px;
    line-height: 1.2;
    color: var(--black);
    text-decoration: none;
}

/* ---- Email copies ------------------------------------------------------------------------------
   Three words in one place: Email at rest, Copy under the pointer, and what happened after a click
   (site.js owns the clipboard write and the reset; these states are pure CSS, driven by .is-copied).

   THE CHANGING WORDS ARE PINNED ON TOP OF THE RESTING ONE, and that is the whole trick here. A shared
   grid cell — the way the holding page did it — makes the box as wide as the widest word, and this menu
   is auto-width: site.js publishes that width as --menu-w for the work row to clear, so a wider word
   would shove the entire row sideways for as long as it showed. Absolutely positioned, the button stays
   exactly "Email" wide whatever it happens to be saying.

   THE OUTGOING WORD CLEARS FIRST. A straight crossfade in one place shows both sets of letterforms
   through each other halfway: the one leaving goes over 180ms with no delay, the one arriving takes
   240ms starting at 150 — they overlap for 30ms, at opacities too low to read. Linear, not the site's
   ease-out: on pure opacity there is no distance for a curve to shape, and a strong ease-out bunches the
   whole change against the start, which is what "snappy" was. */
.menu-contact .copy { position: relative; display: block; text-align: left; cursor: pointer;
    background: none; border: 0; padding: 0; }
.copy .swap { display: block; position: relative; }
.copy .rest { opacity: 1; transition: opacity 240ms linear 150ms; }
.copy .hover,
.copy .done { position: absolute; left: 0; top: 0; white-space: nowrap;
    opacity: 0; transition: opacity 180ms linear; }
@media (hover: hover) and (pointer: fine) {
    .copy:hover .rest,  .copy:focus-visible .rest  { opacity: 0; transition: opacity 180ms linear 0ms; }
    .copy:hover .hover, .copy:focus-visible .hover { opacity: 1; transition: opacity 240ms linear 150ms; }
}
/* Below the hover rules AND equal to them in specificity — ordering alone would not hold. */
.copy.is-copied .rest,
.copy.is-copied .hover { opacity: 0; transition: opacity 180ms linear 0ms; }
.copy.is-copied .done { opacity: 1; transition: opacity 240ms linear 150ms; }
@media (prefers-reduced-motion: reduce) {
    .copy .rest, .copy .hover, .copy .done { transition: opacity 150ms ease; }
}

/* Announced, not shown: the word swap is the only feedback this has, and it is invisible to a
   screen reader. site.js writes into it. */
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

/* ---- Panel ------------------------------------------------------------------------------
   A row of A4 pages. */
.panel {
    display: flex;
    /* CENTRED. Bottom-alignment was what gave the objects a shared baseline back when they were
       different heights; now every card is exactly --card-h tall, so they share one whether the
       row is bottom-aligned or centred, and the only thing the choice decides is where the row
       sits in the viewport. There is still NO drawn shelf line — a rule under objects that
       already share an edge states the same thing twice. */
    align-items: center;
    min-height: 100dvh;
    /* At 80vh tall the five sheets are far wider than any screen, so the row scrolls. */
    overflow-x: auto;
    /* The loop resets scrollLeft by a whole set width at the seam. Chrome's scroll anchoring
       would try to compensate for that as though content had changed size, and fight it. */
    overflow-anchor: none;
    /* No scrollbar. On an endless row it is also a lie — the thumb would report a position in
       a strip that has no ends. The row is still scrollable by wheel, trackpad and keyboard. */
    scrollbar-width: none;
}

.panel::-webkit-scrollbar { display: none; }

/* The padding lives HERE, not on .panel. A scroll container's trailing inline padding is
   unreliably honoured once its flex content overflows — putting it on the row itself
   guarantees the gutter at both ends. */
/* THE GAP HAD TO OUTRUN THE CENTRE-SCALE (removed 2026-09-15; the gap stayed). The gap is layout; the growth below is visual, so a
   page at 1.12 spills roughly 6% of its own width into the space on each side and the gap is
   what is left over. At 56px a 477px case study still showed 28px of air, but a 16/9 website
   887px wide spilled 53px a side and the gap closed to nothing — which read as the gap having
   been deleted rather than eaten.

   And RELATIVE, not a fixed 80px. The pages are sized in vh, so on a large screen they grow and
   a fixed gap does not — measured at 2560x1440 the tightest visible gap fell to 10px while it
   was a comfortable 33px at 1600x1000. A gap that separates things sized in vh has to be in vh
   too. Clamped at both ends so it neither collapses on a laptop nor becomes a corridor. */
/* OPTICALLY CENTRED, not box centred. Centring the card leaves its FRAME sitting high, because
   the caption hangs beneath it — measured at 263px of air above the frame and 367px below while
   the card itself was exactly 263/263. Padding the top by the text's height shifts the content
   down by half of it, which puts the frame on the centre line where the eye expects it. */
.pages { display: flex; align-items: flex-end; gap: 160px;
         padding-inline: 80px 160px; padding-block-start: var(--text-h); }

/* ---- NO CENTRE SCALE (Eoin, 2026-09-15) ------------------------------------------------
   The centred card used to grow to 1.22 as it crossed the middle of the row: a scroll-driven
   animation (view(inline) timeline, animating the standalone `scale` so it composed with Motion's
   transform), with its origin on the frame's centre line so the frame stayed centred as it grew.
   Removed on Eoin's call — every card now keeps its own size — and with it the first card's
   measured spill under the menu (--lead-spill in site.js). Recoverable from 8f2887d. */

/* 64rem, up from 60 (2026-09-15): below it is the narrow screen, where the menu is a bar and a sheet —
   see THE NARROW SCREEN at the end of this file, which shares the number. */
@media (min-width: 64rem) {
    /* The menu LIFTS OUT of the layout and floats over the panel, so the row of pages runs
       full-bleed underneath it and slides behind the frosted glass as it scrolls. Sticky
       would keep it in the flow and the pages would stop at its edge — leaving nothing
       behind it to blur. */
    .menu {
        position: fixed;
        left: 0;
        top: 0;
        height: 100dvh;
        z-index: 2;
        /* (The frosted edge stays a clean line. Three softenings were tried on 2026-09-15 — the glass's own
           last 40px faded, then 6px, then a soft strip of glass added past the edge — and Eoin rejected all
           three.) */
    }

    /* The first page clears the menu; everything after it passes underneath. --menu-w is
       measured in site.js, because the column is auto-width and a floating element's width
       is not knowable to the layout beneath it — it has to be handed over explicitly.
       The first card's leftward growth under the centre-scale was added here too until that scale
       went (2026-09-15); with no growth there is nothing to pay for. */
    .pages {
        padding-left: calc(var(--menu-w, 340px) + 80px);
        padding-right: 80px;
    }
}


/* ==========================================================================================
   THE ROW'S CARDS — one layout, three densities

   Every card is now the same object: framed screens, then an overline, a title, and either the
   result as figures or a single line. What differs between the kinds is HOW MANY frames and
   what shape they are — three for a case study, two for a website, one square for an
   experiment — plus the card's width.

   Chosen over six other arrangements in prototypes/case-cover.html.

   GEIST IS SCOPED TO THE ROW. The menu, the reader and every other title on the site still run
   Averia Serif Libre. Adopting Geist site-wide is a separate and much larger decision.
   ========================================================================================== */
.work-page {
    position: relative; flex: 0 0 auto;
    width: var(--wp-w);
    /* ONE HEIGHT FOR EVERY CARD. The frames' fixed ratios were what made the heights differ —
       three stacked 16/9s are simply taller than one square at the same width. So the card sets
       the height and the frames fill whatever the words leave, rather than the other way round.
       The cost, stated plainly: a frame is no longer exactly 16/9 or exactly 1:1. Its shape now
       follows from the card's width and how many frames share the space. Keeping the exact
       ratios instead would mean deriving each card's WIDTH from a shared height, which puts the
       experiment at ~460px and makes it the widest card in the row. */
    height: var(--card-h); aspect-ratio: auto;
    background: var(--page);
    /* No shadow. The card is a container for the frames and the words, not a sheet of paper —
       the frames carry their own ring. */
    box-shadow: none;
    border: 0; padding: 0; cursor: pointer; text-align: left;
    /* THE FRAME'S ROW IS EXACTLY --frame-h, the words take what is left (Eoin, 2026-09-15: "I want
       them lined up"). It was `1fr auto`: the words' row sized to each card's own text, so a
       one-line website title left its frame's row 28px taller than a two-line case study's — the
       frames still lined up, having a fixed ratio, but the words hung 28px lower under them.
       Fixed at --frame-h, every card's words start at the same height, right under the picture. */
    display: grid; grid-template-rows: var(--frame-h) minmax(0, 1fr); gap: 0;
    /* NO overflow: hidden. The frames are full-bleed to the card's edge, so clipping the card
       cut off their ring and both depth layers on every side — the shadow was computing
       correctly and simply never being painted. Worth remembering that a right computed value
       and a visible result are two different checks. */
    container-type: inline-size;
    font-family: var(--font-geist);
}
/* ONE FRAME PER CARD, AND ITS RATIO SETS THE WIDTH. Every card is the same height, so a wider
   ratio can only express itself horizontally — which is what gives the row its rhythm now that
   kind is no longer carried by frame count. --ar is written per card in home.html so the mix can
   be tuned one item at a time.

   The same custom property serves both jobs: aspect-ratio takes `16/9` as a ratio, and calc()
   reads the identical token as a division. */
.work-page {
    --frame-h: calc(var(--card-h) - var(--text-h));
    --wp-w: calc(var(--frame-h) * var(--ar));
}

/* ---- Frames -----------------------------------------------------------------------------
   Each block is a grey mat holding a screen, and the padding is what makes the mat visible —
   it shows on the sides the padding is on and nowhere else, so a set of frames chains into one
   shape rather than reading as separate boxes.

   min-width: 0 IS LOAD-BEARING. A grid item's default min-width is auto, so it refuses to
   shrink below its content — and the content is an 1800px screenshot. Without it the columns
   blow out and the aspect-ratio drags the height with them. The prototype could never show
   this, because its placeholders had no intrinsic size. */
.wp-comp { display: grid; gap: 16px; min-width: 0; min-height: 0; }
/* The hero frame takes more of the height than the pair beneath it — an even split makes the
   three read as a grid rather than as one image with two supporting it. */
.wp-comp { grid-template-rows: 1fr; }
.wp-comp { grid-template-columns: 1fr; }

.wp-blk {
    display: block; background: var(--frame); aspect-ratio: var(--ar);
    min-width: 0; min-height: 0;
    box-shadow: var(--shadow-border); transition: box-shadow 150ms ease-out;
    /* The card's mat, 12 on a phone to 24 from 1280 — the same token as the document's carousel. */
    --pt: var(--lead-pad); --pr: var(--lead-pad); --pb: var(--lead-pad); --pl: var(--lead-pad);
    padding: var(--pt) var(--pr) var(--pb) var(--pl);
}
.work-page:hover .wp-blk { box-shadow: var(--shadow-border-hover); }
/* THE CASE STUDY'S FRAME WIDENS — about 2%, out from its centre, while the screens inside do not
   change at all: same shape, same size, same spacing, same place. The frame takes
   `scale: 1.02 1` and the reel's TRACK takes the inverse about the card's centre line, so
   everything on it lands exactly where it was and the wider frame simply shows a little more of
   the strip at each edge. Stretching the lot would have been one rule and would have distorted the
   screenshots, which nothing else on this site ever does. (A lift was tried first and rejected on
   the eye, 2026-09-13.)
   THE TRACK, NOT EACH SCREEN. Counter-scaling every screen was tried first and kept their shapes,
   but it gave every step a `scale` — and a transformed element paints above untransformed
   siblings. At rest only the popped step is transformed, which is the ONLY reason it paints over
   its neighbours. With all of them transformed, DOM order took over and the right-hand neighbour
   painted across the step being shown (25 of 100 sampled frames). A z-index on the popped step
   cut that to 4 but could not cover the handover, when no step is marked and the outgoing one is
   still large. One scale on the track leaves the steps' stacking exactly as it is at rest.
   THE ORIGIN IS 50cqw. The track is left-anchored and wider than the reel, so its own 50% is not
   the reel's centre, and scaling about it would slide the strip sideways. The card is the nearest
   container and the reel runs the card's full width, so 50cqw IS the centre line.
   `scale`, the standalone property: the reel slides the track with `transform`, and separate
   properties compose where a second `transform` would clobber it. The inverse is interpolated
   linearly rather than as a true reciprocal — under 0.01% off mid-transition, below a pixel.
   Hover only where a real pointer hovers, so a tap on a phone does not leave a card widened.
   Keyboard focus gets the same. Under reduced motion nothing grows; the ring still darkens. */
.work-page:is([data-kind="case"], [data-kind="site"]) { --widen: 1.02; }
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk { transition: box-shadow 150ms ease-out, scale 200ms ease-out; }
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk > .wp-img,
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk .cs-reel-track { transition: scale 200ms ease-out; }
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk .cs-reel-track { transform-origin: 50cqw 50%; }
@media (hover: hover) and (pointer: fine) {
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk { scale: var(--widen) 1; }
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk > .wp-img,
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk .cs-reel-track { scale: calc(1 / var(--widen)) 1; }
}
.work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk { scale: var(--widen) 1; }
.work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk > .wp-img,
.work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk .cs-reel-track { scale: calc(1 / var(--widen)) 1; }
@media (prefers-reduced-motion: reduce) {
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk,
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk > .wp-img,
    .work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk .cs-reel-track,
    .work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk,
    .work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk > .wp-img,
    .work-page:is([data-kind="case"], [data-kind="site"]):focus-visible .wp-blk .cs-reel-track { scale: none; }
}

/* NO PRESS ON THE CARD. A 98% press scale was built and rejected on the eye, 2026-09-13: pressed
   while hovered, the frame widening outward and the card shrinking inward read as two movements
   pulling against each other. The widening is the card's only pointer feedback. (The close button
   keeps its press — it has no competing hover movement of the same kind.) */

/* NO SURFACE OF ITS OWN ANY MORE (Eoin, 2026-09-15). The card carried images/mat-grain.webp — a
   green-yellow grain — to match the carousel, while the reel was the one figure in the document with
   its own material. Both went back to the cream that is everywhere else: the card to --frame like
   every other card in the row, the carousel to the paper every other mat is (see .cs-fig--lead).
   The class stays, empty, so the markup does not need rewriting; mat-grain.webp stays on disk. */
.wp-blk--mat { }

/* THE CASE STUDY CARDS ARE MATTED LIKE THE CASE STUDY'S OWN PICTURES (Eoin, 2026-09-15): the same
   procedural paper, feathered at its edge, and no ring. The paper is the document's rule, shared by
   selector (.cs-frame::before) rather than copied, so the card and the page it opens cannot drift
   apart — this block only supplies what that rule reads from its frame: the grain's strength, the
   feather's width, the isolation that keeps the multiply inside the mat, and content lifted above the
   paper. The ring also carried the hover — it darkened — so a case card's only pointer feedback is
   now the widening. Experiments keep the flat cream and the ring.
   WEBSITES ARE THIS CARD TOO since 2026-09-15 (Eoin): paper, carousel, overline, title and the same
   widening — so every case-card rule reads :is([data-kind="case"], [data-kind="site"]). What differs
   is what a click does: a website is a link to itself, see .work-page[href]::after. */
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk { background: none; box-shadow: none;
    position: relative; isolation: isolate; --frame-grain: 0.62; --mat-feather: 8px; }
.work-page:is([data-kind="case"], [data-kind="site"]):hover .wp-blk { box-shadow: none; }
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk > * { position: relative; z-index: 1; }
/* FULL BLEED LEFT AND RIGHT, matching what the reel's figure does inside the document: a step
   enters and leaves at the block's own edge rather than at an inset, so the strip reads as
   something passing through the card instead of an animation playing inside a picture.
   Top and bottom keep their mat — those edges are still a frame, and losing all four would take
   the halftone with them. */
.wp-blk--bleed { --pl: 0px; --pr: 0px; }
/* The reel fills the block with no override here: .cs-reel fills whatever mat it is in, and the
   document's lead figure now takes this card's 16:9 — see .cs-fig--lead .cs-frame. */

/* With a single frame there is no neighbour to step against, so the mat sits on all four sides
   and the derived-border rule simply gives the image a line all round. */

/* WHERE THE PADDING IS ZERO THE IMAGE GETS NO LINE ON THAT SIDE — with no padding its edge sits
   exactly on the frame's edge and the two hairlines stack into one double-weight line, which
   reads as a mistake precisely where the composition is meant to look deliberate.
   min(1px, padding) derives it arithmetically, so the rule holds for whatever paddings these
   blocks are given rather than needing a case written per block per kind. */
/* WHITE FILL, not a screenshot. It is a <span> rather than an <img> now, so object-fit and
   object-position have nothing to act on and have gone with it — the derived border and the
   sizing are all that still matter here. */
.wp-img {
    display: block; width: 100%; height: 100%;
    background: var(--page);
    border-style: solid; border-color: var(--img-line);
    border-width: min(1px, var(--pt)) min(1px, var(--pr)) min(1px, var(--pb)) min(1px, var(--pl));
}

/* ---- The words --------------------------------------------------------------------------
   No left/right padding: the frames are full-bleed, so the words align to the card's own edge
   rather than sitting on an inset the composition does not share. */
/* align-self: start, so the strip is its own text's height rather than stretched to the row:
   measureText reads offsetHeight to set --text-h, and a stretched strip would feed back the row
   height it was given and never shrink when a title stops wrapping. */
.wp-text { display: block; padding: 24px 0; align-self: start; }
.wp-kick, .wp-stat-k { display: block; font-family: var(--font-geist-mono); font-size: var(--wp-kick-size);
    text-transform: uppercase; letter-spacing: 0.04em; }
.wp-title { display: block; margin-top: var(--wp-title-gap);
    /* Averia, not Geist — the title rejoins the face that carries every other title on the
       site, so the card stops being typographically foreign to the menu beside it. */
    font-family: var(--font-display); font-weight: 300; font-size: var(--wp-title-size); line-height: 1.25;
    /* The reader's masthead treatment, brought back to the card so the cover and the page it
       opens are the same object seen twice. Same measure cap as .cs-title.
       NOT the same tracking value. .cs-title sets -0.07em, and the note there says why: it is a
       40px size, where Averia's default spacing is drawn for text and reads loose. At 18px that
       default is already about right, and -0.07em would collide the letters. The tracking is
       matched in KIND — tightened, because it is display type — not in number. */
    letter-spacing: -0.025em; max-width: 26ch;
    /* NO BLUR. It was 0.3px of ink bleed and it came out with every other text blur — soft
       type at 46px reads as a rendering fault, not as print. */ }

/* ---- The cover's metrics -----------------------------------------------------------------
   Only a card carrying .wp-stats changes shape; every other card keeps its plain stack.
   Two columns: the words take what is left, the metrics take what they need and end on the
   picture's right edge. The card is exactly as wide as its picture, so the text block's own right
   edge IS that line and nothing has to be measured.
   The metrics span the kick's row and the title's row, and each stat repeats that rhythm — a
   label set exactly like the kick, then a number with the title's margin and line-height — so the
   labels share the kick's line and the numbers sit on the title's first line. */
.wp-text:has(.wp-stats) {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 48px; }
.wp-text:has(.wp-stats) .wp-kick,
.wp-text:has(.wp-stats) .wp-title { grid-column: 1; }
/* Wider than the 26ch cap, which left half the card empty and the title on three lines — but not
   the whole column either: uncapped, the title ran most of the card's width on one line and read
   as a sentence rather than a title. BALANCED, so the two lines come out nearly equal instead of
   one long line and a short tail.
   EVERY CASE STUDY CARD, not only one carrying metrics. It was keyed to .wp-stats, so Gallery Mode
   — a case study with no numbers — fell back to 26ch and broke onto three lines beside WCF's two
   (2026-09-14). The reason for the cap is the card's size and the title being a sentence, and
   neither depends on whether metrics sit next to it. */
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-title { max-width: 44ch; text-wrap: balance; }
.wp-stats { grid-column: 2; grid-row: 1 / span 2; align-self: start;
    display: flex; gap: clamp(24px, calc(24px + 8 * var(--ramp)), 32px); }
.wp-stat { display: grid; justify-items: end; }
.wp-stat-v { display: flex; align-items: center; gap: 0.14em; margin-top: var(--wp-title-gap);
    font-family: var(--font-geist); font-weight: 400; font-size: var(--wp-title-size); line-height: 1.25;
    font-variant-numeric: tabular-nums; }


/* A website LEAVES rather than opens, and that has to be readable before the click (Eoin, 2026-09-15).
   ON THE TITLE'S LINE, RIGHT-ALIGNED. The words become two columns, as a case card's do for its
   metrics, and the arrow is the second column of the title's row. It was a boxed ↗ chip in the
   frame's top corner, shown on hover only; moved into the words and redrawn the same day.
   FAINT AT REST, FULL ON HOVER, so a card says it leaves before it is touched and answers when it is.
   DRAWN, NOT A GLYPH: the same 16-unit arrow as .cs-ar — 1.6 stroke, butt ends, a mitred head —
   turned to point up and right, so it is one family with the ↓16% / ↑7% on World Creation Flow's
   card. A mask, so it takes the text's colour. Keyed to [href]: a website still waiting for its
   address shows none. The card is an <a> since the same day, so it gives up the link colour and
   underline it would otherwise take. */
a.work-page { color: inherit; text-decoration: none; }
.work-page[href] .wp-text { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 24px; }
.work-page[href] .wp-kick,
.work-page[href] .wp-title { grid-column: 1; }
.work-page[href] .wp-text::after {
    content: ""; grid-column: 2; grid-row: 2; align-self: start;
    /* 0.74em of the title's size, as .cs-ar is of its figure, and centred on the title's first
       line: the title's margin, then half of what its 1.25 line leaves around the arrow.
       THE BOX IS CUT AT THE HEAD. In the full 16-unit square the head ends 2.9 units short of the
       right side, which left the ink 2px inside the words' right edge (measured, row-sites); the
       viewBox and the width stop at 13.1, so the ink ends on the edge at the same drawn size. */
    font-size: var(--wp-title-size); height: 0.74em; width: calc(0.74em * 13.1 / 16);
    margin-top: calc(var(--wp-title-gap) + (1.25em - 0.74em) / 2);
    --leave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13.1 16' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='butt' stroke-linejoin='miter'%3E%3Cpath d='M4.2 11.8 12.3 3.7'/%3E%3Cpath d='M6.2 3.7h6.1v6.1'/%3E%3C/svg%3E");
    background: currentColor;
    -webkit-mask: var(--leave) center / contain no-repeat;
            mask: var(--leave) center / contain no-repeat;
    opacity: 0.35; transition: opacity 150ms ease-out, rotate 250ms cubic-bezier(0.23, 1, 0.32, 1);
    /* TURNED ABOUT A POINT CHOSEN SO THE TURN LANDS ON THE LINES. About the ink's own centre (63% 48%) the
       right-pointing arrow ended 2px past the words' right edge (row-sites) — it is longer lying flat than
       standing on its diagonal, and its mitred tip reaches further. About this point the flat arrow's tip
       ends on that edge and stays centred on the title's line: solved for both, in the viewBox's units. */
    transform-origin: 55.3% 33.3%;
}
/* ON HOVER IT TURNS TO POINT RIGHT as it darkens (Eoin, 2026-09-15): up-and-away becomes onward. A real
   pointer only, like the fill; a touch screen keeps the arrow as it is. */
@media (hover: hover) and (pointer: fine) { .work-page[href]:hover .wp-text::after { opacity: 1; rotate: 45deg; } }
.work-page[href]:focus-visible .wp-text::after { opacity: 1; rotate: 45deg; }
@media (prefers-reduced-motion: reduce) { .work-page[href] .wp-text::after { transition: none; } }

/* The reader lifts this; it is never drawn on the card. */
.wp-doc { display: none; }

/* ---- Three size scales, to be compared rather than argued about -------------------------
   Set with ?s=1|2|3. 2 is the default. The row's own centre-scale multiplies whichever is on.

   THE WEBSITE IS CAPPED, and it needs to be for a reason that is not obvious: it is sized by
   HEIGHT like everything else, but at 16/9 its width is 1.78x that height, so a website was
   coming out 887px wide against a case study's 477px — nearly twice the area of the thing it
   is meant to sit below in the hierarchy.

   The min() against vw is the second half of the cap. vh alone is unbounded in the direction
   that matters: on a tall screen 48vh is a bigger number, and every pixel of it is multiplied
   by 1.78 on the way to the width. 22vw holds the derived width under about 40vw whatever the
   window is doing. */
:root, body[data-scale="2"] { --wp-case: 62vh; --wp-site: min(34vh, 22vw); --wp-exp: 34vh; }
body[data-scale="1"] { --wp-case: 70vh; --wp-site: min(30vh, 20vw); --wp-exp: 26vh; }
body[data-scale="3"] { --wp-case: 58vh; --wp-site: min(38vh, 24vw); --wp-exp: 42vh; }

/* ---- Views -----------------------------------------------------------------------------
   Shown and hidden by the hash router. [hidden] alone is not enough for a flex/grid child —
   the display property in the rules below would win over it. */
.view[hidden] { display: none !important; }
/* ---- About ------------------------------------------------------------------------------------
   Two tabbed views: statements on the left, a narrow column of tables beside them (Eoin's layout,
   2026-09-15). Nothing here is a new style: the tabs are the case study's switch tabs, the labels
   its 12px caps, the statements its lead-in, the rows its hairline, and every gap a count of the
   article's 28px line (the --down-* tokens, which are page-wide). A highlight reel of photos sat
   under Locations for a few hours and was taken out on Eoin's call.
   The folder that used to sit here is out of the markup; its rules further down are inert.

   --down-text is declared here, as 66ch of Geist at 16px (a registered <length>, see THE ARTICLE),
   for the side column where it stacks under the statements. The statements take 66ch of their own
   face instead — see .ab-say. */
.view-about { display: block; min-height: 100dvh;
    font-family: var(--font-geist); font-size: var(--body-size); --down-text: 66ch;
    --ab-side: clamp(260px, 22vw, 340px);
    /* between every column and every block on the page — see .ab-panel. 44 on a phone, 88 from 1280
       (THE FLUID SCALE). */
    --ab-gap: clamp(44px, calc(44px + 44 * var(--ramp)), 88px);
    padding: var(--cs-pad) 24px var(--down-section); }
/* The menu floats over the left edge from 64rem up, as it does over the work row — the page clears
   it by the same measured width. Below that the menu is in the flow and there is nothing to clear. */
/* THE TABS START LEVEL WITH "eoin bolger" (Eoin, 2026-09-15). The menu's own top padding is 40px
   (.menu); this is ONE LESS because the letters, not the boxes, have to agree. With both boxes at
   40, Geist Mono's capitals started 1px below Departure Mono's ascenders at 1x and 0.5px below at
   2x (measured by ink in the harness's about-page probe). At 39 they meet at 1x and sit within half
   a device pixel at 2x. */
@media (min-width: 64rem) {
    /* THE TABS STAY 12px WHEREVER THE LEFT MENU IS (2026-09-15). Their letters are lined up with the
       name's, which does not scale, so a tab sliding with THE FLUID SCALE sat 1px low at 1180 (about-page
       probe). Under 64rem there is no name beside them and they take --label-size. */
    .view-about .ab-tabs .ab-tab { font-size: 12px; }   /* one class up: the tabs' own rule comes later */
    .view-about { padding-top: 39px;
        padding-left: calc(var(--menu-w, 340px) + 80px); padding-right: var(--cs-pad); }
}

/* THE TABS are the case study's switch tabs (.cs-flow-tab), at the 12px the article's labels use,
   with two lines of air before the page begins. No rule under the row: the design drew one and
   Eoin took it out (2026-09-15) — the active tab's own underline already marks the row. */
.ab-tabs { display: flex; gap: var(--down-para); margin-bottom: var(--ab-gap); }
.ab-tabs .ab-tab { font-size: var(--label-size); }

/* Statements left, the side column right; the side column hugs the right edge on a wide window,
   as the design has it, and the statements keep their 66ch however wide the window is.
   ONE GAP FOR EVERY COLUMN ON THE PAGE: --ab-gap, 88px (Eoin, later on 2026-09-15; it was two
   lines, 56px, and before that four) — between the tabs and the first statement, between statements, the two sides, between the three
   disciplines, above them, and between the side column's sections (Eoin, 2026-09-15; the sides and
   the disciplines' top had been four lines). A pattern of one number rather than two. */
.ab-panel { display: grid; grid-template-columns: minmax(0, 1fr) var(--ab-side);
    column-gap: var(--ab-gap); align-items: start; }
.ab-panel[hidden] { display: none; }

/* The statements ARE the article's lead-in: the serif at 32 on a line and a half.
   66ch OF THEIR OWN FACE, not the body's (Eoin, 2026-09-15). It was --down-text, 66ch of Geist at
   16px — about 695px, which set a 32px serif in a narrow stack well short of the design's line.
   Written on the element, ch resolves in Averia at 32px; on a window too narrow for that, the
   column stops it first. */
.ab-say { max-width: 66ch; font-family: var(--font-display); font-weight: 300;
    font-size: var(--lede-size); line-height: calc(var(--down-line) * 1.5); letter-spacing: -0.02em; }
/* Statement to statement is the one exception to --ab-gap: 48px (Eoin, 2026-09-15, from a screenshot
   of the three) — at 88 the statements read as three separate blocks rather than one run of them.
   24 on a phone, halved with the rest. */
.ab-say + .ab-say { margin-top: clamp(24px, calc(24px + 24 * var(--ramp)), 48px); }

/* Labels: the article's 12px caps, attached to what follows by half a line. */
.ab .ab-kick { font-size: var(--label-size); font-weight: 400; line-height: 1.5; margin-bottom: var(--down-half); }
/* Two lines between the side column's sections, not four (Eoin, 2026-09-15): at four, "How Eoin
   thinks about design" sat so far under the timeline it read as a separate page. */
.ab-side { display: grid; row-gap: var(--ab-gap); align-content: start; }

/* ---- Lab and writing: not built yet ------------------------------------------------------------
   One line, "Coming soon.", centred (Eoin, 2026-09-15), in the about page's statement type — the
   serif at 32 on a line and a half — so an unbuilt page still reads as the same site. Centred in
   what the menu leaves rather than in the whole window: from 64rem the menu floats over the left
   edge, and centred on the window the line would sit visibly right of the page's middle. */
.view-soon { display: grid; place-items: center; min-height: 100dvh; box-sizing: border-box;
    padding: var(--cs-pad) 24px; }
@media (min-width: 64rem) {
    .view-soon { padding-left: var(--menu-w, 340px); padding-right: 0; }
}
.soon { margin: 0; font-family: var(--font-display); font-weight: 300;
    font-size: var(--lede-size); line-height: calc(var(--down-line) * 1.5); letter-spacing: -0.02em; }

/* THE TABLES: year and entry, a hairline under every row and above the first, and one between the
   year and its entry, as the design draws. A row is two lines tall unless its entry wraps. */
.ab-table { border-top: 1px solid var(--img-line); }
.ab-row { display: grid; grid-template-columns: 64px minmax(0, 1fr); min-height: var(--down-pair);
    border-bottom: 1px solid var(--img-line); }
.ab-row dt { display: flex; align-items: center; font-family: var(--font-geist-mono); font-size: var(--label-size);
    font-variant-numeric: tabular-nums; }
.ab-row dd { display: flex; align-items: center; padding: var(--down-half) 0 var(--down-half) var(--down-half);
    border-left: 1px solid var(--img-line); font-size: 14px; line-height: 1.5; }

/* THE LISTS — the three disciplines and the quotes — a hairline under each item. */
.ab-list { list-style: none; }
/* A LIST ROW IS A TABLE ROW: 56px tall unless it wraps, text centred in it — the same two-line pitch
   as every gap between blocks, columns and rows on this page (Eoin, 2026-09-15). It was 14px of
   padding around the text, which made list rows ~50px beside the tables' 56.
   The min-height sets the pitch and the half-line padding keeps a wrapped row from crowding its
   rules: at 7px of padding a four-line quote sat nearly on its hairlines (seen at 1920). One-line
   rows are still exactly 56. */
.ab-list li { display: flex; align-items: center; min-height: var(--down-pair); padding-block: var(--down-half);
    border-bottom: 1px solid var(--img-line); font-size: 14px; line-height: 1.5; }
.ab-list--quotes { border-top: 1px solid var(--img-line); }
/* The disciplines sit under the statements, two lines down, three across the left column. */
.ab-skills { margin-top: var(--ab-gap); display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: var(--ab-gap); }

/* NARROWER WINDOWS: the side column drops under the statements, and the three disciplines fold to
   as many as fit. */
@media (max-width: 80rem) {
    .ab-panel { grid-template-columns: minmax(0, 1fr); row-gap: var(--ab-gap); }
    .ab-side { max-width: var(--down-text); }
}
@media (max-width: 63.99rem) {
    .ab-skills { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); row-gap: var(--ab-gap); }
}

.folder {
    flex: 0 0 auto;
    height: var(--about-folder-h);
    width: auto;
    aspect-ratio: 648 / 893;
    position: relative;
    background: none;
    border: 0;
    padding: 0;
    display: block;
    cursor: pointer;
    container-type: inline-size;
    /* <button> brings text-align: center with it, which is why the label read centred and
       broke onto two lines. */
    text-align: left;
    /* Square to the frame. The rotation was my idea, not the design's. */

    /* How far the sheet travels to clear the sleeve. The sheet is 96.8% of the folder's
       width, so it needs 100/96.8 ≈ 103.3% of its OWN width to be fully outside. Declared
       once because the close animation has to land on the same value from JS. */
    --sheet-out: 104%;
}

/* ---- Back panel ------------------------------------------------------------------------
   border-radius rather than clip-path, so box-shadow survives — CSS applies filter before
   clip, and a clipped element's shadow is cut back to its own silhouette and vanishes.

   Four corners, two radii: small on the folded left edge, large on the cut right. Given as
   percentages with separate horizontal and vertical components so they stay circular at the
   folder's ratio rather than stretching into ellipses. */
.folder-back {
    position: absolute;
    inset: 0;
    background: var(--folder-back-c);
    border-radius: 0.82% 2.45% 2.45% 0.82% / 0.59% 1.77% 1.77% 0.59%;
    box-shadow: var(--cast);
}

/* ---- The sheet, seen only through the cut ---------------------------------------------- */
.folder-sheet {
    position: absolute;
    /* Placed ENTIRELY by transform — see fitInSleeve() in site.js. There is one thing deciding
       where the page sits, rather than a CSS offset and a transform that have to agree. */
    left: 0;
    top: 0;
    /* A4, DERIVED: site.js sets a width in pixels and the height follows, so the paper cannot
       come out any other shape. The width is the READING width at all times — the page is laid
       out once, at the size it is read at, and scaled DOWN to fit the sleeve. That is what lets
       the type below be real pixel sizes; a page laid out small and scaled up can only carry
       proportional units. Fallback width for the script never running. */
    width: 94%;
    aspect-ratio: 210 / 297;
    background: var(--sheet-white);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.10);
    z-index: 1;
    container-type: inline-size;
    overflow: hidden;
    /* NO will-change: transform, and this is why the text was arriving blurry.

       will-change promotes the element to its own compositor layer, and a composited layer is
       rasterised ONCE at one scale and then stretched by the GPU for the rest of the
       animation. The sheet is a 505px-wide layout drawn at 760px, so the whole flight was a
       bitmap of the small layout blown up 1.5x — soft type — right up until the layout was
       committed at rest, at which point it snapped sharp. That snap is the "sharpens".

       Without the hint, the transform changes are ordinary main-thread style updates and
       Chrome repaints at the live scale each frame, so the text is rasterised at whatever size
       it is currently being drawn. Sharp throughout. It costs a repaint per frame, which for
       one element on an otherwise static page is not a real cost.

       (A stale `transition: transform` also lived here — dead, since a later rule overrode it,
       but it directly contradicted Motion owning these transforms. Gone.) */
}

/* ---- Front panel -----------------------------------------------------------------------
   Opaque, flat, and offset from the back on the RIGHT only. The left edge is the fold —
   front and back are one piece of polypropylene bent over, so they cannot be out of register
   there; top and bottom are guillotined after folding, both leaves at once. The right is the
   only edge cut independently, which is what makes the die-cut possible.

   TWO elements, and the split is forced by the CSS filter order. Filters run BEFORE masks on
   the same element, so a mask would clip the panel's own drop-shadow away. The shadow
   therefore lives on a parent, which renders the masked panel first and then casts from the
   finished silhouette.

   The previous version instead put the shadow on a black copy of the panel underneath. That
   is what was drawing the stepped grey line, and it was a compositing artefact rather than
   anything to do with the mask: along the edge the panel's mask is PARTLY transparent, so at
   every antialiased pixel the black copy showed through at the complementary alpha. A near-
   white panel over black gives a hard dark fringe; the reason it vanished when the panel was
   turned red is that red over black is a far smaller step, not that the line had gone. With
   the copy deleted there is nothing dark left underneath to leak. */
/* THE 3D CONTEXT, AND NOTHING ELSE IN IT.

   perspective was on .folder, and CSS perspective establishes a 3D rendering context for EVERY
   child — not just the one being rotated. So the sticker was composited and resampled even
   though its own transform is 2D, which is why moving it out of the flap did not sharpen it:
   the property responsible lived on their shared parent.

   Measured: edge energy in the sticker's type fell to 72% of its resting value on hover, with
   none of the documented causes present — no will-change, no translateZ, and Motion driving it
   on the main thread frame by frame, which is the case Chrome does re-rasterise.

   This wrapper covers exactly the same box as .folder, which matters for more than tidiness:
   perspective-origin defaults to the centre of whatever carries the perspective, and
   stickerFollow() in site.js reproduces that projection by hand. Same box, same origin, same
   maths. The lens is still STATIC and must never be animated — that was the first-hover jump.

   3000 rather than 1200: a shorter lens tapers the flap more, and the sticker can only follow a
   taper by SHEARING, which turns off Chrome's subpixel antialiasing for its type. Lengthening
   the lens shrinks the taper at source so an axis-aligned follow is close enough. LENS_PX in
   site.js must match this. */
.folder-flap {
    position: absolute;
    inset: 0;
    z-index: 3;
    perspective: 3000px;
    pointer-events: none;
}

/* What actually turns. Separate from the element carrying the perspective, because perspective
   applies to an element's CHILDREN — you cannot rotate the thing providing it. Splitting them
   also makes room for a layer that has to sit outside the shadow caster. */
.folder-leaf {
    position: absolute;
    inset: 0;
    transform-origin: 0 50%;
}

/* THE DIFFUSION IS ON THE SHEET, NOT THE COVER.

   The obvious way to soften what is behind translucent paper is backdrop-filter on the cover.
   It does not work here. Measured: the layer paints (a red test fill showed through) and the
   computed value really is blur(12px), but the content behind is untouched — while the same
   markup in an isolated page blurs correctly. Something in this subtree makes it a backdrop
   root and I could not find it by bisecting perspective, containment, masks, overflow or the
   3D transform, each of which I ruled out by measurement.

   So the sheet is blurred directly instead. It gives the same read — the work showing through,
   softened — and it works everywhere with nothing to discover.

   It was supposed to cost nothing at the die-cut, on the reasoning that the notch exposes a
   blank right margin of the page and there is no detail there to lose. That was an assumption
   about where the text ends, and it was wrong: .sheet-plate and all three .sheet-body rows run
   to 817.4px while the notch begins at 800px, so they reach 17.4px into it. The strip the cut
   exists to reveal was being blurred. It is a masked twin now — see .sheet-diffused below.

   filter: none, never blur(0). A zero blur still promotes the element to a composited layer,
   and that is what softened the reading page's type once already. */
/* The panel's shadow. It lives on this wrapper rather than on the panel because filters run
   BEFORE masks on the same element, so a masked panel's own drop-shadow is cut back to its
   silhouette and vanishes. The wrapper renders the masked panel first, then casts from the
   finished shape.

   The filter is a variable so the dial can switch it off. It went missing entirely during the
   tracing-paper work — one of my edits consumed this rule while inserting the flap wrapper, and
   the dial then appeared to do nothing because there was no shadow either way. */
.folder-cover-cast {
    position: absolute;
    top: 0;
    right: 0.6%;
    bottom: 0;
    left: 0;
    z-index: 3;
    /* The site shadow. filter: drop-shadow() has no spread parameter, so layer two's +1px
       spread is carried as +1px of blur — the nearest expression of the same value. */
    filter: var(--cover-cast-filter,
                drop-shadow(0 1px 2px rgba(0, 0, 0, 0.30))
                drop-shadow(0 1px 4px rgba(0, 0, 0, 0.15)));
    pointer-events: none;
}

.folder-cover {
    position: absolute;
    inset: 0;
    background: var(--cover-color);
    opacity: var(--cover-opacity);
    -webkit-mask-image: var(--folder-mask);
            mask-image: var(--folder-mask);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
}

/* The grain, over the tint and inside the same silhouette. feTurbulence as a data URI, the same
   technique the die-cut mask uses — no asset to load, and it works over file://, where an
   external image would not. baseFrequency is the dial: higher is finer. */
.folder-cover::after {
    content: "";
    position: absolute;
    inset: 0;
    opacity: var(--cover-texture);
    background-image: var(--cover-grain-image);
    background-size: 180px 180px;
    mix-blend-mode: multiply;
    pointer-events: none;
}

/* THE CUT EDGE, DRAWN AS A STROKE, at its own opacity.

   The cover's outline used to be defined entirely by the fill: where translucent grey met the
   background, that contrast WAS the edge. So turning the opacity down faded the outline along
   with it — and the panel's drop-shadow faded too, because a filter casts from the rendered
   subtree and a half-transparent shape casts a half-strength shadow. Fill and edge were the
   same pixels, so they could not be controlled apart.

   A stroked copy of the die-cut path separates them. It stays at full opacity however
   translucent the fill becomes, and because it sits inside the shadow caster it also gives the
   drop-shadow a solid silhouette to work from — so the edge shadow stops fading too.

   The viewBox is 1000x1378 and the folder is 648x893; those ratios agree to four decimal
   places, so preserveAspectRatio='none' scales the stroke evenly and the line does not thicken
   on one axis. The width is written from JS because it is a dial and CSS cannot interpolate a
   number into a data URI. */
.folder-outline {
    position: absolute;
    inset: 0;
    opacity: var(--edge-opacity);
    background-image: var(--edge-image);
    background-size: 100% 100%;
    background-repeat: no-repeat;
    pointer-events: none;
}

/* ---- The printed label -----------------------------------------------------------------
   A white sticker on the panel, not type set directly on the plastic. That is what the blue
   reference does too, and it is doing real work: a small bright rectangle gives the eye a
   true white to measure the panel's grey against, so the panel reads as a material rather
   than as a slightly-off background. */
/* OUTSIDE the flap, sitting over it, and that is what keeps its type sharp.

   rotateY forces the flap onto a compositor layer, and Chrome rasterises a 3D-transformed layer
   ONCE and then resamples that texture — so anything inside it goes soft the moment the flap
   lifts, and stays soft for as long as it is held. There is no quality setting for that; the
   only fix is not to be inside the rotation.

   So the flap rotates and the sticker does not. At 6 degrees about a fold 5.7% to its left, the
   sticker's own displacement is under a pixel, which is well inside what the eye forgives for
   something it never sees move on its own. Its position is unchanged: the flap it used to live
   in spans the whole folder bar 0.6% on the right, so the percentages resolve against very
   nearly the same box. */
.folder-label {
    position: absolute;
    left: 5.7%;
    top: 69.6%;
    width: 36%;
    height: 25.4%;
    background: #FFFFFF;
    padding: 2cqw;
    display: grid;
    align-content: space-between;
    z-index: 4;              /* over the flap, which is 3 */
    transform-origin: 0 0;   /* the matrix in site.js is written for this origin */
    pointer-events: none;
}
.folder-label-top { display: grid; gap: 1cqw; }
.folder-kicker { font-family: var(--font-label); font-size: 1.7cqw; color: var(--black); letter-spacing: 0.04em; }
/* nowrap so it can never silently re-wrap if the copy or the label width changes. */
.folder-title  { font-family: var(--font-label); font-size: 1.85cqw; color: var(--black); letter-spacing: 0.02em; white-space: nowrap; }
.folder-foot   { font-family: var(--font-label); font-size: 1.7cqw; color: var(--black); letter-spacing: 0.04em; }

/* ---- Open -------------------------------------------------------------------------------
   MOTION OWNS EVERY TRANSFORM HERE. There is deliberately no `transition: transform` on the
   leaf or the sheet, and no .is-open transform rule: a CSS transition on an element Motion is
   driving fights it frame by frame and clobbers in-flight offsets. Springs are also not
   expressible as a transition — they have no duration and no fixed endpoint time — so the two
   systems cannot be blended even in principle. See site.js.

   transform-origin stays in CSS because it is not animated: it is a property OF the hinge.
   Without it the origin defaults to 50% 50%, the leaf rotates about its own middle, and the
   fold visibly comes apart from the back panel. The two leaves are one piece of polypropylene
   bent over, so that edge is the one point on the object that cannot move. */
.folder-cover-cast { transform-origin: 0 50%; }

/* The sheet's shadow is still CSS — Motion is not animating it, so there is nothing to fight.
   It gains a WIDER, LIGHTER shadow on the way out rather than simply a bigger one: separating
   from the sleeve widens the penumbra and weakens the occlusion at the same time, because
   ambient light can finally reach underneath. "More shadow" is the usual tell of a fake
   elevation.

   And NO z-index. The sheet stays behind the front panel exactly as it does at rest, so the
   part still inside the sleeve is hidden and only the part that has physically emerged shows.
   Nothing needs raising: the sheet travels RIGHT, and everything right of the folder is
   outside the panel's silhouette already. */
/* THE SCALE IS ANCHORED TO THE TOP EDGE, not the centre, and that is what stops the dip.

   With a centred origin, growing the sheet moves its top edge up and its bottom edge down in
   equal measure — so on the way out the scale was pushing the body downward at the same time
   as the flight was carrying it up, and the two nearly cancelled. The result read as a dip:
   the sheet swelled downward more than it travelled upward.

   Anchored at the top, scale contributes NOTHING to vertical position. The top edge is driven
   by y alone, which moves monotonically upward, and the sheet unfolds downward beneath it. It
   also makes the resting transform a plain scale — no correction term — which is why restIn()
   in site.js is as short as it is. */
.folder-sheet {
    transform-origin: 0 0;
    transition: box-shadow 420ms ease;
}

/* THE DIFFUSION IS ON THE PRINTED CONTENT, NOT ON THE PAPER.

   Blurring .folder-sheet blurred the sheet's own EDGES along with its contents — and the
   sheet's right edge is exactly what the die-cut exposes, so the notch showed a soft white
   smear instead of a cut line. The paper is not what tracing paper diffuses; the printing is.

   .folder-sheet keeps overflow: hidden, so the blur cannot bleed past the page either. */

/* ...AND IT STOPS AT THE COVER'S EDGE.

   A filter on the content blurs ALL of the content, and the cover does not cover all of it:
   the die-cut deliberately exposes a strip of page with nothing in front of it. This rule used
   to carry the blur, and that strip was blurred along with everything else. Measured — toggling
   the blur moved 13.2% of the notch's pixels against 14.7% of the pixels under the cover — so
   the one place the design shows you the page directly was being diffused very nearly as hard
   as the place it is meant to be seen through paper.

   Drawing one region sharp and another blurred means drawing the content TWICE. No single
   element can do it; that is exactly the job backdrop-filter would have done, and it does not
   work on this page (see .folder-cover-cast above). So there are two layers:

     .sheet-inner     the real content, sharp,   masked to the INVERSE silhouette -> the notch
     .sheet-diffused  a twin,           blurred, masked to the silhouette         -> the cover

   They are exact complements, which is why NEITHER carries a background. Giving the blurred
   layer an opaque white ground would blur its own edges and bring back the white smear at the
   cut that this file already fixed once. The paper stays .folder-sheet's own, and stays crisp.

   The mask box is the cover's box in the SHEET's coordinates, written per folder by
   fitInSleeve() in site.js. It is measured there rather than restated here, so the mask and the
   panel it is tracking cannot drift apart. */
.sheet-inner {
    -webkit-mask-image: var(--folder-mask-inverse);
            mask-image: var(--folder-mask-inverse);
    -webkit-mask-size: var(--cover-box-w, 100%) var(--cover-box-h, 100%);
            mask-size: var(--cover-box-w, 100%) var(--cover-box-h, 100%);
    -webkit-mask-position: var(--cover-box-x, 0) var(--cover-box-y, 0);
            mask-position: var(--cover-box-x, 0) var(--cover-box-y, 0);
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
}
/* The twin keeps the .sheet-inner class, so it inherits the page's entire layout and the same
   mask box for free — only the silhouette and the blur differ. It is a RENDERING of the page,
   not a second copy of it: aria-hidden and inert in site.js, unselectable and untouchable
   here, so nothing can reach it by pointer, caret, tab or screen reader. */
.sheet-inner.sheet-diffused {
    position: absolute;
    inset: 0;
    pointer-events: none;
    -webkit-user-select: none;
            user-select: none;
    filter: var(--sheet-filter, none);
    transition: filter 420ms ease;
    -webkit-mask-image: var(--folder-mask);
            mask-image: var(--folder-mask);
}
/* Crisp the moment it is on its way out, and in the reader. */
/* THE DIFFUSION IS THE COVER'S, so it applies only while the page is UNDER the cover.

   Ideally this would be a backdrop-filter on the cover itself, diffusing whatever happens to be
   behind it and nothing else. That does not work on this page — measured: the layer paints and
   the computed value is right, but the content behind is untouched, while the same markup in an
   isolated page blurs correctly. Something above .folder makes it a backdrop root and bisecting
   perspective, containment, masks, overflow and the 3D transform did not find it.

   So it is driven by STATE instead, which is exact for every moment you can actually see:

     at rest in the sleeve        under the cover      diffused
     sliding out                  leaving the cover    crisp
     in the reader                nowhere near it      crisp
     flying home                  still out            crisp
     landed back in the sleeve    under it again       diffused

   The two selectors are the two "out" cases. is-open:not(.is-closing) covers the way out;
   in-reader covers the whole time it is away, including the flight home — which is what keeps
   the page crisp until the moment it is actually put back. */
.folder.is-open:not(.is-closing) .sheet-inner,
.folder-sheet.in-reader .sheet-inner { filter: none; }

/* Away from the folder there is no cover for the diffusion to stop at, so the page becomes one
   layer again: the real content drops its mask and the twin stops painting. Both land on the
   frame the sheet is genuinely clear — promote() adds .in-reader — by which point the blur has
   already animated to about 0.03px, so there is nothing left to see disappear.

   visibility, not display: the twin is absolutely positioned and reflows nothing either way,
   but visibility can be reversed mid-transition without the layout pass display would force.
   Dropping the mask here also removes the one place the complementary pair could be seen as a
   pair — where two masks with complementary alpha abut, the antialiased edge composites to
   slightly less than solid, and a hairline of paper shows through. In the sleeve that line sits
   at 0.49 scale underneath the drawn cut edge and is invisible; at reading size it would run
   the height of the page, so at reading size there is no seam because there is no pair. */
.folder-sheet.in-reader .sheet-inner {
    -webkit-mask-image: none;
            mask-image: none;
}
.folder-sheet.in-reader .sheet-diffused { visibility: hidden; }
/* The lifted shadow, for a page that has separated from the sleeve — wider AND lighter, not
   just bigger, because separating widens the penumbra and weakens the occlusion at the same
   time. "More shadow" is the usual tell of a fake elevation.

   :not(.is-closing) matters. On the way back in, the page returns to the folder while is-open
   is still set, so this shadow snapped on the instant it landed and then transitioned away when
   is-open finally came off — a heavy shadow appearing at the end of the animation and clearing
   a moment later. On the way home it wears its resting shadow the whole way. */
.folder.is-open:not(.is-closing) .folder-sheet {
    box-shadow:
        0 2px 4px rgba(0, 0, 0, 0.10),
        0 14px 34px rgba(0, 0, 0, 0.13);
}

/* ---- Open · beat 3, the reading page ------------------------------------------------------
   A fixed, scrollable overlay. It has to exist because a FIXED element cannot scroll — for the
   sheet to run below the fold the scroll must belong to a container and the sheet must sit
   inside it in normal flow. The sheet is moved in here on open and moved back on close.

   min(760px, 88vw) at the sheet's ratio lands around 1050px tall, which is already past the
   bottom of most windows. So the vertical scroll is real without inventing extra content —
   the same summary simply arrives at reading size. */
.reader {
    position: fixed;
    inset: 0;
    z-index: 20;
    overflow-y: auto;
    overflow-x: hidden;
    overscroll-behavior: contain;
    /* Reserve the scrollbar's width whether or not one is showing. .reader-inner is centred
       with auto margins, so a scrollbar appearing or disappearing moves it by half a scrollbar
       — which lands as a jump if it happens either side of a handover measurement. */
    scrollbar-gutter: stable;
    /* Chrome's scroll anchoring adjusts scrollTop when content above the anchor changes size —
       which is exactly what happens here, repeatedly, mid-flight. Left on, it moves the page
       under the animation. */
    overflow-anchor: none;
}
.reader[hidden] { display: none; }

/* =========================================================================================
   THE READER — a masthead, a constant box, and a rail down the right.

   Everything else that was tried here is gone: a bottom rail, a frame with no siblings, the
   neighbours cropped at the edges, the column that dropped the flight, and two other ways of
   aligning the title. This arrangement won on one measurable thing — across a case study, a
   website and an experiment the title sits at exactly the same point, where the first version
   moved it 240px horizontally and 65px vertically and made the eye hunt for it every cycle.

   TWO THINGS NEVER MOVE, and that is the whole design:
     the MASTHEAD  one element whose text changes; the name cannot move because it is the same
                   node. It is lifted out of the views, so the views must not print it too.
     the BOX       positioned, never laid out from its contents. If the frame were derived from
                   what is in it, it would breathe — and breathing is the thing being fixed.

   What is NOT settled is what goes inside, because a case study is far taller than an
   experiment and one constant frame cannot flatter both. Three answers, below.
   ========================================================================================= */

/* The box's geometry, in one place, because the masthead, the fade and the content all have to
   agree on it exactly. */
:root {
    --rail-w: 104px;
    --mast-h: 132px;
    --box-top: calc(var(--mast-h) + 4vh);
    --box-x:   5vw;
    --box-bot: 5vh;
    --doc-pad: 40px;
}

/* ---- The masthead ----------------------------------------------------------------------- */
.reader-mast {
    display: grid; align-content: center; gap: 10px;
    position: fixed; z-index: 4;
    top: 0; left: 0; right: var(--rail-w); height: var(--mast-h);
    padding: 0 var(--box-x);
    background: var(--page);
}
.reader-mast .mast-kick { font-family: var(--font-label); font-size: 11px; letter-spacing: 0.07em;
    opacity: 0.5; }
.reader-mast .mast-title { font-family: var(--font-display); font-weight: 300; font-size: 40px;
    line-height: 1.05; }
/* The name is in the masthead, so a view printing it again is the fix for "the title moves"
   producing two titles. */
.sheet-head-group, .alt-head { display: none; }

/* ---- The box ----------------------------------------------------------------------------
   .reader-inner IS the box. It is fixed rather than in flow so its size cannot be influenced
   by what kind is showing.

   margin-top is overridden !important because the flight writes an inline one — it is where
   the sheet LANDED, which is the right answer for a page in normal flow and the wrong answer
   for a fixed frame, where it would push the whole box down by the landing offset. The flight
   still measures and corrects itself onto the box; see placeAt().

   Written as `.reader > .reader-inner` rather than `.reader-inner`: the original rule for that
   element lives LATER in this file, and at equal specificity the later one wins — which is why
   the box first came out 70vw wide with no fixed height. Two classes beats one wherever it
   sits in the file. */
.reader > .reader-inner {
    position: fixed;
    top: var(--box-top); bottom: var(--box-bot);
    left: var(--box-x); right: calc(var(--rail-w) + var(--box-x));
    width: auto !important; height: auto !important;
    margin: 0 !important;
    overflow-y: auto; overflow-x: hidden;
    overscroll-behavior: contain;
    /* WHITE, not the grey it was. The grey was right when the box was an empty frame being
       judged as a frame; a case study set on grey is a document printed on the wrong stock. */
    background: var(--page);
}

/* ---- What fills the box -----------------------------------------------------------------
   A plain container: content sits at the top and long content scrolls inside it. Two other
   fills were built and dropped — one that faded its lower edge to admit the crop, and one
   where a website and an experiment filled the frame edge to edge with their image. The second
   read better for short kinds and was rejected anyway, because it reintroduced per-kind layout,
   which is the exact thing the constant box exists to remove.

   So the frame never changes and never comments on its contents. What varies is what is laid
   out INSIDE it, and that is the next piece of work rather than a property of the box. */
.reader-alt,
.reader-inner > .folder-sheet { margin: 0 auto; }
.reader-alt { width: 100%; min-height: 0; padding: 6vh var(--doc-pad); display: block; }

/* ---- The rail, down the right ------------------------------------------------------------
   Vertical rather than along the bottom: it runs WITH the reading direction and against the
   row's own axis, so it stops competing with the page, and it holds all sixteen without
   scrolling itself, which the bottom rail could not. */
.reader-rail {
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
    position: fixed; z-index: 3;
    top: 0; bottom: 0; right: 0; width: var(--rail-w);
    padding: 20px 12px; overflow-y: auto;
    background: rgba(255, 255, 255, 0.82);
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    box-shadow: -1px 0 0 rgba(0, 0, 0, 0.08);
}
.reader-rail button {
    flex: 0 0 auto; width: 54px; height: 42px; padding: 0; border: 0; cursor: pointer;
    background: var(--placeholder); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
    opacity: 0.5; transition: opacity 150ms ease-out;
}
.reader-rail button[data-kind="exp"] { width: 42px; height: 30px; }
/* Websites leave in a new tab since 2026-09-15 rather than open in the reader, so the rail — and
   the up/down steps, see showAt — pass over them. */
.reader-rail button[data-kind="site"] { display: none; }
.reader-rail button:hover { opacity: 0.85; }
.reader-rail button[data-here] { opacity: 1; box-shadow: 0 0 0 2px var(--black); }
.reader-rail img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }

/* ---- Arrows -----------------------------------------------------------------------------
   Up and down, stacked, against the rail. The rail runs top to bottom, so a left/right glyph
   beside it is the chrome contradicting the movement — and the eye believes the glyph. */
.ax-h { display: none; }
.reader-nav button {
    position: fixed; top: 50%; z-index: 4;
    width: 44px; height: 44px;
    left: auto; right: calc(var(--rail-w) + 18px);
    border: 0; background: var(--page); box-shadow: var(--page-shadow);
    font-family: var(--font-label); font-size: 14px; cursor: pointer;
    opacity: 0; transition: opacity 160ms ease-out;
}
.reader-open .reader-nav button { opacity: 1; }
.reader-nav [data-step="-1"] { margin-top: -52px; }
.reader-nav [data-step="1"]  { margin-top: 8px; }

/* ---- A website and an experiment do not get a document ---------------------------------
   A website has no inside to hold anything — its view is the still plus the link, and the link
   is the point. An experiment is a note, not a page. Giving all three kinds the same A4
   document is what made cycling between them feel wrong to begin with. */
.reader-alt .alt-plate { width: 100%; aspect-ratio: 16 / 10; object-fit: cover;
    object-position: top center; background: var(--placeholder);
    box-shadow: var(--page-shadow); }
.reader-alt .alt-link { display: inline-block; margin-top: 24px; font-family: var(--font-label);
    font-size: 11px; letter-spacing: 0.06em; color: var(--black); text-decoration: none;
    border-bottom: 1px solid rgba(0, 0, 0, 0.25); padding-bottom: 3px; }

@media (prefers-reduced-motion: reduce) {
    .reader-nav button { transition: none; }
}

/* During either flight the sheet is transformed well outside this box; without this the
   overlay grows scrollbars mid-animation. */
.reader.is-flying { overflow: hidden; }

.reader-inner {
    /* A plain frame over the viewport; the page's own transform places it. */
    position: relative;
    /* 70% of the viewport, as asked. No cap — I had capped it at 760px on my own judgement and
       that is why it was coming out at 40% of a wide screen.

       Consequence, because it is a real one: the type below is proportional (cqw), so at 70vw of
       a wide monitor the title is larger than the 40px it is calibrated for. Proportional is
       forced by the page being laid out small and scaled UP — a fixed px size would be wrong for
       the whole flight and jump when the layout commits at rest. Pinning the type at 40px means
       laying the page out at reading size instead, which is a bigger change. */
    width: 70vw;
    margin: 6vh auto 12vh;
}

/* The close control. It reads as printed ON the sheet, but it is a sibling rather than a
   child: .folder is a <button>, and a <button> inside a <button> is invalid and does not
   receive clicks. Living in the overlay keeps it out of that nesting entirely. */
/* A DISC, NOT A WORD. "CLOSE" was a fifth piece of type competing with the title, the overline,
   the contents bar and the captions, and it had to be read to be understood. A cross is understood
   before it is read.
   The disc is a true white on a white page, which only works because it carries the same
   --shadow-border the cards and thumbnails use — the ring is what separates it from the paper, so
   the shadow is doing structural work here rather than decorating. It also means the control keeps
   its edge over a figure's grey mat, which a borderless white circle would not.
   The mark itself is drawn as a mask rather than set as a "×" glyph, for the same reason the list
   arrows are: a glyph's weight is decided by the face, and this has to sit against a 1px ring. */
.reader-close {
    /* FIXED, NOT ABSOLUTE. .reader is itself a fixed, scrolling box, and an absolutely-positioned
       child of a scroll container scrolls away with the content — the same trap the document's
       edge glass and the thumbnail strip's both had to avoid. Fixed pins it to the viewport, and
       since .reader is inset: 0 the coordinates are identical either way. */
    position: fixed;
    top: 18px;
    right: 18px;
    z-index: 21;
    width: 34px; height: 34px; padding: 0; border: 0;
    display: grid; place-items: center;
    cursor: pointer;
    background: var(--page);
    border-radius: 50%;
    box-shadow: var(--shadow-border);
    /* Two properties, and only these two: the reader can be open over a document that is being
       scrolled sideways, and this sits on top of it. */
    transition: opacity 260ms ease 420ms, box-shadow 150ms ease-out, transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
    opacity: 0;
}
.reader-close::before {
    content: ""; width: 11px; height: 11px; background: var(--black); opacity: 0.72;
    --x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='butt'%3E%3Cpath d='M3.4 3.4l9.2 9.2'/%3E%3Cpath d='M12.6 3.4l-9.2 9.2'/%3E%3C/svg%3E");
    -webkit-mask-image: var(--x); mask-image: var(--x);
    -webkit-mask-size: contain; mask-size: contain;
    -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
/* EVERY HOVER HERE IS FOR A REAL POINTER ONLY — on a phone a tap sets :hover and leaves it set, so the
   grow, the darker ring and the full-strength mark all stuck (the ring and the mark were ungated until
   2026-09-15). */
@media (hover: hover) and (pointer: fine) {
    .reader-close:hover { box-shadow: var(--shadow-border-hover); transform: scale(1.06); }
    .reader-close:hover::before { opacity: 1; }
    /* PRESS: 0.97, the standard press. It was 0.96 on a plain ease-out, which a normal ~80ms click
       barely started before the reader hid on release; the strong curve on the transition above is what
       lets it read. :active:hover so a keyboard press does not animate. From the hovered 1.06 it passes
       back through rest size on the way down — about 3px on a 34px disc, a press you feel, not a lurch. */
    .reader-close:active:hover { transform: scale(0.97); }
}
.reader-open .reader-close { opacity: 1; }

/* The sheet, once it has been moved into the overlay.

   ITS LAYOUT BOX STAYS THE SIZE IT WAS IN THE SLEEVE — width and height are pinned in pixels
   from JS — and reading size is reached entirely by scale. Letting the box actually grow meant
   the content re-laid-out at the handover: every size on the sheet is in cqw against its own
   container, so a wider container re-resolves every type size, and text laid out at 45px then
   scaled down is not guaranteed to break identically to text laid out at 37px. Any difference
   lands exactly at the seam, which is where the jump kept appearing.

   Pinning the box means nothing re-lays-out at all. The sheet is laid out once, in the sleeve,
   and after that only ever transformed. .reader-inner is given the SCALED height inline so the
   overlay still scrolls to the right extent. */
/* The shadow is written from JS, divided by the current scale, so it draws the same size at
   every stage of the flight — see shadowAt() in site.js. Nothing here, or it would fight it. */
.folder-sheet.in-reader {
    position: absolute;
    top: 0;
    left: 0;
    z-index: auto;
    transition: none;
}

/* EVERYTHING ELSE RECEDES RATHER THAN LEAVING. It used to go to opacity 0, which is a cut —
   the page you came from stops existing and the reader has no context. Held at a low opacity
   and blurred, it stays there as depth of field: still legibly the row of pages, plainly not
   what you are reading.

   Faded rather than hidden either way, because display: none would lose the row's horizontal
   scroll position and closing would not put you back where you were.

   pointer-events: none regardless — visible is not the same as clickable, and clicking a page
   you can see behind a reader would be a trap. */
/* THE WAY OUT lives here — this is the rule in effect once both classes are gone, which is what
   the close transitions against.

   THE RETURN NOW RIDES THE FLIGHT HOME. closeIt() used to hold the sheet still for
   --behind-out and only then fly, so the background finished coming back before the page had
   moved at all. That has gone (2026-08-07): opacity and transform never rasterise, so they cost
   nothing while the sheet travels, and the recede now un-does itself over the same stretch of
   time the page uses to scale down and reposition. One movement instead of two.

   THE FILTER LINE IS INERT AND IS A TRAP FOR LATER. Nothing sets a filter on .menu/.panel any
   more — see the parked settle below — so it currently transitions nothing. It is kept because
   it becomes correct again the moment the blur comes back. But note what else has to come back
   with it: a filter transition here also runs when .reader-still is removed, and that removal
   is what starts the close, so the blur would animate out across the whole flight home. That is
   measured jitter, not a theory — 25.2ms median and 10 of 15 frames over 20ms, against 16.6ms
   without it. The wait in closeIt() was the only thing holding that off.

   So: restoring the blur means restoring the wait as well. One without the other is the closing
   jitter, back. */
.menu, .panel {
    transition: opacity var(--behind-out) ease,
                transform var(--behind-out) ease,
                filter var(--behind-out) ease;
}

/* THE RECEDE STARTS AT LIFT-OFF and costs nothing, because it is made of the two properties
   that never rasterise: the background steps back and dims as the page leaves the sleeve.

   The blur is NOT here, and that is the whole design. It cannot be — see below for the eight
   arrangements that were measured trying. What was wrong before was not the timing but the
   MATERIAL: a dim alone is too weak to read as movement, so the recede leaned on a blur that
   could not afford to be there. Scale carries it instead. A 4% step back is unmistakably
   motion, at zero cost, and it can start exactly when the page does. */
.reader-open .menu,
.reader-open .panel {
    opacity: var(--behind-opacity);
    transform: scale(var(--behind-scale));
    pointer-events: none;
    transition: opacity var(--behind-in) ease, transform var(--behind-in) ease;
}

/* THE BLUR HAPPENS ONLY ON A STILL PAGE. Eight arrangements were measured trying to avoid that
   and every one failed; the list is below so that a ninth does not get attempted from scratch.

   WHAT FINALLY WORKED was not a ninth arrangement — it was giving up on the blur as the thing
   that carries the recede. The blur was never the effect; "the background goes away" is the
   effect, and scale says that at zero cost. The blur is now a finish applied once the page is
   at rest, not the movement itself. Read the .reader-open rule above first; this block is the
   record of why the blur is not in it.

   THE REAL LESSON, and the one that generalises: the cost here is not the blur primitive. It is
   that THIS background is heavy — five A4 pages and a menu. The control run proves it. Chrome's
   documented fix for animated blur (developer.chrome.com/blog/animated-blur) is to duplicate the
   content, blur the copies, promote the PARENT wrapper and cross-fade opacity. Run properly —
   the parent promoted, the blurred children not — it still measured 27-28/29. Then the same
   duplicated DOM was run with the blur radius set to ZERO: still 26-27/29. The duplication alone
   is the entire cost. The technique is sound; this page simply cannot afford a second copy of
   its own background, so no amount of cleverness with filters was ever going to help.

   (An earlier note here claimed the crossfade was tested and failed. It had been run with
   will-change on the blurred COPIES, which the article names as the slow configuration. That
   was a mis-run test, and the record said "settled" when it was not. Re-run correctly above.)

   Measured across the flight window, opening, against the slide as a control:

     slide (control)                16.7ms median,  0/25 frames over 20ms
     flight, blur on the background  22.1ms median, 15/29 over 20ms, 5 over 33ms
     flight, no background blur      16.7ms median,  0/29 over 20ms

   Half the frames of the flight missed their deadline, and removing this one filter was the
   only thing that fixed it. Not the diffused twin (removing it changed nothing), not the
   per-frame box-shadow in paint() (16/29 with it gone), not the transition — a STATIC blur was
   just as bad on the way out — and not missing layer promotion (will-change gave 14/29). Nor
   does it scale with the radius: 1px still dropped 10/29. Any filter on this subtree costs,
   because the sheet keeps moving in front of it and the uncovered region is re-blurred every
   frame. That is also why a static blur fixed CLOSING but not opening: on the way home the blur
   is already gone before the sheet starts moving.

   RE-TESTED 2026-08-06, because "the blur cannot ride the flight" is the single constraint this
   whole arrangement is built around and it deserved better than one session's word. The failure
   reproduces in the current build (14-24 of 29 frames over 20ms, p90 45-53ms, against 0-3 and
   ~17ms shipped), and three untried layer arrangements were measured against it:

     will-change: transform   10-15/29    (the retry of will-change: filter — same result)
     contain: paint           14-18/29
     static blurred twin,     10-29/29    the .sheet-diffused pattern, opacity-only cross-fade,
     opacity-only                         and the WORST of the three

   Applying the blur at the CLICK — static and at full radius before the flight starts — is the
   worst of the content-filter attempts. It does not dodge the cost, it doubles it: the SLIDE now
   pays too (25-61ms median against 16.7 baseline) and the flight still drops 19-25/29.

   backdrop-filter on the OVERLAY was the last hope and is the most expensive thing measured
   here: 27-29 of 29 over 20ms, median 35-44ms, p90 70-99ms. Worth recording that it DOES sample
   — 92.6% sharpness drop, probe-backdrop.js — because an older note in memory claimed it does
   not, and that note was written against a DOM this page no longer has. It samples fine. It is
   simply slower than filtering the content.

   The full scoreboard, all against a no-blur baseline of 0-3 of 29 frames over 20ms:

     scale + dim, no blur      0-5/29   <- SHIPPED. costs nothing, and starts at lift-off
     blur riding the flight   14-18/29
     contain: paint           14-18/29
     promote the SHEET        19-23/29   the moving element, not the blurred one. still worse.
     blur applied at click    19-25/29   the slide pays too
     promoted-parent copies   27-28/29   Chrome's own fix; the duplication is what costs
     downscale 4x -> blur     27-28/29   same duplication cost, same result
     backdrop-filter          27-29/29
     blur masked to margins   28-29/29   the mask costs more than the pixels it saves

   Note the shape of it: everything that ADDS a layer or a mask is worse than the naive filter.
   The only winning move was to stop asking for a blur while anything moves. */
/* PARKED 2026-08-07. The settle used to live here: once the page was at rest — the only moment a
   filter on this subtree is free — the background picked up blur(--behind-blur) over
   --behind-blur-in. It is gone for now, so the recede is scale and dim and nothing else, start to
   finish.

   Not a performance decision. The settle measured fine; it ran on a still page and cost nothing.
   It came off because the blur was arriving as a SECOND event, half a second after the page had
   already landed and finished receding, and reading as a separate thing happening rather than as
   the end of the first thing.

   The declaration is deleted rather than set to a zero radius on purpose: filter: blur(0) is still
   a filter, and a filter makes its subtree a backdrop root — a real side effect for a no-op.

   The rule is kept, empty of it, because it is the anchor for the record above and because
   --behind-blur / --behind-blur-in are still live tokens on the dial. Restoring is one line. */
.reader-still .menu,
.reader-still .panel {
    transition: opacity var(--behind-in) ease,
                transform var(--behind-in) ease;
}

/* ---- The printed page ---- */
/* Wide right margin: the die-cut exposes the last ~9% of the sheet, and that strip has to be
   blank paper. Anything narrower and stray letters appear in the notch. */
/* EVERY SIZE IS IN cqw, deliberately, even though these are meant to be real px sizes.

   The page is laid out at sleeve size and scaled UP to reading size, so a px value would be
   wrong for the whole flight and then jump when the real layout is committed at rest. cqw is a
   fraction of the page's own width, so it scales with it and there is nothing to correct.

   The numbers are calibrated against the 760px cap above: 5.263cqw of 760 is 40px, 2.105cqw is
   16px. So at full size the page really is 40px padding, a 40px title and 16px body — and
   below the cap it scales down proportionally, which is what a page should do. */
/* REAL PIXEL SIZES, which is only possible because the page is laid out at reading size and
   scaled DOWN into the folder. Laid out small and scaled up, a px value would be wrong for the
   whole flight and only correct at the very end. */
.sheet-inner { padding: 40px; display: grid; gap: 32px; height: 100%; align-content: start; }

/* The head, title and lead share a column sized to the TITLE: width: max-content makes the
   column as wide as its widest child, the title is nowrap so it is that child, and the lead is
   width: 0 so it contributes nothing to the measurement before min-width: 100% fills it. The
   body can never run wider than the title, and it stays true if the copy changes. */
.sheet-head-group { display: grid; gap: 16px; width: max-content; max-width: 100%; }
.sheet-head  { font-family: var(--font-label); font-size: 10px; letter-spacing: 0.18em; color: #8A8A8A; }
.sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 40px; line-height: 1.1; color: #111; white-space: nowrap; }
.sheet-lead  { font-family: var(--font-body); font-size: 16px; line-height: 1.55; color: #666; width: 0; min-width: 100%; }

.sheet-plate { background: #EBEBEB; aspect-ratio: 16 / 10; }
.sheet-row { display: grid; grid-template-columns: 1fr 3fr; gap: 24px; align-items: start; }
.sheet-key { font-family: var(--font-label); font-size: 10px; letter-spacing: 0.14em; color: #999; padding-top: 3px; }
.sheet-body { font-family: var(--font-body); font-size: 16px; line-height: 1.55; color: #444; }
.sheet-foot { margin-top: auto; text-align: center; font-family: var(--font-label); font-size: 10px; color: #AAA; }

@media (prefers-reduced-motion: reduce) {
    /* No hinge, no slide, no zoom — the reader simply appears with the sheet already at
       reading size. The JS reads the same media query and skips the FLIP, so the two halves
       cannot disagree about whether the sequence ran. */
    /* The stateful selectors have to be listed too. They carry their own transition shorthand
       and score (0,2,0) against this block's (0,1,0), so a bare `.menu` rule here loses to them
       and the recede would keep its full 1.4s under reduced motion. Equal specificity and a
       later position is what actually wins — not obvious, and previously wrong for
       .reader-still. */
    .folder-sheet,
    .menu,
    .panel,
    .reader-open .menu,
    .reader-open .panel,
    .reader-still .menu,
    .reader-still .panel { transition-duration: 1ms; }
    .reader-close { transition-delay: 0ms; }
    /* No hover grow and no press on the close: both are movement. The ring still darkens.
       Listed with their full selectors, and placed after them, so equal specificity lets these win. */
    .reader-close:hover,
    .reader-close:active:hover { transform: none; }
}


/* =========================================================================================
   THE CASE STUDY READER — a full-screen takeover that reads sideways

   Replaces the masthead-and-box reader FOR CASE STUDIES ONLY. Websites and experiments still
   open the old one, so the site carries two reader designs at once until they are designed —
   deliberate and temporary. Everything here is gated on [data-doc="case"] on .reader, which
   site.js sets, and the old chrome is hidden by the same switch rather than removed.

   88px of frame on every side, and the document runs right rather than down: a header, a
   section indicator, then a horizontal scroll of narrow text columns against tall figures.
   ========================================================================================= */
/* 320 is the narrowest width at which the stacked Context/Relevance column fits the band at
   1080 — re-measured after the title grew to 46px, which took 38px out of the band and left 300
   overflowing by 17. Wider columns wrap LESS, so a column gets SHORTER as it gets wider, which
   is the opposite of the intuition: the fix for a column running past the bottom is usually to
   widen it, not to cut copy.

   These two are coupled — the title's size and the column's width both trade against the same
   band. If the title grows again, re-sweep this. The header's first column derives from
   --cs-col, so the thumbnail row and the first image stay aligned through any of it. */
/* ---- The band's measurements ------------------------------------------------------------------
   Every gap and size the document's layout depends on, in one place. Ported from
   prototypes/case-layout.html, where they were tuned against the real content with a dial panel
   rather than picked. */
:root {
    /* the page margin, and the band's own left and right air. 20 on a phone, 88 from 1280 — see THE
       FLUID SCALE. Under 64rem it is also the gutter of every page. */
    --cs-pad: clamp(20px, calc(20px + 68 * var(--ramp)), 88px);
    /* 440px, NOT 320. The old token comes from the vertical layout, where a column was one of
       three across a fixed frame and could not be wider without taking room from something else.
       A band has no such ceiling — a wider column costs only horizontal length, and the band is
       already long. 320 measured about 37 characters, under the 45-75 that reads comfortably, so
       lines broke constantly and the eye kept returning to the left margin. 440 lands near 50. */
    --cs-col: 440px;
    /* The lead-in is deliberately a WIDER measure than the columns it introduces: one sentence
       read at a glance, not a passage read line by line. */
    --lede-col: 560px;

    /* THE THREE GAP TIERS. A passage and its own picture sit close; one finding is a long way from
       the next; a section boundary is longer still. The band reads as structured rather than as a
       queue because these are far apart in value, not because anything is drawn between them. */
    --gap-pair: 64px;        /* a passage and its own figure */
    --gap-item: 180px;       /* one finding to the next */
    --gap-section: 280px;    /* Summary to Problems to Solution */

    --sum-gap-x: 88px;       /* across the summary block's columns */
    --sum-gap-y: 64px;       /* down, between Context and Relevance */
    --img-max-w: 700px;
    /* ONE HEIGHT FOR EVERY PICTURE, stated rather than derived from the band. Deriving it from the
       band's height sounds tidier and is worse: the band's height then depends on its tallest
       child, which depends on the height, and the loop resolves differently depending on which
       figure happens to be widest. A number breaks the circle. */
    /* AND IT ANSWERS TO THE WINDOW. A fixed 374 was right at 950px tall and wrong below it: the
       figure is the picture plus 128px of mat plus a caption, so on an 800px window the band was
       492px tall holding 532px figures and the document scrolled DOWN as well as across — a
       document going two ways at once, which is the exact class of bug the vertical layout did not
       have and that came back with the axis.
       The subtrahend is the chrome above and around a figure — the reader's top padding, the
       masthead, the mat, the caption — measured rather than guessed, with a margin. 374 stays the
       ceiling because past it the pictures start dominating a band that is meant to be read.
       The extra air under the contents bar beyond the original 32px is subtracted too, so widening
       that gap shortens the pictures on a laptop-height window instead of pushing captions off it. */
    /* 394, down from 560, 2026-09-14: the title left the masthead for the band and took its 166px
       with it (measured: label + title 136, plus the 30px gap to the bar). The 374 ceiling is kept on
       purpose — Eoin chose to let laptops reach full size rather than grow pictures on big screens. */
    --img-h: min(374px, calc(100vh - 394px - (var(--bar-gap) - var(--band-pad))));
    /* 3px, not 8. A radius is read relative to the thing it is on, and at band scale 8 turns a
       screenshot into a card. Tokenised so the four derived corners below cannot drift apart. */
    --img-radius: 3px;
    /* ---- The entrance -----------------------------------------------------------------
       The band's objects rise and fade in from the left when a case study opens. Three
       numbers, here rather than buried in site.js, so the feel is tunable in one place —
       Motion runs the animation itself; these only describe it. */
    --enter-rise: 14px;      /* how far each object travels */
    --enter-step: 50ms;      /* between one object and the next */
    --enter-dur: 420ms;
    --focus: color-mix(in srgb, var(--black) 55%, transparent);
    --reel-pop: 1.8;         /* how much the active carousel step grows; site.js reads this */
    /* The mat around a picture — see --fp-* on .cs-frame. 16 on a phone, 64 from 1280 (THE FLUID
       SCALE): at 64 a side, a phone's screenshot came out 71px wide inside its own paper. */
    --mat-pad: clamp(16px, calc(16px + 48 * var(--ramp)), 64px);
    --band-pad: 32px;        /* below the band's content */
    /* The air between the contents bar and the band. Split from --band-pad 2026-09-13, when Eoin
       asked for more space there and it doubled from 32. Every px added here is taken back out of
       --img-h, or the band outgrows the window. In an article it is the air above the title; 28 on
       a phone, 64 from 1280. */
    --bar-gap: clamp(28px, calc(28px + 36 * var(--ramp)), 64px);
}

.reader[data-doc="case"] {
    background: var(--page);
    /* NO SIDE PADDING ON THE READER. The band supplies its own, so that a figure passing the left
       or right edge of the screen genuinely leaves the screen instead of stopping 88px short of
       it. Padding here would put a dead margin either side of a document whose whole point is
       that it continues past both. Top only. */
    padding: var(--cs-pad) 0 0;
    /* The takeover owns the whole viewport and does not scroll itself — the band inside it does,
       sideways. */
    overflow: hidden;
}

/* NO EDGE TREATMENT AT ALL, and this is the third position on it. There were two backdrop-filter
   panels on the left and right, from when the document ran sideways; those were replaced by one
   at the foot when it turned vertical; and now there is none. Softening the bottom edge turned out
   to be solving a problem the padding was causing — content faded out ABOVE a dead 88px band, so
   the page looked like it ended twice. With the frame's bottom removed the document simply runs
   off the screen, which is what a vertical scroll should look like and needs no help to say. */

/* TWO ROWS: the masthead, then the band. This has been all three of one, three and two rows, and
   the count follows the axis rather than taste. A vertical document could put the header inside
   the scroller so the title scrolled away and only the bar stayed. A horizontal one cannot: the
   scroller travels SIDEWAYS, so anything inside it leaves to the left and never comes back, and
   there is no sticky equivalent for a masthead in a band. Header and bar go back above, where they
   are permanently on screen because the document moves in the other direction.
   minmax(0, 1fr) on the band, so a long document scrolls inside its container rather than
   expanding it. */
.cs-doc {
    height: 100%; min-height: 0; min-width: 0;
    display: grid; grid-template-rows: auto minmax(0, 1fr);
}
/* The old reader's furniture has no place here. Hidden rather than deleted, because a website
   and an experiment still use every one of these. */
.reader[data-doc="case"] .reader-mast,
.reader[data-doc="case"] .reader-rail,
.reader[data-doc="case"] .reader-nav,
.reader[data-doc="case"] > .reader-inner { display: none !important; }
.reader[data-doc="case"] .reader-close { top: var(--cs-pad); right: var(--cs-pad); }

/* ---- Header: the name of the thing ------------------------------------------------------- */
/* A block, not a grid. It was two columns — the title on the left, the thumbnail strip on the
   right, the first column derived from --cs-col so the strip started where the first image did.
   With the strip gone there is one thing in here, and a one-column grid is just a block. */
/* ABOVE the band now, not inside it, and carrying the side margin the reader gave up so the title
   still starts on the page's left line while the band beneath it runs edge to edge. */
.cs-head { display: block; min-width: 0; padding-inline: var(--cs-pad); }
.cs-over { display: block; font-family: var(--font-geist-mono); font-size: 8px;
    text-transform: uppercase; letter-spacing: 0.06em; }
/* A hair of blur on the title — ink bleed, not soft focus. It sits in the header where nothing
   moves over it, so the page's rule about filters during motion does not apply here; measured
   anyway during a hard horizontal scroll and it cost nothing.
   Kept SMALL on purpose: much past 0.5px and it stops reading as a printed edge and starts
   reading as a rendering fault. --title-blur is the dial. */
/* IT DOES NOT COLLAPSE. A version of this shrank the title down to a 22px floor as you scrolled,
   driven by a --t custom property written on every scroll event. It was built and then removed the
   same day: a masthead that resizes while you read draws attention to itself at exactly the moment
   the reader has stopped caring about it. The title simply scrolls away with the document now, and
   only the contents bar stays. */
.cs-title { margin-top: 6px; font-family: var(--font-display); font-weight: 300;
    /* 40px cap, down from 54px — Eoin's call on the eye, 2026-09-13. The fluid floor for narrow
       windows is unchanged. */
    font-size: clamp(32px, 3.6vw, 40px); line-height: 1; max-width: 22ch;
    /* Negative tracking on display type only. It is set at 40px here, where the face's default
       spacing is drawn for text sizes and reads loose — the tighter it is set, the tighter it
       wants to be. Body copy keeps the default; this is not a page-wide change. */
    letter-spacing: -0.07em;
    /* NO BLUR. It was 0.3px of ink bleed and it came out with every other text blur — soft
       type at 46px reads as a rendering fault, not as print. */ }

/* THE THUMBNAIL STRIP IS GONE. It sat in the header as a row of sixteen cards — every other piece
   of work, so the reader was never a dead end — with its own horizontal scroll and its own pair of
   backdrop-filter fades at the ends. Removed 2026-08-10.
   Worth recording why, because it was not broken: the cards are unwritten placeholders, so the
   strip was sixteen empty white rectangles competing with the title for the top of the page, and
   its soft ends softened nothing because there was nothing in them to soften. If a way out of the
   document is wanted again it should be at the END of the document, where a reader who has
   finished is actually looking, rather than at the top where it is only in the way.
   The close button remains the way out. */

/* ---- Section indicator ------------------------------------------------------------------
   Dot-separated rather than tabbed, and the dots are drawn between items by the sibling
   selector so the first one has none — a leading separator is the usual way this goes wrong. */
/* IT STICKS. The bar is inside the scroller now, so it travels up with the title and then stops at
   the top and stays there for the rest of the document — which is the one piece of the masthead
   worth keeping on screen, because it is the only thing that says where you are and lets you jump.
   A background is required, not decoration: without it the document scrolls THROUGH the bar and
   the words overlap. z-index for the same reason — the figures' mats would otherwise paint over
   it, since they come later in the document. */
/* NOT STICKY ANY MORE, and it does not need to be. Sticking was how a bar inside a VERTICAL
   scroller stayed on screen after the title had scrolled past it. In a band the bar lives above
   the scroller, so it is permanently on screen for free — and sticky inside a horizontal scroller
   would have pinned it to the LEFT edge and let it slide, which is not the same behaviour at all.
   The background and z-index went with it: nothing scrolls through this now.
   NO SEPARATE SCRUB RULE. The prototype drew one under the whole bar; this site already does
   something better and it is kept — the ACTIVE WORD fills left to right across its own letters
   (see .cs-nav-item[data-here]), so the progress is reported inside the section it belongs to
   rather than across a bar that spans three. */
.cs-nav { display: flex; align-items: baseline; gap: 0;
    position: relative; width: fit-content;
    margin: 0;
    padding-bottom: 14px; }
/* The 30px under a title only exists where a title is still in the masthead — the unwritten case
   study shells. A written case study opens its band with the title (.cs-lead-head), so its bar
   starts at the top of the page. */
.cs-head-main + .cs-nav { margin-top: 30px; }
.cs-nav-item { background: none; border: 0; padding: 0; cursor: pointer;
    font-family: var(--font-geist-mono); font-size: var(--label-size); letter-spacing: 0.04em;
    color: var(--black); opacity: 0.32; transition: opacity 160ms ease-out; }
/* A real pointer only (2026-09-15): on a phone a tap sets :hover and leaves it set, so the tapped item
   stayed half-faded until something else was touched. */
@media (hover: hover) and (pointer: fine) { .cs-nav-item:hover { opacity: 0.6; } }

/* THE ACTIVE WORD IS A PROGRESS BAR. It fills left to right across its own letters as you scroll
   through that section, so the bar reports how far through you are and not merely where you are —
   the one piece of information a horizontal document cannot give you any other way, since there is
   no scrollbar and no page edge to judge against.
   A gradient clipped to the glyphs, with --p written by the scroll spy in site.js. Both stops sit
   at the same position so the boundary is a hard edge travelling across the word rather than a
   soft wash, which at 12px mono would just read as blurry type.
   The item goes fully opaque the moment it is active — the fill, not the opacity, is what carries
   the progress, and animating both would say the same thing twice and land on neither. */
.cs-nav-item[data-here] {
    opacity: 1; --p: 0%;
    background-image: linear-gradient(to right,
        var(--black) var(--p), rgba(0, 0, 0, 0.32) var(--p));
    -webkit-background-clip: text; background-clip: text;
    -webkit-text-fill-color: transparent; color: transparent;
}
/* The separator is a PSEUDO OF THE ITEM, so it inherits that transparent fill and the clipped
   gradient — which would erase the dot on whichever item happens to be active, and put it back
   when you scrolled on. It has to be painted explicitly. */
.cs-nav-item + .cs-nav-item::before { content: "\00b7"; opacity: 0.32; margin: 0 12px;
    display: inline-block;
    -webkit-text-fill-color: var(--black); color: var(--black); background-image: none; }

/* ---- The document ------------------------------------------------------------------------
   A HORIZONTAL BAND. It was a vertical scroll of rows between 2026-08-10 and this commit, and it
   is worth being honest that the axis has now gone both ways: the reasons for turning it vertical
   were real, and the band still won on the eye once the whole thing was built at reading size.

   THE MODEL IS HEIGHT-DRIVEN AGAIN, which is why this is a restructure and not a property flip.
   In the vertical layout a figure was given a WIDTH by its column and its height followed the
   picture's ratio. Here every picture is a stated height and its width follows — see --img-h, and
   .cs-frame > img, .cs-reel, .cs-shot and .cs-flow, which change together and only make sense
   together.

   ALIGN-ITEMS: CENTER is the whole reason the band scans. Every picture is the same height and
   every one is centred on the same line, so the eye tracks along one horizon and the columns of
   text hang off it. Change this to `start` and it becomes a queue of unrelated objects.

   The band supplies its own left and right air rather than taking it from the reader, so a figure
   leaving the screen genuinely leaves it. */
.cs-scroll { grid-row: 2; grid-column: 1; font-family: var(--font-geist);
    min-height: 0; min-width: 0;
    display: flex; align-items: center; gap: var(--gap-pair);
    overflow-x: auto; overflow-y: hidden;
    overscroll-behavior-x: contain; scrollbar-width: none;
    padding-inline: var(--cs-pad);
    /* Asymmetric on purpose. The caption sits below its mat and out of flow, so the band's own box
       does not account for it — the bottom padding is what keeps it on screen, and the reader
       clips anything past it. */
    padding-block: var(--bar-gap) calc(var(--band-pad) + 30px); }
.cs-scroll::-webkit-scrollbar { display: none; }

/* ---- Sections and rows dissolve into the band -------------------------------------------------
   BOTH ARE display: contents, and that is the hinge of this whole port. The band is a flat
   sequence of columns and figures; the markup is a nest of sections containing rows containing
   pairs. `contents` hands each wrapper's children straight to the flex container, so the markup
   keeps its meaning — a row is still a passage and its picture, a section is still a section, the
   contents bar is still built from data-label and the spy still measures from the section — while
   the layout sees one flat row of objects.

   The alternative was flattening home.html into the prototype's shape. That would have thrown away
   the pairing the gaps are derived from, and left the bar and the spy with nothing to read. */
.cs-sec { display: contents; }
.cs-row { display: contents; }

/* THE THREE GAPS COME FROM THE MARKUP, not from a class on each item. The band's own gap is the
   pair gap, so a passage already sits close to its figure; a row's FIRST child opens a new finding
   and takes the item gap; a section's first child opens a section and takes the section gap. Each
   is the difference from the gap already applied, because flex gap and margin add up.
   This is what the row and section wrappers buy: the prototype needed an `opens-item` class on
   every other element to say the same thing. */
/* The two rows that are NOT a passage-and-picture pair are excluded by name. The lead is a single
   block and its own first column is not opening anything; the lede opens a SECTION and takes the
   larger gap below. Without the exclusions the summary's first column took an item gap and the
   opening block started 116px inside its own left line. */
/* These three margin rules are the BAND'S, and scoped to it: a case study that reads down
   (data-axis="down") spaces the same markup vertically — see THE ARTICLE. */
.cs-scroll:not([data-axis="down"]) .cs-row:not(.cs-row--lead):not(.cs-row--lede):not(.cs-row--close) > :first-child {
    margin-left: calc(var(--gap-item) - var(--gap-pair)); }
/* The section gap rides on the lede itself rather than on "the first thing in a section", because
   every section after the first opens with one and the lede is a real band item — a margin on the
   row would be a margin on a display: contents box, which paints nothing. */
.cs-scroll:not([data-axis="down"]) .cs-lede { margin-left: calc(var(--gap-section) - var(--gap-pair)); }

/* ---- The opening block ------------------------------------------------------------------------
   The one row that does NOT dissolve. Context and Relevance stack in one column, Impact sits
   beside them, then the reel — three things read side by side as a single masthead, not as the
   first three objects in the band. Kept as a row of its own so the band sees one item.
   CENTRED AS ONE BLOCK, 2026-09-14. It was top-aligned, the one object in the band that was not on
   the line — every later column and mat has equal air above and below, and this sat hard under the
   contents bar with 195px spare beneath it at 1920x1071. Centring each column on its own, as the
   band does, was rendered and rejected: Impact is 189px of ink beside Context's 466, so its label
   dropped 138px below Context's and the masthead stopped reading as one block.
   A GRID, because a flex row cannot centre a group while its members share a top. The two 1fr rows
   split the spare height equally and the text sits in the middle track, top-aligned there; the reel
   spans all three and centres on its own. minmax(0, auto), not auto: on a window too short for the
   text the fr rows collapse first and the middle track then stops at the band's height, so
   .cs-col's max-height: 100% still has a definite area to resolve against and the column scrolls
   inside itself instead of running off the bottom. */
/* THE TITLE OPENS THE BAND, 2026-09-14. It sat in the masthead above the band, permanently on
   screen, and took 166px of height (label, 40px title, the gap to the bar) from every picture and
   column in the document. Now the masthead is only the contents bar and the title heads the opening
   block: above the two text columns, spanning them, on the same left line — and it scrolls away
   with the document like everything else in the band, which Eoin chose over keeping a name on screen.
   THE TITLE DOES NOT COUNT TOWARDS THE CENTRING (Eoin, same day). Centring title-plus-columns as
   one group put the carousel level with a block that included the title, so it read as sitting too
   high beside the paragraphs. Now the PARAGRAPHS are what centre, and site.js moves the carousel onto
   their centre (centreLead).
   The title hangs off the top of the paragraphs from the bottom of the spare track above them.
   Still three rows — spare, text, spare — with the title in the top spare row, aligned to its end.
   The 1fr tracks give that for free: while half the spare height covers the title, both halves
   are equal and the paragraphs sit dead on the line; once it cannot, the top track stops shrinking
   at the title's own height and the paragraphs slide down rather than push the title off the top.
   The text row is minmax(0, auto) as before, so on a very short window the columns give, scrolling
   inside themselves. */
.cs-row--lead { display: grid; gap: 0 var(--sum-gap-x);
    grid-template-columns: var(--cs-col) var(--cs-col) auto;
    grid-template-rows: 1fr minmax(0, auto) 1fr; align-items: start;
    flex: 0 0 auto; height: 100%; }
/* 64px of air under the title, and IT IS THE FIRST THING TO GIVE on a short window. At 1440x900
   the full 64 pushed WCF's Context/Relevance stack 10px past the band — which it had fitted before
   the title moved in (measured against the previous commit). The clamp holds 64 on any window 920px
   or taller, narrows to 44 at 900, and never goes under 32. */
.cs-row--lead > .cs-lead-head { grid-row: 1; grid-column: 1 / 3; align-self: end;
    margin-bottom: clamp(32px, calc(100vh - 856px), var(--sum-gap-y)); }
.cs-row--lead > .cs-col { grid-row: 2; width: var(--cs-col); }
/* The carousel SPANS THE GRID rather than sharing the paragraphs' row. Sharing it was tried first
   and measured wrong: the carousel is taller than the paragraphs, so it sized that row to itself and
   the paragraphs sat at the top of a 502px track while the carousel "centred" on its own box — 95px
   low beside Gallery Mode's text. Spanning, it sizes only the spare tracks and centres on the band's
   line; centreLead in site.js then nudges it, with `translate`, onto the paragraphs' centre, which is
   that same line whenever the window has room. */
.cs-row--lead > .cs-fig--lead { grid-row: 1 / -1; grid-column: 3; }
.cs-block + .cs-block { margin-top: var(--sum-gap-y); }

/* ---- A group ----------------------------------------------------------------------------------
   Columns that should read as ONE SET rather than as separate objects on the line. Two uses, and the
   second is a rule, not an option:
   - a run of text-only findings, as rows inside the group (Gallery Mode's four problems);
   - A PASSAGE THAT RUNS ONTO A SECOND COLUMN, in every case study. NOT WRITTEN IN THE MARKUP:
     site.js wraps adjacent columns in a row into a group, and pushes the continuation down so its
     first line sits level with the first column's first paragraph — see alignPassages. The group
     is then the row's first child, so it takes the finding gap itself.
   The opening block's trick, generalised: its members share a top,
   and the set centres on the line as a whole. Centred one by one, as the band does everything else,
   each column's label sits wherever that column's length puts it, and a set of labels stepping up
   and down reads as nothing in particular. (2026-09-14)
   Same grid as .cs-row--lead for the same reasons: the 1fr tracks split the spare height so the
   middle one centres; minmax(0, auto) keeps a column's max-height: 100% resolving, so a column
   too long for the window still scrolls inside itself. Auto-flow by column so the members can be
   any number. The rows inside stay display: contents, so their first child still takes the finding
   gap and the column-gap below is the band's own pair gap — spacing is identical to ungrouped. */
.cs-group { display: grid; grid-auto-flow: column; grid-auto-columns: auto;
    grid-template-rows: 1fr minmax(0, auto) 1fr; align-items: start;
    column-gap: var(--gap-pair); flex: 0 0 auto; height: 100%; }
.cs-group > .cs-row > *, .cs-group > :not(.cs-row) { grid-row: 2; }
/* A PICTURE IN A GROUP does not join the shared top — that is for text. It spans the grid and
   centres on the line like every other mat, so the band's horizon holds straight through the set. */
.cs-group .cs-fig, .cs-group .cs-figs--pair { grid-row: 1 / -1; align-self: center; }

/* The hinge between sections is one sentence set large. It stays narrower than the band's own
   measure would allow — at full width it runs as a single line and stops reading as a pause. */
.cs-row--lede { display: contents; }
.cs-lede { flex: 0 0 var(--lede-col); width: var(--lede-col); max-width: none; }

/* A TEXT COLUMN CAN OVERFLOW ITSELF, and must be allowed to. The band is one screen tall, so a
   passage longer than the screen would otherwise force the band taller than the viewport and take
   the horizontal scroll with it — a document that scrolls both ways at once. Its own overflow is
   the release valve, and it is the lesser of the two evils by a distance. */
.cs-col { flex: 0 0 var(--cs-col); width: var(--cs-col);
    display: block; font-family: var(--font-geist); min-width: 0;
    max-height: 100%; overflow-y: auto; scrollbar-width: none; }
.cs-col::-webkit-scrollbar { display: none; }

/* THE CLOSING BLOCK. One column, the same one the summary opens with, so the document ends on the
   shape it began with. It is a row rather than a bare column only so the section gap lands before
   it — the widest gap in the document falls between the last finding and the result. */
.cs-row--close { display: contents; }
.cs-scroll:not([data-axis="down"]) .cs-row--close > .cs-col { margin-left: calc(var(--gap-section) - var(--gap-pair)); }

/* A figure is as wide as its picture makes it and never stretches. */
.cs-fig { flex: 0 0 auto; }

/* ---- The Before/After pair --------------------------------------------------------------------
   Still two figures in the markup. Step 5 of this port turns it into a switch — one place, one
   control — at which point these become two panes sharing a cell rather than two pictures sharing
   a row. Until then they sit side by side in the band as one unit. */
/* ---- The Before/After switch -------------------------------------------------------------------
   One place, one control. site.js turns the two figures into two panes sharing a grid cell and
   builds the tabs above them — see buildPairSwitch for why the control cannot be markup.

   THE CELL IS SHARED so the block holds the size of the larger state and NOTHING resizes when you
   press it. That also restores the full mat: half a picture column could not afford an 80px mat
   and it was cut to 40, which is no longer true now that each pane has the whole width. */
.cs-figs--pair { position: relative; flex: 0 0 auto; }
.cs-panes { display: grid; }
.cs-panes > .cs-fig { grid-area: 1 / 1; opacity: 1; transition: opacity 260ms ease; }
/* visibility carries its delay ONLY on the way out, so the leaving pane stays painted until it has
   finished fading while the arriving one appears at once. Transitioned together they would both be
   half-gone in the middle of the swap. */
.cs-panes > .cs-fig.is-off { opacity: 0; visibility: hidden;
    transition: opacity 260ms ease, visibility 0s linear 260ms; }
@media (prefers-reduced-motion: reduce) {
    .cs-panes > .cs-fig, .cs-panes > .cs-fig.is-off { transition: none; }
}
/* The tabs sit above the mat and out of flow, exactly as the flow's do, so the control cannot
   push its own picture off the band's horizon. */
.cs-figs--pair > .cs-flow-tabs { position: absolute; left: 0; bottom: 100%; margin-bottom: 12px; }
.cs-kick { display: block; font-family: var(--font-geist-mono); font-size: 10px;
    text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 20px; }
.cs-h { font-family: var(--font-display); font-weight: 300; font-size: 22px; line-height: 1.22;
    margin-bottom: 20px; }
/* The hinge between sections, spoken rather than labelled. It replaces an overline-plus-heading
   pair — "A deeper dive" / "The problems" — which named the section twice over, once in the
   contents bar and again at its head. One sentence at 24px does the same work and sounds like a
   person, and at this size it is the only thing in its column, so it reads as a breath before the
   evidence rather than as another heading to scan past. */
/* TOP-ALIGNED, like everything either side of it. It was centred in the band on the theory that a
   pause should not start where the body copy starts — which read, in practice, as a sentence
   floating in the middle of an empty column with no relationship to anything. Sharing the top line
   with the columns around it, it reads as the first thing in the section, which is what it is. */
/* NO max-width HERE. It carried one, and being the LAST .cs-lede rule in the file it beat the
   width set with the band's measurements 60 lines above — same specificity, source order decides.
   The lead-in rendered at 282px instead of 560 and nothing said why. The width belongs with the
   layout; this rule is type only. */
.cs-lede { font-family: var(--font-display); font-weight: 300; font-size: 26px;
    line-height: 1.32; letter-spacing: -0.02em; }

.cs-p { font-size: 13px; line-height: 1.75; margin-bottom: 0; }
.cs-p + .cs-p { margin-top: 20px; }
/* A paragraph after a list takes the same 20px as after a paragraph: the last item already
   carries 12px below it, so this adds the difference. Without it the sentence closing a list sat
   12px under it and read as another item (Gallery Mode, Solution 02, 2026-09-14). */
.cs-list + .cs-p { margin-top: 8px; }
.cs-p code { font-family: var(--font-geist-mono); font-size: 0.92em;
    background: rgba(0, 0, 0, 0.05); padding: 1px 3px; }
/* A LONG UNBROKEN STRING BREAKS rather than running out of a phone's column (2026-09-15): the URL in
   World Creation Flow's field passage is ~230px of mono with nowhere to wrap. Both only act on a word
   wider than its whole line, so nothing that already fits moves. */
.cs-p code { overflow-wrap: anywhere; }
.cs-title, .cs-h, .cs-lede, .ab-say { overflow-wrap: break-word; }
/* ARROWS, NOT DISCS. A disc is a typographic default; these lists are findings, and an arrow
   says "this followed from that" where a dot says only "another one of these".
   Drawn from the SAME shaft-plus-chevron path as .cs-ar on the stats, rotated, rather than set
   as a "→" glyph — Geist's arrow is drawn for its own text weight and lands heavier and larger
   than the one already on the page, so two arrows in one document would not have matched.
   Carried as a MASK, not a background image, so it takes currentColor and inherits whatever the
   copy is doing. Inlined as a data URI because a separate .svg file is blocked over file://
   exactly the way the fonts are. */
.cs-list { margin: 18px 0 0; padding-left: 0; list-style: none;
    font-size: 13px; line-height: 1.7; }
.cs-list li { position: relative; padding-left: 20px; margin-bottom: 12px; }
.cs-list li::before {
    content: ""; position: absolute; left: 0;
    /* Optically centred on the FIRST LINE of the item, not on the item — a multi-line finding
       would otherwise put its arrow halfway down the paragraph. 12px at 1.7 is a 20.4px line, so
       a 10px mark sits at 5px. */
    top: 5px; width: 10px; height: 10px;
    background: currentColor; opacity: 0.55;
    --arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='butt' stroke-linejoin='miter'%3E%3Cpath d='M2.4 8h10.5'/%3E%3Cpath d='M8.6 3.7L12.9 8l-4.3 4.3'/%3E%3C/svg%3E");
    -webkit-mask-image: var(--arrow); mask-image: var(--arrow);
    -webkit-mask-size: contain; mask-size: contain;
    -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}

/* The stats, as a before or an after. Tabular so the digits hold a column between the two. */
/* Opened up with the rest, then pulled back. The document band is what is LEFT after the
   header and the contents bar take their air, and at a 1000px window the stats' extra 62px was
   exactly what pushed the Context column 61px past the bottom. Everything else keeps its
   breathing room; this one pays for it. */
.cs-stats { display: grid; gap: 20px; margin-top: 30px; }
.cs-stat { display: grid; grid-template-columns: 1fr auto; align-items: baseline;
    row-gap: 4px; column-gap: 12px; }
.cs-stat-k { grid-column: 1; font-size: 11px; }
.cs-stat-d { grid-column: 1; font-size: 9px; line-height: 1.35; }
/* Centred against the label AND its description together, rather than sitting on the label's
   baseline. It spans both rows, so align-self: center puts it on the midpoint of the pair —
   the grid's own align-items: baseline would otherwise pin it to the first row. */
.cs-stat-v { grid-column: 2; grid-row: 1 / span 2; align-self: center;
    display: flex; align-items: center; gap: 0.14em;
    font-family: var(--font-geist);
    font-weight: 400; font-size: 17px; font-variant-numeric: tabular-nums; }
/* The same drawn arrow the row's cards use — matched to Geist's metrics rather than borrowed
   from the face, so its weight can sit against a 300-weight figure without shouting. Direction
   only, uncoloured: here it happens to read correctly either way, since a fall in drop-offs and
   a rise in activations are both wins and both point the way you would expect. */
.cs-ar { width: 0.74em; height: 0.74em; flex: 0 0 auto; display: block; }

/* A figure fills the frame's height and takes whatever width its image wants. The grey mat and
   the white fill are the row's treatment, reused so a missing screenshot reads as deliberate. */
/* NO max-width. It was 62vw, and it was actively breaking pictures rather than protecting the
   layout: capping the figure's width while the image's height is pinned at 100% leaves the
   reset's `img { max-width: 100% }` (base.css) to take the difference out of the width alone, so
   a 4.20:1 screenshot rendered at 3.54:1 — visibly stretched, and nothing in the figure's own
   rules said so. Nothing needs the cap now that ratios are the images' own: width derives from
   the band's height, which derives from the viewport, so a figure is already self-limiting. */
/* HEIGHT-DRIVEN AGAIN. Both tracks stay `auto` — the frame is as tall as --img-h plus its mat and
   the caption takes what it needs — but the figure no longer takes a width from anything: it is as
   wide as the picture inside it comes out, which is what lets ratios differ along the band without
   any figure being stretched to a column. */
/* THE CAPTION IS OUT OF FLOW, and this is the difference between the pictures sharing a horizon
   and merely being the same height. align-items: center centres the FIGURE, and a figure is the
   mat plus its caption — so the mat's own centre sat half a caption above the line: 16px on twelve
   figures and 26px on the lead one, whose caption wraps to two lines. Absolutely positioned, the
   figure IS the mat, and the band centres what the eye is actually tracking. */
.cs-fig { display: block; position: relative; margin: 0; min-width: 0; flex: 0 0 auto; }
/* --wide is now inert: it used to widen a figure by hand, but a locked ratio against a row-height
   image leaves exactly one possible width. Kept as a hook so the markup does not need rewriting. */
/* SOFT-EDGED MAT. The grey cannot be blurred as the frame's own background — filter applies to
   the whole subtree and would take the image with it. So the grey moves to a layer BEHIND a
   sharp image, and only that layer is blurred.
   overflow is no longer hidden: blur spreads outward, and clipping it would put back the hard
   edge this is removing. It was only there to catch images wider than the mat, which the locked
   16/9 ratio now prevents.
   The shadow-border is dropped rather than blurred — a crisp 1px line around a soft edge reads
   as a mistake, and blurring it just muddies the grey. */
/* The padding is FOUR CUSTOM PROPERTIES rather than one shorthand, because half the figures in
   this document are exceptions to it — an image cropped off a screen edge loses that edge's
   padding, so the crop reads as the screen continuing past the mat rather than as a picture that
   happens to touch the frame. Declaring them separately means a per-figure override is one line
   and the border and radius rules below can DERIVE themselves from the same numbers instead of
   needing a hand-written case each. */
.cs-frame { display: block; position: relative; min-height: 0;
    /* 64px, down from 80. The larger value was drawn against a frame filling a wide picture column
       in the vertical layout. Against a 374px-tall picture in a band it was eating a third of the
       figure's height and the mat stopped reading as a mat. */
    --fp-t: var(--mat-pad); --fp-r: var(--mat-pad);
    --fp-b: var(--mat-pad); --fp-l: var(--mat-pad);
    padding: var(--fp-t) var(--fp-r) var(--fp-b) var(--fp-l);
    --mat-feather: 8px; }
/* Feathered with a MASK, not filter: blur(). A blur spreads outward, and these frames sit flush
   with .cs-scroll's top edge, so overflow-y: hidden sliced the soft top off every one of them —
   three soft sides and a ruled line on the fourth. A mask fades inside the element's own box, so
   there is nothing outside to clip. It is also cheaper: no blur pass on 13 layers mid-scroll.
   Two gradients intersected, one per axis — a single gradient only feathers one pair of sides. */
/* ---- THE PAPER --------------------------------------------------------------------------------
   The mat WAS a picture — mat-halftone.webp under every frame, with a per-figure crop cycle so
   thirteen frames were not thirteen copies of one image. It is now cream plus two layers of
   procedural noise, and the crop cycle goes with it: there is nothing to crop, and every mat is
   different everywhere anyway because the noise tiles at two periods that share no factor.

   PROCEDURAL, NOT A FILE. An SVG data URI cannot fail to fetch, has no request to lose over
   file://, and stays sharp at any DPR — where a webp tile is a fixed raster that softens on a
   retina screen. It also drops 261KB from the page.

   TWO FREQUENCIES, because one is not paper. A single fine noise reads as television static; real
   stock has a fine tooth AND a slow unevenness across the sheet. So a 200px tile of grain over a
   520px tile of mottle. stitchTiles='stitch' is what makes each seamless — without it the 520px
   layer draws a visible grid of seams across the mat.

   THE GAMMA IS THE WHOLE TRICK, and it is why this cannot be simplified to one flat noise layer.
   multiply can only DARKEN, so "lighter" and "more texture" fight each other: every step toward
   visible is a step toward grey. Paper is a light sheet with sparse darker fibres, so feFuncA
   gamma 5 pushes the bulk of the alpha to zero and leaves only the peaks — most of the sheet
   untouched, a few fibres genuinely dark. The mat lands LIGHTER than the grey it replaced while
   the spread goes up.
   Gamma is right for the tooth and wrong for the mottle: at low frequency it turns a slow
   unevenness into isolated dark blobs that read as dirt on the sheet rather than as the sheet. The
   mottle stays smooth and is held down by opacity on its own rect instead.

   Two values tuned as a PAIR and not separately, by measuring the painted pixels rather than by
   picking numbers that sounded faint — see the harness's paper probe. --frame is chosen so the
   PAINTED result is the light cream, not so the token is.

   isolation on the frame keeps the blend inside the mat rather than reaching the page behind it.
   images/mat-halftone.webp and images/mat-gradient.webp are both unreferenced by this page now.
   Left on disk: they are the other half of a decision that could be reversed, and nothing fetches
   them. The work row's card no longer uses the halftone either, and since 2026-09-15 neither it nor
   the carousel uses mat-grain.webp: both are the cream, and that file is unreferenced too. */
.cs-frame { --frame-grain: 0.62; isolation: isolate; }
/* Shared, by selector, with the case study cards in the work row — see .wp-blk there. */
.cs-frame::before,
.work-page:is([data-kind="case"], [data-kind="site"]) .wp-blk::before { content: ""; position: absolute; inset: 0; z-index: 0;
    background-color: var(--frame);
    background-image:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='t'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='gamma' exponent='5'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23t)'/%3E%3C/svg%3E"),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='520' height='520'%3E%3Cfilter id='m'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.013' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='520' height='520' filter='url(%23m)' opacity='0.16'/%3E%3C/svg%3E");
    background-size: 200px 200px, 520px 520px;
    opacity: var(--frame-grain);
    mix-blend-mode: multiply;
    /* The feather stays. It is a mask rather than a blur for the reason above this rule, and it
       still softens the mat's own edge into the page — which matters more now that the mat is
       nearly the page's own colour and a hard edge would be the only thing declaring it. */
    --fx: linear-gradient(to right, transparent, #000 var(--mat-feather),
          #000 calc(100% - var(--mat-feather)), transparent);
    --fy: linear-gradient(to bottom, transparent, #000 var(--mat-feather),
          #000 calc(100% - var(--mat-feather)), transparent);
    -webkit-mask-image: var(--fx), var(--fy); mask-image: var(--fx), var(--fy);
    -webkit-mask-composite: source-in; mask-composite: intersect; }

.cs-frame > * { position: relative; z-index: 1; }

/* (Our Wedding's card sat on a dithered rose painting, printed onto the paper at half strength, for part of
   2026-09-15. Eoin took it back to the same paper as every other card that evening.) */
/* The mat stays square and the image inside is rounded — the reverse would round the outer
   silhouette and lose the sheet-of-paper read the frames are doing. */
/* 16/9 regardless of what the screenshot actually is. Height comes from the row, so width is
   derived from the ratio — which also fixes wide screenshots being cropped by the frame: they
   used to keep their own ratio with max-width: none and run straight past the mat.
   object-fit: contain, not cover, because cropping a UI screenshot loses the thing being shown.
   The letterboxing is invisible — it lands on the image's own white --page background. */
/* DIRECT CHILD, not any descendant. Two figures now put something between the frame and its
   image — the annotated one wraps it so the overlay has something to be absolute against, and the
   reel holds four of them on a moving track. Both want their own sizing, and a descendant
   selector here would have imposed the full-height 16/9 lock on all of it. */
/* THE PICTURE IS THE PICTURE. No aspect-ratio, no object-fit, no background — one axis is given
   and the other follows the image's own proportions, so the box IS the intrinsic ratio. There is
   no second shape for a fit mode to reconcile and nothing behind the image for a background to
   show through, which is the only way to guarantee that no white is ever added around anything.
   It used to be locked to 16/9 with object-fit: contain, and the note here claimed the
   letterboxing was invisible because it landed on the image's own white. That was true when
   figures sat on a white page. The mats are a halftone print now, so those bars read as white
   slabs grown around the picture — measured at 3.82:1 content inside a 1.78:1 box on the
   dashboard, 2.55:1 inside 1.78:1 on the breadcrumb.
   THE HEIGHT IS A CEILING, NOT A HEIGHT — and the comment used to claim otherwise. Most pictures
   do land at --img-h, but a capture too wide to fit that height inside --img-max-w gets shorter
   rather than cut, so the drawn heights genuinely vary (148 to 424 measured). What they share is
   the CENTRE LINE, not the height: the band centres each mat, which is why the caption had to
   leave the flow for the horizon to be real.

   max-width DOES TWO JOBS HERE and both matter. It caps a very wide source so one strip cannot run
   half a screen long, AND it overrides base.css's `img { max-width: 100% }`, which would otherwise
   constrain the width while the height is pinned — that is what squashed the ratio and stretched
   problem 02 the last time this layout existed. object-fit: cover keeps the crop honest when the
   cap bites. */
/* TWO MAXIMA, BOTH DIMENSIONS AUTO. This has been wrong three ways and the arrangement is the
   whole point. A fixed height with a width cap is a CROP. A fixed height with no cap keeps the
   picture whole but lets a 4.77:1 capture run 1776px. A width cap with a fixed height is a SQUASH.
   Bounding both axes and letting the box size itself is the only version that never changes the
   picture's shape: a 16:9 still lands at 374 tall because its width there is inside the cap, and
   only a capture too wide to fit gets shorter instead of getting cut. */
/* A <video> clip (data-clip) is a picture that moves, so it takes the same sizing, line and corners. */
.cs-frame > :is(img, video) { display: block; width: auto; height: auto;
    max-width: var(--img-max-w); max-height: var(--img-h);
    /* WHERE THE PADDING IS ZERO THE IMAGE GETS NO LINE ON THAT EDGE AND NO RADIUS ON THE CORNERS
       THAT TOUCH IT. Same rule the work row's .wp-img runs, and for the same reason: with no
       padding the image's edge sits exactly on the mat's edge, and two hairlines stack into one
       double-weight line that reads as a mistake. A rounded corner on a flush edge reads the same
       way — it turns "this screen continues past the frame" back into "this is a card".
       min() does both arithmetically, so the rule holds for whatever paddings a figure is given
       rather than needing a modifier class per edge per figure. */
    border-style: solid; border-color: var(--img-line);
    border-width: min(1px, var(--fp-t)) min(1px, var(--fp-r))
                  min(1px, var(--fp-b)) min(1px, var(--fp-l));
    border-top-left-radius:     min(var(--img-radius), var(--fp-t), var(--fp-l));
    border-top-right-radius:    min(var(--img-radius), var(--fp-t), var(--fp-r));
    border-bottom-right-radius: min(var(--img-radius), var(--fp-b), var(--fp-r));
    border-bottom-left-radius:  min(var(--img-radius), var(--fp-b), var(--fp-l)); }

/* THE PLACEHOLDER IS THE OPPOSITE CASE and must keep its own rule. It has no image, so it has no
   intrinsic ratio to inherit and no content to be honest about — it is a blank sheet standing in
   for the four case studies still to be written, and a shape is the entire thing it contributes.
   DO NOT FOLD THIS BACK INTO THE RULE ABOVE. */
/* HEIGHT-DRIVEN, LIKE EVERY PICTURE IN THE BAND. It was width: 100% of its frame, left over from the
   vertical layout, where a column gave the frame a width. In a band nothing does: the frame is as
   wide as its content and the fill was as wide as its frame, so the loop resolved to nothing and
   every placeholder measured 4x2 inside a bare 132px mat (harness gm-layout, 2026-09-14). A stated
   height plus the ratio breaks the circle exactly as --img-h does for a picture. Radius is the
   pictures' own, since this stands in for one. */
.cs-fill { display: block; height: var(--img-h); width: auto; aspect-ratio: 16 / 9;
    background: var(--page);
    border: 1px solid var(--img-line); border-radius: var(--img-radius); }

/* ---- Per-figure exceptions --------------------------------------------------------------- */

/* The reel runs the FULL WIDTH of its mat: no left or right padding, so a step enters and leaves
   at the mat's own edge rather than appearing and vanishing 112px inside it. The strip reads as
   something passing THROUGH the frame, which is what it is; inset, it read as a small animation
   playing inside a large picture. Top and bottom padding stay — those edges are still a mat. */
.cs-fig--lead .cs-frame { --fp-l: 0px; --fp-r: 0px; }
/* THE CARD'S SHAPE, NOT THE ROW'S. This mat used to stretch to the opening row's height and centre
   a 2.35:1 reel inside it. The row is as tall as the Context and Relevance stack, so the mat came
   out 4:3 — 879x661 at 1920x1071 — with the steps at 28% of its height, against 45% on the work
   row's card. Eoin preferred the card, 2026-09-14, so the mat now takes the card's 16:9 and the
   card's 24px top and bottom (.wp-blk's --pt/--pb), and the reel fills it exactly as it fills
   the card. The steps land at the same share of the mat in both places.
   AS TALL AS EVERY OTHER MAT: the picture height plus its mat, so this frame is the same height
   as the figures that follow it rather than a third size.
   CENTRED, NOT STRETCHED. The caption hangs off the figure's bottom edge (top: 100%), so a
   stretched figure would leave it at the foot of the row, well below a mat that no longer fills
   it. Centred, the figure IS the mat, and its centre sits on the band's line with every other. */
.cs-row--lead .cs-fig--lead { align-self: center; }
.cs-fig--lead .cs-frame { --fp-t: var(--lead-pad); --fp-b: var(--lead-pad);
    height: calc(var(--img-h) + var(--mat-pad) * 2); aspect-ratio: 16 / 9; }
/* THE CAROUSEL IS ON THE PAPER, like every other mat (Eoin, 2026-09-15). It had a surface of its own
   — images/mat-grain.webp, a green-yellow grain, switched in here in place of the paper — on the
   theory that a moving component should not read as another framed picture. On the eye it read as
   the one off-colour object in a cream document, so the override is gone and the paper applies. */
/* A PLACEHOLDER IN THE OPENING MAT takes what the mat leaves, inside the card's 24px on all four
   sides. The mat's shape and height are already set on the frame, so the sheet fills rather than
   sizing itself — sized itself, it came out the frame's full height and ran 48px past the mat. The
   sides get their margin back because the bleed exists for a strip passing THROUGH the frame; a
   blank sheet run into the edges only reads as a mistake. */
.cs-fig--lead .cs-frame:has(> .cs-fill) { --fp-l: 24px; --fp-r: 24px; }
.cs-fig--lead .cs-frame > .cs-fill { width: 100%; height: 100%; aspect-ratio: auto; }

/* ---- FLUSH EDGES ------------------------------------------------------------------------------
   A capture that runs off a screen edge loses that edge's mat, so the screen reads as continuing
   past the frame rather than as a picture that happens to touch it. Four independent classes that
   compose, rather than one class per combination — the derived border and radius rules above
   already follow --fp-*, so declaring the padding is the whole edit. */
.cs-fig--flush-t .cs-frame { --fp-t: 0px; }
.cs-fig--flush-r .cs-frame { --fp-r: 0px; }
.cs-fig--flush-b .cs-frame { --fp-b: 0px; }
.cs-fig--flush-l .cs-frame { --fp-l: 0px; }
/* AND THE PICTURE GROWS BY WHAT THE MAT GAVE UP, or the one figure running to the mat's edge is
   also the one shorter than every other frame by exactly the padding it dropped. Only the VERTICAL
   edges count — a flush left or right changes the width, which is free in a band. */
.cs-fig--flush-t .cs-frame > img,
.cs-fig--flush-b .cs-frame > img { max-height: calc(var(--img-h) + var(--mat-pad)); }
.cs-fig--flush-t.cs-fig--flush-b .cs-frame > img { max-height: calc(var(--img-h) + var(--mat-pad) * 2); }
/* The summary card figure is cropped off the top and the left. Kept as its own name because the
   markup already says --crop-tl; it is exactly flush-t plus flush-l. */
.cs-fig--crop-tl .cs-frame { --fp-t: 0px; --fp-l: 0px; }
.cs-fig--crop-tl .cs-frame > img { max-height: calc(var(--img-h) + var(--mat-pad)); }

/* NOW INERT, and worth understanding why rather than just deleting. The URL field's source is
   1800x319, a 5.6:1 strip. Sized off the band's height it ran about a screen WIDE, so it was held
   to 58% of that height to bring it back. Sized off a column's width it is 5.6 times shorter than
   it is wide, all by itself — the problem this solved cannot occur in a width-driven layout.
   Kept as a hook so the markup does not need rewriting, exactly as --wide is. */
.cs-fig--short { /* no declarations — see above */ }

/* --wide is inert again: with no cap there is nothing for it to lift. */

/* --asis IS GONE, and it went the way its own comment said it would. It existed because the
   dashboard capture was 508x133 — too small for any layout to enlarge without falling apart, so
   that one figure opted out of the shared height and sat off the band's horizon. dashboard_v2 is
   3824px wide and joins the horizon like everything else. Recorded rather than silently deleted,
   because the modifier was a workaround for an asset and not for a layout. */

/* ---- The reel ------------------------------------------------------------------------------
   A strip of the four redesigned steps that walks itself: each one travels to the centre, stops,
   grows, holds, shrinks, and hands over to the next. It runs in two places — the lead figure of
   the document and the case study's card in the work row — from ONE component, because the card
   is meant to be a preview of the document and not a separate picture of it.

   THE ITEMS ARE LAID OUT SMALL AND SCALE UP, rather than laid out large and scaled down. Scaling
   an <img> above 1 normally softens it; it mostly does not here, because the sources are far wider
   than the screens are drawn. MOSTLY: measured 2026-09-15 (harness reel-res, reel-raster), the work
   row's card pops a screen to ~1000css, which on a 2x display wanted 1995px from 1800px sources —
   Gallery Mode's reel sources went to 2880 for that. And a popped screen measured 89-92% as sharp as
   the same file drawn still at the same size: the scale itself costs a little, independent of the
   source. World Creation Flow's card sources are still 1800, with no larger originals on disk.
   Laid out large instead, the resting strip would have had to
   be a downscale — and the gaps would have grown with it, which is the opposite of "a row of steps,
   smaller in height".

   transform and opacity only, per the page's rule: the strip has to move over a mat that must not
   re-rasterise. Position is a translate on the track, size is a scale on the item, and a scale
   leaves layout alone — which is what makes it grow IN PLACE rather than shoving its neighbours. */
.cs-reel { display: block; position: relative;
    /* IT FILLS ITS MAT, in both places, and has no ratio of its own. The mat decides the shape —
       the card's --ar in the work row, 16:9 on the lead figure — and the reel takes the box it is
       given. It used to carry 2.35:1 and the card overrode it back to 100%; once the document's
       mat took the card's shape too, a reel ratio inside a mat ratio would have been two competing
       ratios with one left unmet. The neighbours still enter on both sides at the ~1.95:1 this
       leaves — the card has shown that all along. The items inside take their height from THIS box
       (--reel-h is a percentage of it). */
    width: 100%; height: 100%;
    overflow: hidden;
    /* THE SCREENS FADE INTO THE PAPER AT THE SIDES (Eoin, 2026-09-15). The paper already feathers into the
       page, but the screens entering and leaving ran on to its very edge and stopped there in a hard
       vertical cut. A mask, not a blur: the strip moves under it every frame, and a mask costs nothing
       for that where a blur would be redone each frame.
       SHORT AND SOFT (Eoin: 48px faded to nothing "comes in too much and it's too white" — the screens washed
       out to the paper). 12px on a phone, 20 from 1280, and never fully gone: the edge keeps 15% and the
       fade is mostly done by its middle, so a screen stays a screen right up to the paper's edge. */
    --reel-fade: clamp(12px, calc(12px + 8 * var(--ramp)), 20px);
    --reel-mask: linear-gradient(to right,
        rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.7) calc(var(--reel-fade) * 0.5), #000 var(--reel-fade),
        #000 calc(100% - var(--reel-fade)), rgba(0, 0, 0, 0.7) calc(100% - var(--reel-fade) * 0.5), rgba(0, 0, 0, 0.15));
    -webkit-mask-image: var(--reel-mask);
            mask-image: var(--reel-mask); }
.cs-reel-track { position: absolute; top: 0; left: 0; height: 100%;
    display: flex; align-items: center; gap: var(--reel-gap, 22px);
    /* NO will-change (2026-09-15). It promoted the track to one moving layer, and a promoted layer
       keeps the raster scale it was painted at — so a screen scaled up inside it was the resting
       paint stretched. Measured against the same file drawn still at the same size (harness
       reel-raster2): the parked first screen 55-75% as sharp with it, 84% without; one popped by the
       animation 75-81% with, 81-85% without. The ceiling, a screen laid out at its popped size with
       no scale at all, measured 86-95%: that rework (lay out large, scale DOWN at rest) would close
       the rest and is not done. Motion promotes the track for the length of each travel anyway. */ }
/* Natural ratio here too, and no background: all four steps happen to be 16/9 today, so a forced
   ratio would have been invisible — right up until a fifth step arrived in a different shape and
   quietly grew white bars or lost its edges. The strip takes whatever shape each step is.
   max-width: none for the reset, as everywhere else a height drives a width in this document. */
.cs-reel img { flex: 0 0 auto; display: block;
    height: var(--reel-h, 50%); width: auto; max-width: none;
    /* A RING, NOT A BORDER, and only because a border cannot be counter-scaled. --reel-pop drives
       the scale, the ring and the radius so the three cannot drift; site.js reads it rather than
       keeping a second copy of the number. */
    border: 0; border-radius: var(--img-radius);
    box-shadow: 0 0 0 1px var(--img-line);
    transform-origin: center center; }
.cs-reel img[data-on] {
    box-shadow: 0 0 0 calc(1px / var(--reel-pop)) var(--img-line);
    border-radius: calc(var(--img-radius) / var(--reel-pop)); }

/* ---- The annotated figure ------------------------------------------------------------------
   Problem 02 is the one figure making an argument rather than showing a screen: the dots claimed
   five steps and the flow had two. The screenshot alone cannot say that, so the count is boxed,
   a dashed line drops from it, and the real steps are drawn underneath. The frame becomes a
   column for this one figure so the bar sits under the picture inside the same mat. */
/* A grid, not a flex column: the picture takes the 1fr and the bar takes what it needs, which is
   the same two-track shape .cs-fig itself uses for image-plus-caption. */
/* A COLUMN THAT SPREADS. The leader takes the slack between the picture and the steps, so the
   frame is a flex column rather than a two-row grid — a grid would give the slot a fixed track
   and the line would stop short again. */
.cs-fig--anno .cs-frame { display: flex; flex-direction: column; align-items: center;
    /* SIZED BY ITS CONTENT. Forced to a picture's frame height it carried 154px of empty leader
       slot — a large div holding a small picture, which is what the height was buying. The shot,
       the leader and the steps each take what they need; the leader still spans whatever gap it is
       given, that gap is just no longer padded out to meet a number. */ }
/* THE SHOT DECLARES ITS SOURCE'S RATIO, as a number, and that is deliberate rather than lazy.
   fit-content was tried and is circular here: it clamps to the available space, and the available
   space is a grid column that is itself being sized by its content. Stated outright, the box is
   exactly the picture's shape at any height, so .cs-anno — positioned against .cs-shot — measures
   its percentages against THE IMAGE. That is the only thing keeping the annotations on what they
   point at as the window changes.
   IF found_pagination.webp IS EVER REPLACED, THIS NUMBER MUST CHANGE. The coupling is real but it
   is not new: the annotation percentages below are already tuned to this exact crop, so a new
   screenshot means re-tuning them anyway. */
.cs-fig--anno { --shot-ar: 1800 / 429; }
/* FLUSH AT THE TOP. The capture begins part-way down a form — it is a detail of a screen, not a
   screen — so a mat above it framed a cut rather than an edge. */
.cs-fig--anno .cs-frame { --fp-t: 0px; }
/* box-sizing: content-box, AND THE BORDER MOVES OFF THE IMAGE ONTO THIS BOX. Everything on the
   site is border-box, which means aspect-ratio sizes the box INCLUDING its border — take 1px off
   each side of a 4.2:1 rectangle and the remaining content is 4.22:1, so the picture inside came
   out 0.7% wrong. Sizing the content area instead makes the declared ratio the picture's ratio
   exactly, and the line is then drawn around it rather than out of it.
   .cs-anno's inset: 0 resolves against the padding box, which equals the content box here, so the
   annotations still measure against the image. */
/* Width-driven, like every other picture here now. justify-self: start went with the change — it
   was holding a height-derived box to the left of a column wider than it; the box fills the
   column outright now, so there is nothing to align. */
.cs-shot { position: relative; display: block; box-sizing: content-box;
    /* A SHARE OF THE SHARED HEIGHT, not a share of the width. Its source is 4.2:1, so at the full
       --img-h it would run 1571px wide — a figure a screen and a half long. Capping the WIDTH
       instead was the first fix and it was wrong twice over: it made this the only width-driven
       picture in the band, and on a short window its height then could not shrink, so the whole
       figure ran past the bottom and the band scrolled two ways at once.
       Cropping is the one thing that cannot happen here — the annotation percentages are tuned to
       THIS crop and cropping moves the ring off the dots — so the height comes down instead and
       the width follows. 0.45 lands it at 168px against a 374px band, and it stays in proportion
       at every window height because it is derived from the same number as everything else. */
    height: calc(var(--img-h) * 0.45); width: auto; aspect-ratio: var(--shot-ar);
    border-style: solid; border-color: var(--img-line);
    border-width: min(1px, var(--fp-t)) min(1px, var(--fp-r))
                  min(1px, var(--fp-b)) min(1px, var(--fp-l));
    border-top-left-radius:     min(var(--img-radius), var(--fp-t), var(--fp-l));
    border-top-right-radius:    min(var(--img-radius), var(--fp-t), var(--fp-r));
    border-bottom-right-radius: min(var(--img-radius), var(--fp-b), var(--fp-r));
    border-bottom-left-radius:  min(var(--img-radius), var(--fp-b), var(--fp-l)); }
/* max-width: none for the same reason as the figures above — base.css's reset would otherwise
   constrain the width while the height is pinned, and squash the picture. */
.cs-shot img { display: block; width: 100%; height: 100%; max-width: none; border: 0; }
/* ---- THE MARK -----------------------------------------------------------------------------
   A SCRIBBLED RING, not a dashed box, and a drawn leader, not a dashed drop. The box-and-drop
   version read as a UI selection — the same visual language as the interface being criticised,
   which is exactly the wrong voice for the one figure in this document that is an argument rather
   than a screenshot. This is a pen: something a person put on a printout.

   ONE PASS THAT OVERSHOOTS AND CROSSES ITSELF. That is what makes a circle look drawn rather than
   placed — the hand comes round, meets where it started, and carries on a few degrees before
   lifting. The crossing is more recognisable than the wobble, which a perfect ellipse could fake.

   Percentages, not pixels: the ring has to stay on the dots while the figure is sized off the
   band, and preserveAspectRatio="none" lets it stretch to an ellipse around a WIDE cluster rather
   than staying a circle that misses it. The stroke does not stretch with it — vector-effect keeps
   the pen one weight however the box is scaled, which is what stops it reading as a second pen.

   IF found_pagination.webp IS EVER REPLACED, RE-TUNE THE PERCENTAGES; nothing else will notice. */
.cs-anno { position: absolute; inset: 0; pointer-events: none; }
.cs-anno-ring { position: absolute; overflow: visible; }
.cs-anno-ring path, .cs-anno-lead path {
    fill: none; stroke: var(--black); stroke-width: 2.8; stroke-linecap: round; opacity: 1; }

/* THE LEADER IS A MEMBER OF THE STACK, not an overlay on the picture, and that is the whole
   reason it works. Positioned against the shot it had a fixed length, so the moment the gap
   between picture and steps became elastic the line stopped short in mid-air. As a flex item
   taking the remaining height it is exactly as long as the distance it has to cover.
   NO ARROWHEAD: a line that ends is a line, a line with a head on it is a diagram — and with only
   the ring and the step in this figure, the line between them cannot mean anything else. */
.cs-lead-slot { flex: 0 0 auto; height: clamp(40px, calc(40px + 22 * var(--ramp)), 62px); width: 100%; position: relative; }
.cs-anno-lead { position: absolute; inset: 0; left: 50%; width: 34px; margin-left: -17px;
    height: 100%; overflow: visible; }

/* ---- The step bar --------------------------------------------------------------------------
   NO RULES AND NO CELLS. It was three bordered boxes on a shadow-border — a table of steps, which
   made the real flow look like another interface. Three centred labels with the document's own
   arrow between them say the same thing and sound like the rest of the page.
   The arrows are drawn between the columns by the sibling selector, so the first has none. */
.cs-stepbar { flex: 0 0 auto; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
    /* Capped to the picture's width, or the bar is wider than the shot above it and the two arrows
       land off the column boundaries they are meant to sit on. */
    gap: var(--down-line); width: 100%; max-width: var(--img-max-w); position: relative; }
.cs-step { text-align: center; padding-top: 12px; }
.cs-stepbar .cs-step + .cs-step::before { content: "\2192"; position: absolute; top: 12px;
    transform: translateX(-50%); opacity: 0.55; font-size: 13px; }
.cs-stepbar .cs-step:nth-child(2)::before { left: calc(33.333% - 5px); }
.cs-stepbar .cs-step:nth-child(3)::before { left: calc(66.666% + 5px); }
/* The two steps that were not the drop-off are held back so the eye lands on the one that was —
   and the third is quieter for a different reason: it never existed. Its overline names the thing
   rather than counting it, because numbering something in order to say it does not exist still
   numbers it. */
.cs-step.is-muted .cs-step-n,
.cs-step.is-muted .cs-step-l { opacity: 0.38; }
.cs-step-n { display: block; font-family: var(--font-geist-mono); font-size: 9px;
    text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
/* 15 from 1280, down to 10 on a phone (THE FLUID SCALE), where a 15px "Enter world details" needed 129px
   of an 85px column and all three wrapped. */
.cs-step-l { font-family: var(--font-display); font-weight: 300; line-height: 1.25;
    font-size: clamp(10px, calc(10px + 5 * var(--ramp)), 15px); }

/* ---- The flow comparison --------------------------------------------------------------------
   Solution 01's figure. Two plans running five steps, of which the free one genuinely had two,
   collapsing into a single shared row of four.

   THE GREY STAGE IS LOCKED TO THE TALLER STATE. Letting it resize on the toggle made the box
   itself the thing that moved, which is a distraction from the only movement that means anything —
   two rows becoming one. Held at the two-row height with the table centred, the single row fades
   in exactly where the two used to meet, and the merge is the whole animation. */
/* The 128px top/bottom mat that used to be here is GONE, and it should be: it existed to shrink
   this card back down after the band stretched it to full height and left a table adrift in a
   mostly-empty white box. Nothing stretches it now — the card takes the column's width and its own
   2.1 ratio gives the height — so the extra mat would just be padding around a correctly sized
   thing. Kept as a named rule with no declarations rather than deleted, because the modifier is
   still in the markup and a reader who greps for it should land on this explanation. */
/* .cs-fig--flow .cs-frame — no overrides needed; the default mat applies. */

/* ---- THE FLOW TABLE ---------------------------------------------------------------------------
   NO CARD, NO CELL BORDERS. It was a white box with a shadow-border and a 1px grid between every
   cell — a UI table sitting on the mat, which is the one thing this document's material change was
   meant to get away from. It reads as a table now because it is set like one: a header of step
   numbers, a rule under each row, and columns separated by PADDING rather than by a gap, so each
   row's rule runs unbroken across the whole table. A column-gap slices it into one dash per cell.

   The tabs sit ABOVE the table and out of flow. Two mono words with a rule under the live one —
   a control, not a segmented bar welded to the bottom of a card. Out of flow because this figure
   shares one horizon with every other picture in the band, and a control in normal flow would push
   its own table below that line. */
/* IT IS NOT A PICTURE, so it does not take the picture height. Forced to --img-h the table sat in
   the middle of a mat two-thirds empty, which reads as a small thing in a big frame rather than as
   a table. Sized by its own content and centred on the band, it keeps the horizon without
   pretending to be a screenshot. */
.cs-flow { display: flex; flex-direction: column; justify-content: center;
    position: relative; width: max-content; }
.cs-flow-stage { position: relative; flex: 0 0 auto; min-height: 0; }
.cs-flowtable { display: grid; width: 100%;
    /* minmax(max-content, 1fr): a cell is at least as wide as its label, then shares what is
       spare — so "Configure your world" gets the room it needs instead of wrapping, and the
       shorter cells do not all have to grow to match it. */
    /* max-content, not 1fr. Equal tracks spread the table to a fixed width and pushed the short
       steps apart; content-sized ones let the row be as wide as its labels and no wider. */
    grid-template-columns: var(--label-w, 92px) repeat(var(--cols, 5), max-content);
    /* STRETCH, NOT CENTER. Centred, a cell is only as tall as its content — so the free plan's two
       empty cells were shorter than their neighbours and their bottom rules landed at a different
       height, breaking the row's line exactly where the divergence is. Stretched, every cell in a
       row is the same height and the rule runs unbroken, which is the whole reason the columns are
       separated by padding rather than by a gap. */
    align-items: stretch; }
/* BOTH TABLES IN ONE CELL, so the stage holds the taller with nothing measured and the swap is
   opacity alone. visibility carries its delay only on the way out, so the leaving table stays
   painted until it has finished fading while the arriving one appears at once. */
.cs-flow-stage { display: grid; }
.cs-flowtable { grid-area: 1 / 1; opacity: 1; transition: opacity 260ms ease; }
.cs-flowtable.is-off { opacity: 0; visibility: hidden;
    transition: opacity 260ms ease, visibility 0s linear 260ms; }
@media (prefers-reduced-motion: reduce) {
    .cs-flowtable, .cs-flowtable.is-off { transition: none; }
}
.cs-fcell { padding: 14px 44px 14px 0; min-width: 0;
    border-bottom: 1px solid var(--img-line);
    display: flex; flex-direction: column; justify-content: center; gap: 3px; }
.cs-fcell--label { font-family: var(--font-geist-mono); font-size: 9px; text-transform: uppercase;
    letter-spacing: 0.08em; opacity: 0.45; white-space: nowrap; justify-content: center; }
/* The header carries the rule that opens the table and no bottom padding of its own. */
.cs-fcell--head { padding-bottom: 10px; border-bottom: 1px solid var(--img-line);
    font-family: var(--font-geist-mono); font-size: 9px; text-transform: uppercase;
    letter-spacing: 0.08em; opacity: 0.45; }
.cs-flowtable[data-state="after"] { --label-w: 92px; }
/* A DASH, NOT AN EMPTY CELL. The free plan genuinely skips steps 3 and 4, and a blank there reads
   as a rendering fault where a dash reads as an answer — the gap IS the divergence the figure is
   about, so it has to be stated rather than left out. */
.cs-fcell--none .cs-ft { opacity: 0.28; }
.cs-fn { display: none; }   /* the step number is the column header now; in the cell it said it twice */
.cs-ft { font-family: var(--font-display); font-weight: 300; font-size: 13px; line-height: 1.35;
    margin-top: 3px; white-space: nowrap; }

/* The tabs are appended here by site.js — see the note in home.html for why they cannot be
   written in the markup. */
.cs-flow-tabs { position: absolute; left: 0; bottom: 100%; margin-bottom: 12px;
    display: flex; gap: 18px; }
/* Anchored to the FIGURE, so the control sits above the mat rather than on it — the same place the
   pair's control sits. .cs-fig is the positioned ancestor; .cs-flow must not be one. */
.cs-fig--flow { position: relative; }
.cs-fig--flow .cs-flow { position: static; }
.cs-flow-tab { background: none; border: 0; padding: 0 0 3px; cursor: pointer;
    border-bottom: 1px solid transparent;
    font-family: var(--font-geist-mono); font-size: 10px; text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--black); opacity: 0.38;
    transition: opacity 150ms ease-out; }
@media (hover: hover) and (pointer: fine) { .cs-flow-tab:hover { opacity: 0.7; } }
.cs-flow-tab[data-here] { opacity: 1; border-bottom-color: currentColor; }

.cs-fig figcaption { position: absolute; top: 100%; left: 0; margin-top: 12px;
    /* Its own width, capped — full-bleed to the figure a caption under a 1776px picture ran the
       whole way and stopped reading as a caption. */
    width: max-content; max-width: 46ch;
    font-family: var(--font-geist-mono); font-size: 10px; line-height: 1.5;
    letter-spacing: normal; }

/* =========================================================================================
   THE ARTICLE — a case study that reads DOWN instead of across.

   Gallery Mode, 2026-09-15. Eoin found the band hard to write in: a passage can only be read
   against the picture beside it, never against the passage before it, so the argument is hard to
   follow while it is still being made. It reads top to bottom while it is written, and whether
   it stays that way is decided once it has been read both ways.
   World Creation Flow followed the same evening (Eoin: "match the gallery mode"), so neither case
   study uses the band now. Its three band-only objects have column versions at the end of this section.

   THE SWITCH IS ONE ATTRIBUTE: data-axis="down" on a case study's .cs-scroll in home.html.
   Remove it and the case study is the band again — nothing here applies without it, site.js reads
   the same attribute (the spy, the contents bar, the wheel, the arrow keys, the entrance), and the
   band's own margin rules are scoped away from it (see THE THREE GAPS).

   THE SAME MARKUP, laid out as ONE CENTRED COLUMN: title, carousel, lead-ins, text and pictures
   all 66ch of the body face wide, on one left edge (Eoin, 2026-09-15, after a day of two widths —
   text at 440, 680 and 460 against pictures at 880). 66ch is the CSS unit as written, so it is
   66 of Geist's "0", not 66 characters: at 16px it is ~695px and a full line holds ~92-102
   characters. A column holding 66 real characters (~466px) was offered and not taken.
   A picture's PAPER is the column's width, so the screenshot inside is the column less its mat.

   ONE LINE, AND EVERY GAP IS WHOLE LINES OF IT. The body is 16px at 1.75, a 28px line; the space
   between blocks is counted in those lines, and what is ATTACHED to the next thing takes half of one:
     196  7    either side of a section's lead-in — the sentence sits IN the break, as far from the
               section before as from the one it opens (it had sat 64 above its first finding, which
               tied it to that finding rather than to the section)
     112  4    finding to finding — Solution 01 to Solution 02
      84  3    a second beat inside one finding (the modes picture to the paragraph after it)
      56  2    a passage to its own picture; the title to the carousel
      28  1    paragraph to paragraph, heading to its text — and a passage that ran on into a second
               column is still one passage
      14  1/2  label to heading, picture to caption, tabs to picture, list item to list item
   The hierarchy is space alone, as in the band; a rule between sections was offered and turned down.
   Sections and rows stay display: contents, so as in the band the spacing is read from the
   markup: a row's first child opens a finding, a lede opens a section. One difference matters:
   stacked margins between siblings COLLAPSE, where the band's side-by-side ones added up, so each
   value here is the whole gap rather than a difference from another.
   ========================================================================================= */
/* 66ch MEASURED IN THE BODY FACE, ONCE. ch resolves against the font of the element it is written
   on, so set on each object it would be a different width on each — twice as wide on a 32px lead-in.
   Registered as a <length>, --down-text computes to pixels where it is declared (the scroller, set in
   Geist at 16px) and every descendant inherits that one number. Unregistered, it would inherit the
   text "66ch" and resolve afresh on every element. */
@property --down-text { syntax: '<length>'; inherits: true; initial-value: 0px; }
:root {
    /* The body line: 16px at 1.75. FLUID since the mobile version — 24 on a phone, 28 from 1280, beside
       a body of 14 to 16 (THE FLUID SCALE) — and every tier below is still a whole count of it, so the
       rule holds at every width. The text's own line-height reads this token rather than restating
       1.75, so the text and the gaps cannot drift apart as the two sizes slide. */
    --down-line: clamp(24px, calc(24px + 4 * var(--ramp)), 28px);
    --down-section: calc(var(--down-line) * 7);
    --down-item: calc(var(--down-line) * 4);
    --down-beat: calc(var(--down-line) * 3);
    --down-pair: calc(var(--down-line) * 2);
    --down-para: var(--down-line);
    --down-half: calc(var(--down-line) / 2);
}

/* The bar's air above, as in the band; a section's gap below, so the closing Impact list can come
   up off the bottom edge instead of ending pressed against it. */
.cs-scroll[data-axis="down"] { display: block; overflow-x: hidden; overflow-y: auto;
    overscroll-behavior: contain;
    /* The body face at the body size, so 66ch here is 66ch of the text — see @property above. */
    font-size: var(--body-size); --down-text: 66ch;
    /* The reader's 88px frame is added back here, INSIDE the scroller, so the title opens where it
       did while the text now scrolls all the way to the window's top edge — see the reader rule below. */
    padding-block: calc(var(--cs-pad) + var(--bar-gap)) var(--down-section); }
/* THE DOCUMENT SCROLLS TO THE WINDOW'S EDGE. The reader carries an 88px top frame from when the
   masthead and contents bar sat above the band; with both gone from that row, the frame only moved
   the scroller's top edge down, and text vanished 88px short of the top of the window (measured:
   scroller top 88, Eoin 2026-09-15). The frame moves inside the scroller's padding instead. */
.reader[data-doc="case"]:has([data-axis="down"]) { padding-top: 0; }
.cs-scroll[data-axis="down"] .cs-group { display: contents; }
/* THE CONTENTS BAR IS A COLUMN IN THE TOP-LEFT CORNER (Eoin, 2026-09-15). It was centred over the
   article earlier the same day; stacked on the left it answers the close button in the opposite
   corner — the two pieces of chrome at the two top corners, the document running between them.
   OUT OF THE MASTHEAD'S ROW: placed absolutely, so that row collapses to nothing and the article
   gets its height back. top: --cs-pad, where the close button's top edge sits — the document now
   starts at the window's top, with no frame above it. The text scrolls past the bar and the button
   rather than under them: at any window over ~1200px wide the column clears both. The active word
   still fills as its section is read; the dots between items go, because a column already separates
   its items by line. */
.cs-doc:has(> [data-axis="down"]) { position: relative; }
.cs-doc:has(> [data-axis="down"]) > .cs-head { position: absolute; top: var(--cs-pad); left: var(--cs-pad);
    padding: 0; z-index: 2; }
.cs-doc:has(> [data-axis="down"]) .cs-nav { flex-direction: column; align-items: flex-start;
    gap: 10px; padding: 0; }
.cs-doc:has(> [data-axis="down"]) .cs-nav-item + .cs-nav-item::before { content: none; }

/* ---- The three measures ---- */
/* A column no longer scrolls inside itself. That was the band's release valve for a passage taller
   than the screen; here the document is simply as tall as it needs to be. */
.cs-scroll[data-axis="down"] .cs-col { width: auto; max-width: var(--down-text); margin-inline: auto;
    max-height: none; overflow: visible; }
.cs-scroll[data-axis="down"] .cs-lede { width: auto; max-width: var(--down-text); margin-inline: auto; }
/* THE BODY IS 16px (Eoin, 2026-09-15 — 13 in the band, then 16, 14 and 16 again the same day). Its
   1.75 line is the 28px unit every gap is counted in, and a list takes the same line so an item is
   not set to a different measure from the paragraph beside it. Only here: the band keeps 13.
   Counting line lengths: use real characters (harness gm-down-opening), not an estimate — half an
   em a character undercounts Geist by a fifth, and passed a 110-character line as ~80. */
/* (Since the mobile version the body is --body-size, 14 to 16, on --down-line, 24 to 28 — the line is
   read, not restated as 1.75, so the text's lines and the gaps counted in them cannot drift apart as the
   two slide with the window. At 1280 and up they are 16 on 28, as above.) */
.cs-scroll[data-axis="down"] .cs-p,
.cs-scroll[data-axis="down"] .cs-list { font-size: var(--body-size); line-height: var(--down-line); }
/* Inline code keeps the ratio it inherited before the line became a length (0.92em at 1.75), so its
   inline box is the size it always was rather than inheriting the paragraph's whole 28px. */
.cs-scroll[data-axis="down"] .cs-p code { line-height: 1.75; }
.cs-scroll[data-axis="down"] .cs-p + .cs-p,
.cs-scroll[data-axis="down"] .cs-list + .cs-p,
.cs-scroll[data-axis="down"] .cs-list { margin-top: var(--down-para); }
/* Items are attached to each other: half a line. The last one's collapses into the line after it. */
.cs-scroll[data-axis="down"] .cs-list li { margin-bottom: var(--down-half); }
/* The arrow re-centres on the first line: a 28px line puts a 10px mark at 9 — derived, so a phone's
   24px line puts it at 7. */
.cs-scroll[data-axis="down"] .cs-list li::before { top: calc((var(--down-line) - 10px) / 2); }
/* HEADING 22, LEAD-IN 32, TITLE 45, LABELS 12, against the 16px body, with the heading and the
   lead-in set on the same 28px line — a heading's line is one of them, a lead-in's one and a half
   (Eoin, 2026-09-15). At 26 the lead-in was barely a step above a 22px heading and did not read as
   its own level; at 10 the labels read as fine print beside 16. The title keeps the band's fluid
   floor, scaled by 45/40, and loses the band's 22ch cap so it runs the column's width. A label is
   attached to its heading, so half a line under it; a heading sits a whole line above its text.
   The band keeps its own sizes and spacing.
   ALL FLUID since the mobile version, to 18 / 22 / 30 / 10 on a phone (THE FLUID SCALE). The title's
   own clamp(36px, 4vw, 45px) went with it: its 36px floor was too large for a phone's column. */
.cs-scroll[data-axis="down"] .cs-lede { font-size: var(--lede-size); line-height: calc(var(--down-line) * 1.5); }
/* Balanced, so the title's lines share the width rather than filling two and leaving "Topia" alone
   on a third (Eoin, 2026-09-15). Nothing sets balance page-wide, so it has to be said here — the
   work row's card title says it for itself too. */
.cs-scroll[data-axis="down"] .cs-title { font-size: var(--title-size); max-width: none; text-wrap: balance; }
.cs-scroll[data-axis="down"] .cs-h { font-size: var(--h-size); line-height: var(--down-line); margin-bottom: var(--down-para); }
.cs-scroll[data-axis="down"] .cs-kick { font-size: var(--label-size); margin-bottom: var(--down-half); }
/* Only a figure that is a row's own child. A figure inside a switch fills its pane — given auto
   side margins a grid item shrinks to its content, and a width-driven picture has none. */
.cs-scroll[data-axis="down"] :is(.cs-row > .cs-fig, .cs-figs--pair) {
    width: auto; max-width: var(--down-text); margin-inline: auto; }

/* WIDTH-DRIVEN, the inverse of the band: the column gives a picture its width and the source's own
   ratio gives the height. Every screen here is 16:9 today, but nothing assumes it. The placeholder
   keeps its stated 16:9, which is the shape it stands in for. */
.cs-scroll[data-axis="down"] .cs-frame > :is(img, video) { width: 100%; height: auto; max-width: none; max-height: none; }
.cs-scroll[data-axis="down"] .cs-fill { width: 100%; height: auto; }

/* CAPTIONS AND TABS BACK IN FLOW. The band took them out so every mat could centre on one line.
   There is no line here, and out of flow they would sit inside the gaps that carry the hierarchy —
   a caption would eat into the space after its picture, the tabs into the space above it.
   AND CENTRED ON THEIR PICTURE, with the band's 46ch cap gone (Eoin, 2026-09-15). The cap kept a
   caption from running the length of a 1776px picture in the band; here no picture is wider than
   the column, and a centred caption is measured against the picture it sits under. Both are attached
   to their picture: half a line from it. */
.cs-scroll[data-axis="down"] .cs-fig figcaption { position: static; margin-top: var(--down-half);
    width: auto; max-width: none; text-align: center; }
.cs-scroll[data-axis="down"] .cs-figs--pair > .cs-flow-tabs { position: static; margin-bottom: var(--down-half);
    justify-content: center; }

/* ---- The tiers ----
   ORDER MATTERS: several of these share a specificity, and each later rule is an exception to an
   earlier one. */
.cs-scroll[data-axis="down"] .cs-row > :first-child { margin-top: var(--down-item); }
/* A row that opens on text with no label of its own is the same finding going on, not a new one. */
.cs-scroll[data-axis="down"] .cs-row > .cs-col:first-child:not(:has(> .cs-kick)) { margin-top: var(--down-beat); }
.cs-scroll[data-axis="down"] .cs-row > .cs-col + .cs-col { margin-top: var(--down-para); }
.cs-scroll[data-axis="down"] .cs-row > * + :is(.cs-fig, .cs-figs--pair) { margin-top: var(--down-pair); }
.cs-scroll[data-axis="down"] .cs-row--lede > .cs-lede { margin-top: var(--down-section); }
/* The first finding after a lead-in sits a section gap below it, the same as the space above it, so
   the lead-in is the break between two sections rather than a heading for the first finding. The
   group is the Problems' explicit set. */
.cs-scroll[data-axis="down"] :is(.cs-row--lede + .cs-row, .cs-row--lede + .cs-group > .cs-row:first-child) > :first-child {
    margin-top: var(--down-section); }
.cs-scroll[data-axis="down"] .cs-row--close > .cs-col { margin-top: var(--down-section); }
/* NEXT PROJECT, the last row (built in site.js, see nextProject): a section's gap above it, like the
   closing Impact, then a finding's label and heading — the heading being the next case study's
   title, the drawn arrow after its last word. A <button> reset to read as type; it answers a pointer
   by fading, as the contents bar's items do. */
.cs-scroll[data-axis="down"] .cs-row--next > .cs-col { margin-top: var(--down-section); }
.cs-next-link { display: block; padding: 0; border: 0; background: none; color: inherit; font: inherit;
    text-align: left; cursor: pointer; transition: opacity 150ms ease-out; }
.cs-next-link .cs-next-title { display: inline; margin: 0; }
.cs-next-ar { display: inline-block; vertical-align: -0.04em; }
@media (hover: hover) and (pointer: fine) { .cs-next-link:hover { opacity: 0.6; } }
@media (prefers-reduced-motion: reduce) { .cs-next-link { transition: none; } }

/* ---- The opening, stacked ----
   Title, then the carousel, then Context, Relevance and Impact one under the next — all in the one
   column, so the document has one shape from its first line (Eoin, 2026-09-15, over keeping the
   band's side-by-side masthead; the text was three across, then two, the same day).
   A GRID STILL, because the carousel is last in the markup and sits second on the page. Title to
   carousel is two lines, carousel to Context three, and Context, Relevance and Impact three apart.
   After the tiers, and overriding them wholesale: nothing in here opens a finding. */
.cs-scroll[data-axis="down"] .cs-row--lead { display: grid; height: auto;
    max-width: var(--down-text); margin-inline: auto;
    grid-template-columns: minmax(0, 1fr); grid-template-rows: none;
    align-items: start; }
.cs-scroll[data-axis="down"] .cs-row--lead > .cs-lead-head { grid-row: 1; grid-column: 1 / -1;
    align-self: auto; margin: 0 0 var(--down-pair); }
.cs-scroll[data-axis="down"] .cs-row--lead > .cs-fig--lead { grid-row: 2; grid-column: 1 / -1;
    align-self: auto; max-width: none; margin: 0 0 var(--down-beat); }
/* .cs-row.cs-row--lead, doubled on purpose: the passage rule above (.cs-col + .cs-col) outranks a
   single class here and pushed Impact 20px down. */
.cs-scroll[data-axis="down"] .cs-row.cs-row--lead > .cs-col { width: 100%; max-width: var(--down-text);
    justify-self: center; margin: 0; }
.cs-scroll[data-axis="down"] .cs-row.cs-row--lead > .cs-col--stack { grid-row: 3; }
.cs-scroll[data-axis="down"] .cs-row.cs-row--lead > .cs-col:not(.cs-col--stack) { grid-row: 4;
    margin-top: var(--down-beat); }
/* The band's .cs-block gap is 64; on the line it is three. */
.cs-scroll[data-axis="down"] .cs-row--lead .cs-block + .cs-block { margin-top: var(--down-beat); }
/* Still the card's 16:9, but taken from the width now rather than from the band's picture height. */
.cs-scroll[data-axis="down"] .cs-fig--lead .cs-frame { height: auto; aspect-ratio: 16 / 9; }

/* ---- World Creation Flow's own objects ----
   WCF READS DOWN TOO (Eoin, 2026-09-15: "match the gallery mode"). Three of its objects were only
   ever drawn for the band, and each needs a column version. Measured in the article before these
   rules: the stats were set at 11/9/17px beside a 16px body, the annotated screenshot ran 708px wide
   in a 567px mat (its height came from the band's picture height), and the five-step table ran
   725px in the same 567. Harness: wcf-down-parts, and gm-down with CARD="create world flow". */

/* THE STATS ON THE LINE. The label at the body's size, its description a step below, the figure at
   the lead-in's 32 — the result is the headline of its passage, not a footnote to it (Eoin,
   2026-09-15: scaled up to fit). A line from the sentence that introduces them, a line apart. */
.cs-scroll[data-axis="down"] .cs-stats { gap: var(--down-para); margin-top: var(--down-para); }
.cs-scroll[data-axis="down"] .cs-stat { row-gap: 0; column-gap: var(--down-pair); }
.cs-scroll[data-axis="down"] .cs-stat-k { font-size: var(--body-size); line-height: var(--down-line); }
.cs-scroll[data-axis="down"] .cs-stat-d { font-size: var(--stat-d-size); line-height: 1.5; }
.cs-scroll[data-axis="down"] .cs-stat-v { font-size: var(--lede-size); line-height: calc(var(--down-line) * 1.5); }

/* THE ANNOTATED DOTS, WIDTH-DRIVEN. The band took the shot's height from its shared picture height
   (0.45 of --img-h), a number with nothing to answer to here. It takes the mat's width instead and
   its declared ratio gives the height; the ring and the leader are percentages of the shot, so they
   follow. Less 2px: the shot is content-box, with its 1px side borders outside the width. */
.cs-scroll[data-axis="down"] .cs-fig--anno .cs-shot { width: calc(100% - 2px); height: auto; }

/* THE STEP TABLE FITS THE COLUMN. Its cells are as wide as their labels, so it cannot take a width
   the way a picture does: at the band's 44px between steps and 64px of mat it ran off the paper.
   Between steps comes down to 20, the mat's sides to one line, and the table centres in what is left. */
.cs-scroll[data-axis="down"] .cs-fig--flow .cs-frame { --fp-l: var(--down-line); --fp-r: var(--down-line); }
.cs-scroll[data-axis="down"] .cs-fig--flow .cs-fcell { padding-right: 20px; }
.cs-scroll[data-axis="down"] .cs-fig--flow .cs-flow { margin-inline: auto; }
/* ITS TABS ABOVE IT, IN FLOW AND CENTRED, as the pair's are. site.js appends them after the caption
   (see buildFlowCmp), so the figure becomes a column and the tabs are ordered first. */
.cs-scroll[data-axis="down"] .cs-fig--flow { display: flex; flex-direction: column; }
.cs-scroll[data-axis="down"] .cs-fig--flow > .cs-flow-tabs { order: -1; position: static; align-self: center;
    margin: 0 0 var(--down-half); }

@media (prefers-reduced-motion: reduce) {
    .cs-nav-item { transition: none; }
    /* The close button keeps its opacity fade — it has to arrive somehow — but loses the scale.
       A control that grows under the pointer is exactly the kind of motion this query is for. */
    .reader-close { transition: opacity 260ms ease 420ms; }
    .reader-close:hover, .reader-close:active { transform: none; }
}

/* A WEBSITE'S ARROW AT FULL STRENGTH WHERE NOTHING HOVERS (2026-09-15). It is faint at rest so a hover
   can answer; on a touch screen there is no hover to answer with, so faint was all it would ever be. */
@media (hover: none) { .work-page[href] .wp-text::after { opacity: 1; } }

/* =========================================================================================
   THE NARROW SCREEN — phones and upright tablets, under 64rem (mobile version, 2026-09-15)

   ONE BREAKPOINT for all of it, the same number site.js asks (`narrow`). Written as max-width
   63.99rem, not a range query: iOS Safari before 16.4 cannot parse `width < 64rem`, and a query a
   browser cannot parse matches nothing, so those phones would have been handed the desktop layout.
   Sizes and gaps are not set here — they already slide with the window (THE FLUID SCALE). This block
   only changes what is ARRANGED differently: the menu, the row, the pages' gutters, a case study's
   contents and close, and World Creation Flow's step table.
   ========================================================================================= */
@media (max-width: 63.99rem) {
    /* ---- THE MENU ON A NARROW SCREEN ------------------------------------------------------------
       A bar across the top — the name, and a two-line button — and a sheet that covers the window.
       .menu GIVES UP EVERY PROPERTY THAT WOULD TRAP ITS FIXED CHILDREN. A backdrop-filter, or the
       recede's transform while a case study is open, makes an element the containing block for its
       position: fixed descendants — a sheet inside a blurred .menu would be sized to the menu, not to
       the window. So the blur moves to the bar, which is the sheet's sibling and cannot trap it. */
    html { scroll-padding-top: var(--bar-h); }
    .menu { display: block; padding: 0; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
    /* The recede is invisible here — a case study's reader is opaque paper over all of it — so it is
       not paid for: scaling a column six cards tall is a large layer to move for nothing. */
    .reader-open .menu, .reader-open .panel { transform: none; }

    .menu-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 12; height: var(--bar-h);
        display: flex; align-items: center; justify-content: space-between;
        /* The button's lines end on the page's right gutter: the 44px box has 12px of air either side. */
        padding: 0 calc(var(--cs-pad) - 12px) 0 var(--cs-pad);
        background: rgba(254, 253, 251, 0.85);
        -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
        translate: none; transition: translate 240ms cubic-bezier(0.23, 1, 0.32, 1), opacity 400ms ease; }
    /* SCROLLED AWAY, and back on the first scroll up — see THE BAR STEPS OUT OF THE WAY in site.js. */
    .menu-bar[data-hidden] { translate: 0 calc(-100% - 1px); }
    /* OVER THE SHEET the bar is plain paper: no blur re-rendering the sheet every frame as it slides
       beneath, and nothing to see through — the sheet is the same paper. */
    html[data-menu] .menu-bar { translate: none; background: var(--page); -webkit-backdrop-filter: none; backdrop-filter: none; }

    /* TWO LINES THAT CROSS INTO AN X. A CSS transition on the lines alone — Motion never touches them.
       The 80% ink is applied ONCE, to the pair: #111c on each line would darken where they cross. */
    .menu-toggle { display: block; position: relative; width: 44px; height: 44px; padding: 0; opacity: 0.8; }
    .menu-toggle-line { position: absolute; left: 12px; top: calc(50% - 0.75px); width: 20px; height: 1.5px;
        background: #111; border-radius: 1px;
        transition: translate 300ms cubic-bezier(0.23, 1, 0.32, 1), rotate 300ms cubic-bezier(0.23, 1, 0.32, 1); }
    .menu-toggle-line:first-child { translate: 0 -4px; }
    .menu-toggle-line:last-child  { translate: 0 4px; }
    .menu-toggle[aria-expanded="true"] .menu-toggle-line:first-child { translate: 0 0; rotate: 45deg; }
    .menu-toggle[aria-expanded="true"] .menu-toggle-line:last-child  { translate: 0 0; rotate: -45deg; }

    /* THE SHEET. Motion slides it (site.js, THE MENU ON A NARROW SCREEN); here it is only where it
       is and what it holds. Hidden, not removed, while shut, so its links cannot be reached. */
    .menu-body { position: fixed; inset: 0; z-index: 11; display: grid; grid-template-rows: 1fr auto;
        padding: calc(var(--bar-h) + var(--down-pair)) var(--cs-pad) var(--down-pair);
        background: var(--page); visibility: hidden; overflow-y: auto; overscroll-behavior: contain; }
    html[data-menu] { overflow: hidden; }
    html[data-menu] .menu-body { visibility: visible; }
    /* The pages large, on the page's left gutter: 40px on a 1.1 line is a 44px row to tap. The current
       page keeps its bold and its growth, a little less of it at this size. */
    .menu-nav { --nav-grow: 1.2; gap: 8px; }
    .menu-nav a { font-size: 40px; }
    /* The contact links in a row along the foot, each a 44px tall target. */
    .menu-contact { grid-auto-flow: column; justify-content: start; gap: var(--down-para); }
    .menu-contact a { display: flex; align-items: center; min-height: 44px; }

    /* ---- THE WORK ROW, AS ONE COLUMN ----------------------------------------------------------------
       The cards take their WIDTH from the page here, and their frame's 16:9 gives the height — the
       reverse of the row, where one shared height set every width. No loop: the clones stay in the DOM,
       undrawn, so crossing back over the breakpoint needs nothing rebuilt (with no box their offsetLeft
       reads 0, and the loop's set width with it, which switches the wrap off by itself). Capped at
       48rem, about the size a card is on a laptop, so an upright tablet does not get a poster. */
    .panel { display: block; min-height: 0; overflow: visible; }
    /* One line between cards and two under the last (Eoin, from his phone: three and seven were too much).
       Each card's words carry 24px beneath them as well, so the air reads as 48 and 72 on a phone. */
    .pages { flex-direction: column; align-items: center; gap: var(--down-para);
        padding: calc(var(--bar-h) + var(--down-pair)) var(--cs-pad) var(--down-pair); }
    .pages > [data-src] { display: none; }
    .work-page { width: 100%; max-width: 48rem; height: auto; grid-template-rows: auto auto; }
    /* World Creation Flow's figures go under its title: beside it, the title had about 100px. */
    .wp-text:has(.wp-stats) { grid-template-columns: minmax(0, 1fr); }
    .wp-stats { grid-column: 1; grid-row: auto; margin-top: var(--wp-title-gap); }
    .wp-stat { justify-items: start; }

    /* ---- THE PAGES' GUTTERS ---------------------------------------------------------------------------
       The page margin at both sides, and the bar's height added above. */
    .view-about { padding: calc(var(--bar-h) + var(--down-pair)) var(--cs-pad) var(--down-section); }
    .view-soon { padding: var(--bar-h) var(--cs-pad) 0; }
    /* A 44px target round each switch tab (the about page's two, a case study's pairs), without moving
       the half-line rhythm around them. */
    .cs-flow-tab { position: relative; }
    .cs-flow-tab::after { content: ""; position: absolute; inset: -14px -8px; }

    /* ---- A CASE STUDY: THE STRIP ------------------------------------------------------------------------
       The contents column in the top-left corner becomes a strip across the top: the section being read,
       with its progress filling the word as on a wide screen, and the close button at the right. Tapping
       the strip drops every section down beneath it. The strip and the close leave together on a scroll
       down and come back on a scroll up, as the menu bar does (site.js, buildDoc). The page behind cannot
       scroll while one is open — on iOS a touch on the strip would otherwise scroll it. */
    html:has(> body.reader-open) { overflow: hidden; }
    /* Three lines under Next project rather than a section's seven (Eoin, from his phone). */
    .cs-scroll[data-axis="down"] { padding-block-start: calc(var(--strip-h) + var(--bar-gap)); scroll-padding-top: var(--strip-h);
        padding-block-end: var(--down-beat); }
    .cs-doc:has(> [data-axis="down"]) > .cs-head { top: 0; left: 0; right: 0; height: var(--strip-h);
        display: flex; align-items: center; padding: 0 calc(var(--cs-pad) + 44px) 0 var(--cs-pad);
        background: var(--page); box-shadow: 0 1px 0 var(--img-line);
        translate: none; transition: translate 240ms cubic-bezier(0.23, 1, 0.32, 1); }
    .reader[data-strip-hidden] .cs-doc > .cs-head { translate: 0 calc(-100% - 1px); }

    /* THE CONTENTS AS A ROW (Eoin, from his phone — it was a "01 Summary" button opening a list). Every
       section across the strip, the one being read filling as on a wide screen. Wider than the strip on a
       small phone, it scrolls sideways, and site.js brings the current section into view as it changes.
       NO PADDING ON THE ITEMS: the fill is a gradient across each item's box, so a padded box would put the
       progress somewhere other than the word. The row's gap spaces them; every word is already wider than
       44px, and the row is taller than that. */
    /* SCROLLED, THE ROW FADES AT ITS ENDS rather than cutting a word off at the page's gutter. 12px of
       padding either side holds the fade, and the row pulls 12px left so the first word still starts on
       the text's left edge when nothing is scrolled — where the fade then covers only empty padding. */
    .cs-doc:has(> [data-axis="down"]) .cs-nav { flex-direction: row; align-items: stretch; gap: 20px;
        position: relative; flex: 1 1 auto; min-width: 0; width: auto; height: 100%;
        margin-left: -12px; padding: 0 0 0 12px;
        overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none;
        -webkit-mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
                mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent); }
    .cs-doc:has(> [data-axis="down"]) .cs-nav::-webkit-scrollbar { display: none; }
    /* THE FADE'S ROOM AT THE FAR END is an empty item, not padding: a scrolling flex row does not reliably
       keep its trailing padding, and the last word ended inside the fade. The row's 20px gap less 8 is 12. */
    .cs-doc:has(> [data-axis="down"]) .cs-nav::after { content: ""; flex: 0 0 0; margin-left: -8px; }
    .cs-doc:has(> [data-axis="down"]) .cs-nav-item { flex: 0 0 auto; display: flex; align-items: center;
        min-height: 44px; padding: 0; white-space: nowrap; }

    /* THE CLOSE BUTTON SITS IN THE STRIP, still a child of the reader (a child of the strip would be
       torn down with each document). Its disc keeps its size; an invisible 44px ring round it is the
       target. It leaves with the strip on a scroll down. */
    .reader[data-doc="case"] .reader-close { top: calc((var(--strip-h) - 34px) / 2); right: var(--cs-pad);
        transition: opacity 260ms ease 420ms, box-shadow 150ms ease-out, transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
                    translate 240ms cubic-bezier(0.23, 1, 0.32, 1); }
    .reader-close::after { content: ""; position: absolute; inset: -5px; }
    .reader[data-strip-hidden] .reader-close { translate: 0 calc(-1 * var(--strip-h)); }

    /* ---- WORLD CREATION FLOW'S STEP TABLE, RESTACKED ---------------------------------------------------
       Five steps across does not fit a phone at any size, so the table turns on its side: the plans named
       once across the top, then a row per step with the step's name in a column of its own on the left
       (Eoin, from his phone — it had been each name over its values). The cells are the same elements;
       site.js writes each one's place (--nr, --nc, and --plans on the table) when it builds the header,
       because the transposition depends on each table's own step count. Every cell keeps its rule, so each
       row's line runs unbroken across the names and the plans. */
    .cs-scroll[data-axis="down"] .cs-fig--flow .cs-flow { width: 100%; }
    .cs-scroll[data-axis="down"] .cs-flowtable { grid-template-columns: max-content repeat(var(--plans, 1), minmax(0, 1fr)); }
    .cs-scroll[data-axis="down"] .cs-fig--flow .cs-fcell { grid-row: var(--nr); grid-column: var(--nc); padding: 12px 12px 12px 0; }
    .cs-scroll[data-axis="down"] .cs-fig--flow .cs-fcell--head { padding: 12px 16px 12px 0; justify-content: center; }
    .cs-scroll[data-axis="down"] .cs-fig--flow .cs-ft { white-space: normal; }
    /* THE MONO LABELS FADE BY COLOUR HERE, NOT OPACITY. Opacity faded each label cell's rule along with
       its letters, so every row's line looked broken where it passed under a step's name. 45% of the ink
       is the same grey the opacity gave. */
    .cs-scroll[data-axis="down"] .cs-fig--flow :is(.cs-fcell--head, .cs-fcell--label) { opacity: 1;
        color: color-mix(in srgb, var(--black) 45%, transparent); }

    /* PROBLEM 02'S STEPS ON ONE LINE EACH (Eoin, from his phone). The labels slide down to 10px (see
       .cs-step-l) and the steps close up to 16px apart — any closer and the arrows between them touch the
       words. The arrows sit in the middle of that gap: a third of the bar less a sixth of the gap, and two
       thirds plus a sixth. "CONFIRMATION SCREEN" is wider than its column at any readable size and keeps
       its two lines. */
    .cs-stepbar { gap: 16px; }
    .cs-stepbar .cs-step:nth-child(2)::before { left: calc(33.333% - 16px / 6); }
    .cs-stepbar .cs-step:nth-child(3)::before { left: calc(66.666% + 16px / 6); }
}
@media (max-width: 63.99rem) and (prefers-reduced-motion: reduce) {
    .menu-bar, .menu-toggle-line,
    .cs-doc:has(> [data-axis="down"]) > .cs-head { transition: none; }
    .reader[data-doc="case"] .reader-close { transition: opacity 260ms ease 420ms; }
}

/* =========================================================================================
   THE INTRO, AND THE SITE ASSEMBLING (2026-09-15)

   A visit opens on one paragraph and ENTER PORTFOLIO — the holding page's voice — and then the site
   puts itself together: the menu's words in from the left, the cards on screen in from the right one
   after another (rising, on a phone). Once per visit; <head> decides whether, before first paint,
   by setting html.has-intro. site.js runs the rest (THE INTRO in site.js).

   THE STATES, in order:
     has-intro                 the intro covers the page; the site underneath is not drawn at all
     has-intro is-assembling   Enter pressed: the site is drawn again beneath the fading intro, with the
                               pieces that will move already at their start (inline, from site.js)
     is-assembling menu-in     the intro is gone; the menu's panel fades in on its own transition while
                               its words and the cards travel
     (none)                    at rest; the one carousel starts
   ========================================================================================= */
.intro { display: none; }
html.has-intro { overflow: hidden; }
html.has-intro .intro { display: grid; position: fixed; inset: 0; z-index: 40;
    place-content: center; justify-items: center; gap: 40px; padding: 40px 24px;
    background: var(--page); text-align: center; }
/* Not drawn while covered, so nothing of it can show before its pieces are at their start. It is still
   laid out, so site.js can measure which cards are on screen. */
html.has-intro:not(.is-assembling) .shell { visibility: hidden; }

/* The holding page's statement: the serif at 24 on a balanced, centred measure, 18 on a phone. */
.intro-say { margin: 0; font-family: var(--font-display); font-weight: 300; font-size: 24px;
    line-height: 1.25; letter-spacing: -0.01em; max-width: 32ch; text-wrap: balance; color: var(--black); }
/* The way in reads as an overline — the site's small mono caps — with a 44px target round it. */
/* font-weight 400 said outright: a button inherits the body's 300, and it only drew at regular because Geist
   Mono is loaded at 400 alone — loading a lighter cut would silently thin it. */
.intro-enter { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px;
    font-family: var(--font-geist-mono); font-weight: 400; font-size: var(--label-size); text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--black); }
/* The return key's mark between brackets, drawn like the site's other arrows but a LIGHTER STROKE — 1.15 of
   16 units, not their 1.6 (Eoin, 2026-09-15: at 1.6 it was heavier than the letters). TIGHT TO THE BRACKETS (Eoin,
   2026-09-15): the drawing's box is cropped to its own ink (the viewBox in home.html), so the only air
   between mark and bracket is this hair of gap, not the empty corners of a 16-unit square. */
.intro-key { display: inline-flex; align-items: center; gap: 0.08em; margin-left: 0.5em; }
.intro-key svg { width: auto; height: 0.85em; display: block; }
@media (hover: hover) and (pointer: fine) {
    .intro-enter { transition: opacity 150ms ease-out; }
    .intro-enter:hover { opacity: 0.6; }
}
/* THE TWO PARAGRAPHS ARE ONE STATEMENT, so they sit a line of their own type apart (1em, as on the holding
   page) rather than the intro's 40px between the words and the button. */
.intro-say + .intro-say { margin-top: calc(1em - 40px); }
@media (max-width: 40rem) {
    html.has-intro .intro { gap: 32px; }
    .intro-say { font-size: 18px; max-width: 34ch; }
    .intro-say + .intro-say { margin-top: calc(1em - 32px); }
}

/* ITS OWN ARRIVAL — the holding page's: each part fades up 12px, the button a beat after the words, and
   only once a frame has been painted (site.js adds .is-in two frames in), so it starts from a state that
   is on screen rather than hoped for. No blur: soft type on this site reads as a fault. */
html.has-intro .intro:not(.is-in) > * { opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
    .intro.is-in > * { animation: intro-fade 600ms linear both, intro-rise 600ms cubic-bezier(0.23, 1, 0.32, 1) both; }
    .intro.is-in > :nth-child(2) { animation-delay: 100ms; }
    .intro.is-in > :nth-child(3) { animation-delay: 200ms; }
}
@media (prefers-reduced-motion: reduce) {
    .intro.is-in > * { animation: intro-fade 200ms ease both; }
}
@keyframes intro-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes intro-rise { from { transform: translateY(12px); } to { transform: none; } }

/* THE MENU'S PANEL WAITS, then fades in on the transition it already has. The panel itself never
   travels: it is frosted, and a moving backdrop blur is redone every frame. Its words travel instead.
   On a phone it is the bar that fades — .menu there has no box of its own, and an opacity on it would
   make it a stacking context that the cards could paint over for the length of the fade. */
@media (min-width: 64rem) { html.is-assembling:not(.menu-in) .menu { opacity: 0; } }
@media (max-width: 63.99rem) { html.is-assembling:not(.menu-in) .menu-bar { opacity: 0; } }
