@import url("tokens.css");

/* ==========================================================================
   site.css — layout and components.
   Read this top to bottom; it is ordered the way the page is built.

     1. Fonts
     2. Reset and base
     3. Utilities
     4. Page shell: the two stacked pages, header, curtain
     5. The bento grid
     6. Cards: the base object, its surfaces, its sizes
     7. Card content patterns (hero, stat, work, writing, contact)
     8. Footer
     9. The sheet (case studies and articles)
    10. Focus and reduced motion

   Every colour, size and duration comes from tokens.css. If you want to
   restyle the site, go there first.
   ========================================================================== */


/* 1. FONTS =================================================================
   Self-hosted, Latin subset, so there is no CDN and no third-party request.
   Raleway is one variable file covering weights 400-800.
   The serif is only used inside articles, so it is NOT declared here - see
   the @font-face block in the <head> of article pages.
   To add accented Latin (ł, ž, ș...), download the "latin-ext" subset from
   Google Fonts and add a second @font-face with its unicode-range.
   ========================================================================== */

/* --- The typography chooser's families ---------------------------------
   Declared here, downloaded never - until one is chosen. A browser fetches
   a font file only when a glyph actually renders in it, so these five cost
   a normal visit nothing at all. Self-hosted rather than pulled from a CDN:
   PRD section 5 rules out web fonts from CDNs, and a visitor who tries the
   tool should not have their address handed to anyone.

   See js/eggs.js for the tool and [data-type] below for what each one
   changes.
   ----------------------------------------------------------------------- */
@font-face {
  font-family: "Inter";
  src: url("../fonts/inter-latin.woff2") format("woff2");
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}
@font-face {
  font-family: "Cormorant Garamond";
  src: url("../fonts/cormorant-garamond-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("../fonts/jetbrains-mono-latin.woff2") format("woff2");
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}
/* Instrument Serif ships Regular only. Nothing asks it for a bold - see
   [data-type="instrument"] below, which keeps its headings at 400 rather
   than letting the browser smear a synthetic one. */
@font-face {
  font-family: "Instrument Serif";
  src: url("../fonts/instrument-serif-latin.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}
@font-face {
  font-family: "Instrument Serif";
  src: url("../fonts/instrument-serif-latin-italic.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}
@font-face {
  font-family: "Instrument Sans";
  src: url("../fonts/instrument-sans-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}

@font-face {
  font-family: "Raleway";
  src: url("../fonts/raleway-var-latin.woff2") format("woff2");
  font-weight: 400 800;         /* variable range */
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+2122, U+2212;
}


/* 2. RESET AND BASE ========================================================
   Small on purpose. Only the things that actually bite.
   ========================================================================== */

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

/* Selected text, everywhere. Written against the tokens rather than fixed
   colours so a surface can change the pair for its own ground - which the
   orange cards do, below, because an orange highlight on an orange card
   would be no highlight at all. ::selection does not inherit the way a
   normal element does: the pair has to reach it from the element the text
   belongs to, which is why these are custom properties and not literals. */
::selection {
  background: var(--select-bg);
  color: var(--select-ink);
}

/* Remove default margins; every gap on this site is deliberate. */
body, h1, h2, h3, p, figure, blockquote, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { list-style: none; padding: 0; }

html {
  /* rem base stays at the browser default so user font-size settings work. */
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  /* --backdrop shows in exactly two places: the gap between the two pages,
     and the rounded corners where they meet. Everything else is covered by
     one page or the other. No colour transition here - the curtain does all
     the recolouring, in one sweep. */
  background: var(--backdrop);
  color: var(--ink);
  font-family: var(--font-ui);
  font-variant-numeric: var(--figures);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: 400;
  /* No fi, fl, ff. Raleway ships liga and the subset keeps the glyphs, so
     they were firing by default; this turns them off for every face the
     type tool can reach, since the setting inherits. no-common-ligatures
     rather than none: that would also kill contextual alternates, which
     are spacing rather than decoration and which the mono relies on. */
  font-variant-ligatures: no-common-ligatures;
  min-height: 100svh;
}

/* --backdrop is the colour that shows BETWEEN the two halves of the home
   page. A page with no layers has no gap - the backdrop is the page - so it
   wants the page colour instead. Without this, switching a standalone page
   to the light palette leaves near-black ink on a near-black ground. */
body:not(:has(.layer)) { background: var(--bg); }

/* The edge-to-edge galleries are sized in vw, which counts the scrollbar
   when the content box does not - a few px of overshoot on any system with
   classic scrollbars, and a horizontal scrollbar on a page that should
   never have one. This absorbs it. Clip rather than hidden on purpose:
   hidden would make this a scroll container and break the sticky TOC. */
body { overflow-x: clip; }

/* The browser hides [hidden] with display:none, but that is a UA rule and
   any author `display` beats it - .card is display:flex, so hiding a card
   by its property alone does nothing at all. This puts it back, for every
   element that sets its own display. */
[hidden] { display: none !important; }

img, picture, svg { display: block; max-width: 100%; height: auto; }

a { color: inherit; }

/* The browser's own rule for these is `bolder`, which is RELATIVE to the
   parent: 700 inside body text, 900 inside a heading. Left alone it would be
   the heaviest thing on the page now that nothing else goes above 600 -
   emphasis outweighing the headings it sits under. One fixed weight instead. */
strong, b { font-weight: 600; }

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

/* Tabular numerals everywhere a number is a number, not a word. */
.num { font-variant-numeric: var(--figures) tabular-nums; }


/* 3. UTILITIES ============================================================= */

/* Visible only to screen readers. Used for headings that the design carries
   visually but that the document outline still needs. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Skip link: first thing in the tab order, invisible until focused. */
.skip-link {
  position: absolute;
  top: var(--gap);
  left: var(--gap);
  z-index: 100;
  padding: 0.75rem 1.25rem;
  background: var(--accent);
  color: var(--ink-on-accent);
  border-radius: var(--radius-pill);
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-200%);
  transition: transform var(--dur-fast) var(--ease-out);
}
.skip-link:focus-visible { transform: translateY(0); }

/* A link that covers its whole card. The card itself is position:relative. */
.stretched-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}


/* 4. PAGE SHELL ============================================================
   Two complete pages, stacked.

   Each .layer holds its own header, its own grid and its own footer, and
   paints its own background. They sit in the same grid cell, so the document
   is as tall as the taller of them and the shorter simply has empty space below.

   The dark one is clipped from the left by --curtain. Because that single
   inset covers the whole page - background, header, cards, footer - the
   recolour happens in one sweep. An earlier version kept the header and
   footer outside the curtain and they flipped colour on their own while the
   grid revealed, which read as a bug.

   Only one layer is ever exposed to assistive technology: the hidden one
   carries inert + aria-hidden, so the duplicated header, main and footer
   landmarks never both appear.
   ========================================================================== */

body {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
}

.layer {
  grid-area: 1 / 1;              /* both pages share one cell */
  min-width: 0;
  display: flex;
  flex-direction: column;
  min-height: 100svh;
  background: var(--bg);
  color: var(--ink);
}
.layer__main { flex: 1 0 auto; } /* pushes the footer to the bottom */

/* The two pages are panels, not halves of one sheet. Each is clipped to its
   own side of the seam and rounded on the edge that faces it, so what shows
   between them is the backdrop rather than a hard join. Their far edges stay
   square and full-bleed.

   Dark runs from the left edge of the window to the seam; light from the
   seam plus --seam to the right edge. */
/* Both pages take the SAME pair of corner radii, which is the whole trick:
   whichever side is rounded, both are rounded on it, so the gap between them
   stays an even strip rather than opening into a crescent.

   Which side that is swaps as the curtain crosses - see --round-l and
   --round-r in tokens.css - so the page that dominates always reads as a
   card with a big rounded corner, and the thin one hugs its edge. */
.layer--dark {
  z-index: 1;
  clip-path: inset(0 calc(100% - var(--curtain)) 0 0
                   round var(--round-l) var(--round-r)
                         var(--round-r) var(--round-l));
}
.layer--light {
  /* Pulled back under the dark page by however much the dark corner is
     rounded. Without that, the notch the dark corner vacates would show the
     backdrop instead of the light page, and the seam would read as a line
     drawn through the curve rather than the dark simply ending.

     At the other end --round-r is 0, so this collapses to the plain
     seam + gap and the backdrop shows through as intended.

     It rounds on its LEFT only. Its right edge is the window edge, and
     rounding there would cut a notch out of the far corner and show the
     backdrop through it. */
  clip-path: inset(0 0 0 calc(var(--curtain) + var(--seam) - var(--round-r))
                   round var(--round-l) 0 0 var(--round-l));
}

/* The hidden page is already invisible to a screen reader via aria-hidden;
   this stops a stray click landing on it. */
.layer[inert] { pointer-events: none; }

.wrap {
  width: 100%;
  max-width: var(--page-max);
  margin-inline: auto;
  padding-inline: var(--page-margin);
}

/* --- Header ------------------------------------------------------------- */
.site-header {
  padding-block: var(--header-pad);
}
.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  /* Centre, not baseline. The logo is an image and the nav is small caps,
     so a shared baseline hangs the nav off the bottom of the mark instead
     of lining the two up. */
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
}

/* The wordmark is one SVG lockup: the mark and the name together.
   Set its height and the width follows from the file's own aspect ratio. */
.wordmark { display: flex; }
.wordmark a {
  display: flex;
  text-decoration: none;
}
/* No hover state on the mark. It used to fade to 0.7, which reads as the
   logo changing colour - and it only ever happened on the inner pages,
   because the home page's wordmark is an <h1> with no link to hover. One
   mark, one colour, everywhere. Keyboard users still get the global
   :focus-visible ring. */
.wordmark img {
  height: var(--logo-h);
  /* A fixed box rather than a width that follows each mark's own shape -
     see --logo-w. contain keeps every mark its true proportion inside it,
     and a left object-position pins them all to the same starting edge, so
     swapping one for another moves nothing. */
  width: var(--logo-w);
  object-fit: contain;
  object-position: left center;
}
/* The file is a single flat brown. brightness(0) crushes it to black and
   invert(1) lifts that to white, so one file serves both pages and there is
   no second asset to keep in sync. */
.layer--dark .wordmark img,
[data-side="dark"]:not(:has(.layer)) .wordmark img { filter: brightness(0) invert(1); }

/* The two side buttons. These are the switch on every screen size; on
   desktop the curtain handle does the same job by drag. */
.side-switch { display: flex; gap: 1.5rem; }

.side-switch__btn {
  min-height: var(--tap);
  padding-inline: 0.25rem;
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
  border-bottom: 2px solid transparent;
  transition: color var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out);
}
/* On the home page these are buttons; on a standalone page they are links
   to the grid, and a link brings an underline with it that the buttons
   never had. The only underline here is the one marking the current side. */
.side-switch__btn { text-decoration: none; display: inline-flex; align-items: center; }
.side-switch__btn:hover { color: var(--ink); }
@media (min-width: 80rem) {
  /* The header row comes down to one band of the horizontal ruling. It is
     the nav that sets that row's height, not the mark - the buttons carry
     --tap, so at 44 the row was 44 and the 40px mark sat centred 2px off
     both rules, while the active item's underline landed 4px past the
     lower one.

     At 40 the mark spans rule to rule, the underline sits on the lower
     rule, and the header gives back the 4px that were holding the grid
     below it off a rule as well. Desktop only, and 40px is still well
     clear of the 24px WCAG 2.2 target minimum; --tap stays 44 everywhere
     a finger is likely. */
  .side-switch__btn { min-height: 40px; }
}
/* aria-pressed is the source of truth for the active side - the CSS follows
   the accessibility state rather than a separate class, so they cannot
   disagree. */
.side-switch__btn[aria-pressed="true"] {
  color: var(--ink);
  border-bottom-color: var(--ink);
}

/* --- The curtain itself -------------------------------------------------
   There is no drawn line. The gap between the two panels IS the line, and
   the backdrop shows through it. Only the handle is painted, and it sits in
   the middle of that gap so it reads as the thing holding the pages apart.
   ------------------------------------------------------------------------ */

/* The wrapper is a landmark only; it has no box of its own. */
.curtain { display: contents; }

/* The handle and the pulse are the same object in two layers: one you can
   press, one that radiates underneath it. Everything that decides WHERE
   they are is shared, so they can never drift apart. */
.curtain__handle,
.curtain__ping {
  position: fixed;
  top: 50%;
  /* Centred on the gap, so the control reads as sitting ON the join rather
     than beside it. Clamped so it can never hang off the window, which
     would leave it impossible to grab at the extremes. */
  /* --handle-x is normally just --curtain. The load sweep overrides it to
     the DESTINATION, so that left - a layout property - never changes
     during the intro. See --handle-dx below. */
  left: clamp(calc(var(--handle) / 2),
              calc(var(--handle-x, var(--curtain)) + var(--seam) / 2),
              calc(100% - var(--handle) / 2));
  /* -50% centres it on the seam. --handle-dx is the sweep's offset, added
     here rather than to left because translate is composited: the browser
     counts a left change as content moving unexpectedly (CLS) and a
     translate as nothing at all. It animates back to 0, and 0 is exactly
     where left already put it. */
  translate: calc(-50% + var(--handle-dx, 0px)) -50%;
  width: var(--handle);
  height: var(--handle);
  border-radius: var(--radius-pill);
  transition: scale var(--dur-fast) var(--ease-out),
              translate var(--dur-base) var(--ease-out);
}

/* Below the handle, and out of the way of everything. */
.curtain__ping {
  z-index: 5;
  pointer-events: none;
}

.curtain__handle {
  z-index: 6;
  display: grid;
  place-items: center;
  background: #FFFFFF;
  color: #3B2F28;
  box-shadow: var(--shadow-lift);
  cursor: ew-resize;
  /* Without this the browser claims the horizontal gesture for scrolling
     and the drag never reaches us on a touch screen. */
  touch-action: none;
}
.curtain__handle:hover { scale: 1.08; }
.curtain__handle:active { scale: 0.96; }
.curtain__handle svg { display: block; }

/* --- The filter menu and its merged view --------------------------------
   A small menu that sits above the toolbar, and the one-column-per-card
   layout the filter drops the grid into while it is on.
   ------------------------------------------------------------------------ */
.filter-menu {
  position: fixed;
  /* [popover] comes with inset: 0 from the UA stylesheet, which pins it to
     all four edges - setting bottom alone leaves top: 0 winning and the
     menu at the top of the screen. Clear all four first, then place it. */
  inset: auto;
  left: 50%;
  /* Stacked on top of the dock, derived from the dock's own geometry rather
     than guessed: its offset from the bottom, plus its height (a --tap
     button inside 0.375rem of padding each side), plus a gap. */
  bottom: calc(clamp(1rem, 3vw, 2rem) + var(--tap) + 0.75rem + 0.75rem);
  translate: -50% 0;
  z-index: 11;
  margin: 0;
  gap: 0.25rem;
  padding: 0.3125rem;
  border: 0;
  border-radius: var(--radius-pill);
  background: var(--bg);
  color: var(--ink);
  box-shadow: var(--shadow-lift);
}
/* The display value belongs to the OPEN state only. A closed popover is
   hidden by the UA rule [popover]:not(:popover-open){display:none}, and an
   author `display` on the base defeats it - the menu would sit on screen
   from the moment the page loaded. */
.filter-menu:popover-open { display: flex; }
/* Only for a browser without the popover API: with it, [popover] handles
   both hiding and the top layer. */
.filter-menu:not([popover]) { display: none; }
.filter-menu:not([popover]).is-open { display: flex; }

.filter-menu__btn {
  min-height: var(--tap);
  padding-inline: 1rem;
  border-radius: var(--radius-pill);
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
  background: transparent;
  transition: color var(--dur-fast) var(--ease-out),
              background var(--dur-fast) var(--ease-out);
}
.filter-menu__btn:hover { color: var(--ink); background: var(--card); }
/* aria-pressed is the source of truth for which filter is on, the same way
   the side switch works - the style follows the state, not a class. */
.filter-menu__btn[aria-pressed="true"] { color: var(--bg); background: var(--ink); }

/* While a filter is on, the grid is one flat set of cards rather than three
   hand-tuned columns - the merged set has nothing to do with the columns
   the shuffle dealt. */
/* --- The typography menu -----------------------------------------------
   Same pill and the same place as the filter menu, but a stack rather than
   a row, with each option set in the face it offers. That is what makes it
   a type chooser rather than a list of names - and it is why opening this
   menu is the moment the five files are fetched, not before.
   ------------------------------------------------------------------------ */
.type-menu {
  position: fixed;
  /* [popover] arrives with inset: 0 from the UA sheet; clear it before
     placing, or top:0 wins and the menu opens at the ceiling. */
  inset: auto;
  left: 50%;
  bottom: calc(clamp(1rem, 3vw, 2rem) + var(--tap) + 0.75rem + 0.75rem);
  translate: -50% 0;
  z-index: 11;
  margin: 0;
  flex-direction: column;
  min-width: 15rem;
  max-width: calc(100vw - 2rem);
  padding: 0.375rem;
  border: 0;
  border-radius: 14px;
  background: var(--bg);
  color: var(--ink);
  box-shadow: var(--shadow-lift);
}
.type-menu:popover-open { display: flex; }
.type-menu:not([popover]) { display: none; }
.type-menu:not([popover]).is-open { display: flex; }

.type-menu__title {
  margin: 0;
  padding: 0.5rem 0.75rem 0.625rem;
  font-family: var(--font-ui);
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
}
.type-menu__title span { opacity: 0.7; text-transform: none; letter-spacing: 0; }

.type-menu__btn {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  min-height: var(--tap);
  padding: 0.5rem 0.75rem;
  border-radius: 9px;
  text-align: left;
  font-size: 1rem;
  color: var(--ink);
  background: transparent;
  /* A line between each, which is the separator - drawn on the top edge so
     the first option sits straight under the title without one. */
  border-top: 1px solid var(--line);
  transition: background var(--dur-fast) var(--ease-out);
}
.type-menu__btn:hover { background: var(--card); }
/* aria-pressed is the source of truth for the current face, the same way
   the side switch and the filter work. */
.type-menu__btn[aria-pressed="true"] { background: var(--card); font-weight: 600; }

/* The chosen marker, read as a radio rather than a tick after the name. Its
   circle is the toolbar button's treatment - ink fill, page-coloured mark -
   at 1.5rem instead of the full --tap, which is what fits the 28px the row
   leaves between its own padding. The row keeps its height: this sits
   inside it rather than setting it.

   align-self centres the circle on the row while the row itself stays on
   `baseline`, so the specimen names - which have wildly different vertical
   metrics - still sit on one line as before. */
.type-menu__mark {
  flex: none;
  align-self: center;
  width: 1.5rem;
  height: 1.5rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--line);
  color: transparent;            /* the mark shows only once chosen */
  transition: background-color var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
.type-menu__btn[aria-pressed="true"] .type-menu__mark {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--bg);
}
.type-menu__note {
  margin-left: auto;
  font-family: var(--font-ui);
  font-size: var(--fs-label);
  color: var(--ink-muted);
  white-space: nowrap;
}

/* --- The summary ---------------------------------------------------------
   A chat-shaped panel where the cards were. Deliberately plain: a task
   line, the text as it arrives, and a follow-up box that is out of credit.
   No product's name, mark or colours - it mimics the SHAPE of a generation,
   which is the joke, not any particular one.
   ------------------------------------------------------------------------ */
.grid[data-layout="ai"] {
  display: block;
  max-width: var(--measure);
  margin-inline: auto;
}
.grid[data-layout="ai"] .col { display: contents; }

.ai {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;

  /* The rail the timeline hangs on. --ai-rail is the status dot's own width
     plus the gap it keeps from the status text, so indenting the body by it
     lands every paragraph on the same left edge as that text. */
  --ai-dot: 0.5rem;
  --ai-step: 0.625rem;
  --ai-rail: calc(var(--ai-dot) + var(--ai-step));

  /* Dot colours, from the card palette. Chosen per side rather than shared:
     a peach dot disappears on white and a brown one disappears on
     near-black, and an invisible dot is a missing dot, not a quiet one. */
  --dot-a: var(--accent);
  --dot-b: var(--ink-muted);   /* already light on dark and dark on light */
  --dot-c: var(--card-ink);
  /* The first paragraph sits directly under the orange status dot, so it
     takes its own colour rather than repeating it. */
  --dot-first: var(--card-strong);
}
.layer--dark .ai {
  --dot-c: var(--accent-soft);
  /* Brown on near-black is 1.4:1 - a dot nobody can see. The palette's
     counterpart holds the same role on this side. */
  --dot-first: var(--card-tint);
}

/* The task line, with a dot that pulses only while it is working. */
.ai__task {
  display: flex;
  position: relative;
  z-index: 1;                    /* above the rail, which starts behind it */
  align-items: center;
  gap: 0.625rem;
  margin: 0;
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
  min-height: 1.25rem;
}
.ai__dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--accent);
}
.ai.is-working .ai__dot { animation: ai-pulse 1s var(--ease-out) infinite alternate; }
@keyframes ai-pulse { from { opacity: 0.25; } to { opacity: 1; } }

.ai__body {
  font-size: var(--fs-body);
  line-height: var(--lh-lead);
  position: relative;
  padding-left: var(--ai-rail);
}

/* The line itself. It starts level with the status dot above - up through
   the flex gap (1.25rem) and half the task row (0.625rem) - and runs to just
   short of the last paragraph's bottom. */
.ai__body::before {
  content: "";
  position: absolute;
  left: calc(var(--ai-dot) / 2);
  /* Both ends are measured in js/eggs.js: the top from the status dot it
     grows out of, the height from the dot of the last paragraph written so
     far, so the rail ends ON the last dot instead of running past it.
     Measuring both there keeps them from drifting apart. */
  top: var(--rail-top, 0px);
  height: var(--rail-h, 0px);
  width: 1px;
  translate: -0.5px 0;
  background: var(--line);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}
/* Nothing to connect until the first paragraph starts arriving. */
.ai__body.is-railed::before { opacity: 1; }

.ai__line { margin: 0; min-height: 1em; text-wrap: pretty; position: relative; }

/* A dot per paragraph, sitting on the rail and centred on that paragraph's
   first line. js/eggs.js marks which lines open a paragraph: the copy is
   stored pre-wrapped, so "not a blank line" would mean every wrapped line. */
/* Only the first one. Paragraphs after it keep the a/b/c cycle untouched. */
.ai__line--start:first-child::before { background: var(--dot-first); }

.ai__line--start::before {
  content: "";
  position: absolute;
  left: calc(var(--ai-rail) * -1);
  top: calc(0.85em - var(--ai-dot) / 2);
  width: var(--ai-dot);
  height: var(--ai-dot);
  border-radius: 50%;
  background: var(--dot, var(--dot-a));
}
/* The links are the point of the summary, so they look like links. */
.ai__body a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}
.ai__body a:hover { text-decoration-thickness: 2px; }
.ai__line + .ai__line { margin-top: 0.9em; }
.ai__line--gap { min-height: 0.75em; }

/* The caret sits on the last line while it is still arriving. */
.ai.is-working .ai__line:last-child::after,
.ai:not(.is-done) .ai__line:last-child::after {
  content: "";
  display: inline-block;
  width: 0.5em;
  height: 1em;
  margin-left: 0.08em;
  translate: 0 0.16em;
  background: currentColor;
  animation: ai-caret 1s steps(1) infinite;
}
@keyframes ai-caret { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }

/* The follow-up box. It only appears once the summary is finished, the way
   the real thing waits its turn. */
.ai__ask {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.5rem;
  padding: 0.375rem 0.375rem 0.375rem 1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}
.ai.is-done .ai__ask { opacity: 1; }
.ai__input {
  flex: 1;
  min-width: 0;
  min-height: var(--tap);
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 0.9375rem;
}
/* Long enough to be the joke, so it must not be cut off mid-sentence on a
   phone - the placeholder is the punchline. */
.ai__input::placeholder { color: var(--ink-muted); opacity: 1; }
.ai__input:disabled { cursor: not-allowed; }
.ai__send {
  flex: none;
  display: grid;
  place-items: center;
  width: var(--tap);
  height: var(--tap);
  border-radius: 50%;
  color: var(--ink-muted);
  background: var(--card);
}
.ai__send:disabled { cursor: not-allowed; }

/* The punchline. Appears with the box it explains. */
.ai__note {
  margin: 0.5rem 0 0;
  font-size: 0.875rem;
  color: var(--ink-muted);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}
.ai.is-done .ai__note { opacity: 1; }

/* Reduced motion: the text still arrives, the decoration does not. */
@media (prefers-reduced-motion: reduce) {
  .ai.is-working .ai__dot,
  .ai .ai__line:last-child::after { animation: none; }
}

/* While a filter is on there is only one set of cards, drawn from both
   sides at once - so everything whose job is to move you BETWEEN the two
   sides goes away: the handle and its pulse and hint, the seam, and the
   Leader/Designer buttons. The way out is the toolbar: press the filter
   again, or press the option that is already on.

   The listeners in js/eggs.js that drop the filter when the curtain moves
   are left in place. They cannot fire while these are hidden, and they are
   what makes the curtain safe if it is ever shown here again.
   ------------------------------------------------------------------------ */
.is-filtering .curtain__handle,
.is-filtering .curtain__ping,
.is-filtering .curtain__tip,
.is-filtering .curtain__line,
.is-filtering .side-switch { display: none; }

/* The mark goes too - with the nav gone it is the only thing left in the
   header. But it is the page's <h1>, so it is taken out of SIGHT rather
   than out of the document: display:none would leave the page with no
   heading at all, which is what axe's page-has-heading-one is for. Same
   clip .sr-only uses. */
.is-filtering .wordmark {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Masonry, by multi-column rather than by grid. CSS Grid lines rows up
   across the tracks, so a short card leaves a hole under it until the next
   row starts; multicol just fills each column and moves on, which is what
   packs them.

   The home page gets the same effect from three flex columns that the
   shuffle deals into by hand. That cannot be reused here: the filtered set
   is assembled at the moment you press the button, from both sides, so
   there is nothing to deal in advance. The column COUNT matches the grid's
   own breakpoints, so a filtered page still lines up with an unfiltered one.

   Reading order follows the columns - down the first, then the second -
   which is the order the DOM is in, so the tab order matches what you see. */
.grid[data-layout="filter"] {
  display: block;
  column-count: 1;
  column-gap: var(--gap-col);
}
@media (min-width: 40rem) {
  .grid[data-layout="filter"] { column-count: 2; }
}
@media (min-width: 80rem) {
  .grid[data-layout="filter"] { column-count: 3; }
}
.grid[data-layout="filter"] .col { display: contents; }

/* The New badge hangs 16px above its card everywhere else. Here it cannot:
   this layout is a multi-column container, and Chrome clips whatever
   overflows the block-start of the first column box - so a tagged card that
   lands at the top of a column had the badge sliced flat, losing its top
   corners. It is the fragmentation that clips, not an overflow property, so
   no amount of padding or margin on the container reaches it; the only
   fixes are to stop using columns or to stop overflowing upwards.

   Columns earn their place - laid out as a grid instead, the About filter
   grew 14% taller with holes under the short cards - so the badge gives up
   its 16px instead. It still overhangs to the right, which is NOT clipped,
   so it reads as the same object, just sitting on the corner rather than
   over it. */
.grid[data-layout="filter"] .card__tag { top: 0; }
.grid[data-layout="filter"] .card {
  /* A card is one object; it must not be split down the middle by a column
     break. */
  break-inside: avoid;
  /* The gap BETWEEN cards in a column - column-gap only spaces the columns
     themselves, so the vertical rhythm is the card's own margin. */
  margin-bottom: var(--gap);
  /* The cards were dealt heights by the column layout; here they size to
     what is in them. */
  height: auto;
}

/* --- The handle's idle pulse --------------------------------------------
   Two filled discs growing out from under the handle and fading as they go,
   half a cycle apart so one is always on its way out as the next leaves.

   z-index: -1 puts them above the handle's own white but below the arrows,
   which is the whole trick: the icon stays crisp while the pulse reads as
   coming from underneath the object rather than being drawn on top of it.

   transform and opacity only, so it runs on the compositor and costs
   nothing. The global reduced-motion block at the foot of this file already
   stops every ::before and ::after animation, so there is no media query
   here.
   ------------------------------------------------------------------------ */
.curtain__ping::before,
.curtain__ping::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--ping);
  animation: handle-ping var(--ping-time) var(--ease-out) infinite;
}
.curtain__ping::after { animation-delay: calc(var(--ping-time) / -2); }

@keyframes handle-ping {
  from { transform: scale(1);               opacity: 1; }
  to   { transform: scale(var(--ping-size)); opacity: 0; }
}

/* The handle sits ON the seam, so the rings spread across whichever page is
   showing. One colour would be invisible on one of the two - dark rings on
   Leader, light rings on Designer. */
.curtain__ping            { --ping: rgb(17 17 17 / 0.22); }
[data-side="dark"] .curtain__ping { --ping: rgb(255 255 255 / 0.26); }

/* --- The handle's hover hint --------------------------------------------
   A pill saying which way this thing pulls. It hangs off the side you would
   be pulling towards, --tip-gap clear of the handle, and it is drawn like
   the handle itself so the two read as one object rather than a label stuck
   next to a button.

   It never takes a pointer: it sits in the path of one, and intercepting a
   hover would make it flicker as the cursor crossed onto it.
   ------------------------------------------------------------------------ */
.curtain__tip {
  --tip-gap: 10px;

  position: fixed;
  top: 50%;
  /* The same anchor the handle uses, so they cannot drift apart. */
  left: clamp(calc(var(--handle) / 2),
              calc(var(--handle-x, var(--curtain)) + var(--seam) / 2),
              calc(100% - var(--handle) / 2));
  z-index: 6;

  padding: 0.5rem 0.875rem;
  border-radius: var(--radius-pill);
  background: #FFFFFF;
  color: #3B2F28;
  box-shadow: var(--shadow-lift);

  font-size: var(--fs-label);
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;

  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-fast) var(--ease-out),
              visibility 0s linear var(--dur-fast);
}

/* Showing Leader: the handle rests at the left edge and the only way to go
   is right. Translate X of 0 puts the pill's LEFT edge on the handle's
   centre, so the gap is half a handle plus --tip-gap. */
.curtain__tip {
  translate: calc(var(--handle) / 2 + var(--tip-gap) + var(--handle-dx, 0px)) -50%;
}
.curtain__tip-l { display: none; }

/* Showing Designer: the handle is at the right edge, so the pill goes left.
   -100% puts its RIGHT edge on the handle's centre. */
[data-side="dark"] .curtain__tip {
  translate: calc(-100% - var(--handle) / 2 - var(--tip-gap) + var(--handle-dx, 0px)) -50%;
}
[data-side="dark"] .curtain__tip-r { display: none; }
[data-side="dark"] .curtain__tip-l { display: inline; }

.curtain__handle:hover ~ .curtain__tip,
.curtain__handle:focus-visible ~ .curtain__tip {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--dur-fast) var(--ease-out);
}
/* The handle grows 8% under the pointer, which would eat 2px of the gap and
   leave the pill closer than it is on focus. Give it that back. */
.curtain__handle:hover ~ .curtain__tip { --tip-gap: calc(10px + var(--handle) * 0.04); }

/* The moment you start pulling, the hint has served its purpose. Same for
   the load sweep, which is already demonstrating the point. */
:root.is-dragging  .curtain__handle ~ .curtain__tip,
:root.is-intro     .curtain__handle ~ .curtain__tip,
:root.is-sweeping  .curtain__handle ~ .curtain__tip {
  /* Has to out-specify the :hover rule above, which is still matching:
     you are by definition still over the handle when you start pulling. */
  opacity: 0;
  visibility: hidden;
}

/* Idle means idle. The moment the handle is being used - or the load sweep
   is already saying "this thing moves" - the pulse has nothing to add. */
.curtain__handle:hover ~ .curtain__ping::before,
.curtain__handle:hover ~ .curtain__ping::after,
.curtain__handle:focus-visible ~ .curtain__ping::before,
.curtain__handle:focus-visible ~ .curtain__ping::after,
.is-dragging .curtain__ping::before,
.is-dragging .curtain__ping::after,
.is-intro .curtain__ping::before,
.is-intro .curtain__ping::after,
.is-sweeping .curtain__ping::before,
.is-sweeping .curtain__ping::after { animation: none; }

/* No drag below 1024px: a full-width horizontal gesture fights the page,
   and the two header buttons already do the job. */
@media (max-width: 63.99rem) {
  .curtain__line,
  .curtain__ping,
  .curtain__tip,
  .curtain__handle { display: none; }
}

/* 5. THE BENTO GRID =======================================================
   Three columns of stacked cards, not a row-aligned grid.

   Why columns and not one grid: CSS Grid lines rows up across columns, so
   every card in a row ends up the same height and the layout reads as a
   table. The design wants the opposite - each card as tall as its own
   content, and the three columns landing at different heights. Three flex
   columns give that for free, and nothing can ever clip its text.

   The middle column also starts higher than the other two: --col-offset
   pushes the outer pair down, so the top edge is stepped rather than flat.

   Below 1280px the columns dissolve (display: contents) and their cards
   become direct children of the grid, so they reflow into two columns or
   one without any special handling.
   ========================================================================== */

.grid {
  display: grid;
  /* One column, filling the container. --card-max caps a card when there
     are siblings beside it; on its own there is nothing to line up with, so
     capping only leaves a margin the page already has - by 639px it was
     wasting 195px. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--gap) var(--gap-col);
  /* Once the columns hit their cap there is slack left over; centre it
     rather than letting the grid hug the left edge. */
  justify-content: center;
  /* Cards keep their own height instead of stretching to match whatever
     else is in their row. Without this, a short card next to a tall one
     (Presenter beside Writing) inflates into a block of empty colour. */
  align-items: start;
  padding-bottom: clamp(3rem, 8vw, 6rem);
}

@media (min-width: 40rem) {           /* 640px - two columns  */
  .grid { grid-template-columns: repeat(2, minmax(0, var(--card-max))); }
}
@media (min-width: 80rem) {           /* 1280px - three columns */
  .grid { grid-template-columns: repeat(3, minmax(0, var(--card-max))); }
}

/* Two columns pack as COLUMNS rather than as a grid.
   -------------------------------------------------------------------------
   A grid row is as tall as the tallest card in it, so a short card beside a
   tall one leaves a hole under itself until the next row starts. At three
   columns that never happens, because each .col is its own flex column and
   the cards simply stack. At one column there is nothing to pair with. It is
   only this middle range, where the columns dissolve into a two-track grid,
   that the holes appear: measured at 820px, 794px of empty space in a grid
   2100px tall - two fifths of it, with a 493px hole under the shortest card.

   Nothing can be paired by hand to avoid it. The shuffle at the foot of
   index.html deals the cards into slots at load, with a new permutation on
   every visit, so which card sits beside which is not knowable when this is
   written. The layout has to pack whatever it is given.

   Columns do that: a card goes under the one above it and the two columns
   balance themselves. It is the same answer, for the same reason, that the
   filter layout above already reached. What it costs is that `order` stops
   applying - a multi-column box has no items to reorder - so the hero keeps
   the top of the first column, where the shuffle pins it, and the portrait
   flows with everything else instead of heading the second. At one column,
   below 640px, the grid and its order rules are untouched. */
@media (min-width: 40rem) and (max-width: 79.99rem) {
  .grid:not([data-layout]) {
    display: block;
    column-count: 2;
    column-gap: var(--gap-col);
    /* The grid capped each TRACK at --card-max and centred what was left
       over. A multi-column box has no tracks to cap, so the box itself is
       capped and the slack goes to its margins: same width, same place. */
    max-width: calc(var(--card-max) * 2 + var(--gap-col));
    margin-inline: auto;
  }
  .grid:not([data-layout]) .card {
    /* A card is one object; it must not be split down the middle by a
       column break. */
    break-inside: avoid;
    /* column-gap spaces the columns themselves, so the rhythm down a column
       is the card's own margin - the same --gap the grid used. */
    margin-bottom: var(--gap);
  }
  /* The same fragmentation clip the filter layout documents: Chrome slices
     whatever overflows the block-start of a column box, so a card that
     lands at the top of a column had its badge cut flat. It sits on the
     corner here rather than above it. */
  .grid:not([data-layout]) .card__tag { top: 0; }
}

/* Below 1280px a .col is not a box at all: its cards become direct grid
   items and flow on their own. */
.col { display: contents; }

@media (min-width: 80rem) {
  .col {
    display: flex;
    flex-direction: column;
    gap: var(--gap);
    min-width: 0;
  }
  /* The stagger. Only the outer two move, so the middle column rides high. */
  .col--start,
  .col--end { padding-top: var(--col-offset); }
}

/* Reading order once the columns have dissolved. Without this the page
   would read column by column - the whole left column, then the portrait.
   Everything unlisted keeps order 4 and stays in document order. */
@media (max-width: 79.99rem) {
  .card             { order: 4; }
  .card--hero       { order: 1; }
  .card--portrait   { order: 2; }
  .card--stat-lead  { order: 3; }
}

/* One column: a fixed running order.
   -------------------------------------------------------------------------
   Above 640px the arrangement is the point - the shuffle at the foot of
   index.html deals the cards into slots afresh on every visit, and which
   card sits beside which is part of the charm. In one column there is no
   beside: the shuffle only changes what order you scroll through, and a
   portfolio that reorders its own argument each time you open it is not
   charming, it is disorienting. So below 640px the sequence is pinned.

   Keyed on data-card rather than on a class because both layers use the
   same names - one list orders the light side and the dark side at once.
   data-kind="work" catches all seven case cards however many there are.

   The order is the argument: the face, then who he is, then how the work
   splits (80/20 of the work, then 100% of the time), then the work itself,
   then the career, the writing, the talks, and finally how to get in touch.
   Anything new falls to 50 and lands before the contact card, which stays
   last by design. */
@media (max-width: 39.99rem) {
  .grid:not([data-layout]) .card                   { order: 50; }
  .grid:not([data-layout]) [data-card="portrait"]  { order: 1; }
  .grid:not([data-layout]) [data-card="about"]     { order: 2; }
  .grid:not([data-layout]) [data-card="stat-lead"] { order: 3; }
  .grid:not([data-layout]) [data-card="stat-ghost"]{ order: 4; }
  .grid:not([data-layout]) [data-card="stat-time"] { order: 5; }
  .grid:not([data-layout]) [data-kind="work"]      { order: 6; }
  .grid:not([data-layout]) [data-card="cv"]        { order: 7; }
  .grid:not([data-layout]) [data-card="writing"]   { order: 8; }
  .grid:not([data-layout]) [data-card="presenter"] { order: 9; }
  .grid:not([data-layout]) [data-card="contact"]   { order: 99; }

  /* The portrait leads the page now, so it shows whole. The rule above at
     63.99rem crops it to a 4/3 band around the face - right when the photo
     is one card among others partway down, wrong when it is the first thing
     on the screen. 3/4 is the frame the file is shot at (420x560), so cover
     crops nothing: the full picture, full height. */
  .grid:not([data-layout]) .card--portrait img {
    aspect-ratio: 3 / 4;
    object-position: 50% 50%;
  }
}

/* Card sizes: a card is one column wide unless it says otherwise. 2x1 is
   the only wide size the design uses, and only once there are columns to
   span. */
@media (min-width: 40rem) and (max-width: 79.99rem) {
  [data-size="2x1"], [data-size="2x2"] { grid-column: span 2; }
}

/* 6. CARDS =================================================================
   One object, three axes: a surface (colour), a size (data-size) and a
   content pattern (section 7). Add a card by copying _templates/card.html.
   ========================================================================== */

.card {
  --card-bg: var(--card);
  --card-fg: var(--ink);

  /* Makes the card a container, so its contents can be sized in cqw - a
     percentage of THIS CARD's width rather than the window's. The big
     numerals need that: a 1x1 card and a 2x1 card are very different widths
     at the same viewport, and a vw-based size overflows one or looks lost in
     the other. */
  container-type: inline-size;

  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: var(--pad);
  border-radius: var(--radius);
  background: var(--card-bg);
  color: var(--card-fg);
  overflow: hidden;
  /* No shadows on cards - see PRD section 4. */
}

/* --- Surfaces: swap the two local tokens, nothing else ------------------ */
.card--tint        { --card-bg: var(--card-tint);   --card-fg: var(--ink-on-tint);
                     --ink-ghost: var(--ink-ghost-tint); }
.card--strong      { --card-bg: var(--card-strong); --card-fg: var(--ink-on-dark); }
.card--ink         { --card-bg: var(--card-ink);    --card-fg: var(--ink-on-dark); }
.card--accent      { --card-bg: var(--accent);      --card-fg: var(--ink-on-accent); }
.card--accent-soft { --card-bg: var(--accent-soft); --card-fg: var(--ink-on-accent); }
/* The one ground the orange highlight cannot sit on is the orange itself:
   the card's own background and the selection would be the same colour and
   the selection would vanish. Here it inverts - the dark brown becomes the
   ground and the card's orange the text - which keeps the pairing, and its
   5.69:1, exactly the way round the rest of the site has it. */
.card--accent { --select-bg: var(--ink-on-accent); --select-ink: var(--accent); }

/* TRYING OUT: a 1px line on the neutral cards, index pages only. .grid and
   .post-grid exist on nothing but index.html and writing/index.html, so the
   "more work" strips on case studies and articles keep their flat cards.
   The :not() list is the four coloured surfaces above - add a surface there
   and it gets a border unless you add it here too. box-sizing is border-box
   globally, so the line costs 1px of padding, not 2px of grid cell. */
:is(.grid, .post-grid) .card:not(.card--tint, .card--strong, .card--accent, .card--accent-soft) {
  border: 1px solid var(--card-line);
}

/* --- The peek: shots that fan out over a card's edges --------------------
   A few pictures scattered past the card's own bounds, shown on hover and,
   where there is no hover, on tap (js/main.js adds .is-peeking there).

   Two things make this work. The shots live INSIDE the card rather than
   beside it, so the four eggs that hide cards with c.hidden = true - the
   summary, the filter, bento and the stack - take the shots with them and
   need no change. And because they are descendants, :hover on the card
   already covers them: moving onto a shot never counts as leaving.

   .card sets overflow: hidden so image cards can clip to the 40px radius.
   Neither card here holds an image, and .stat__value is sized in cqw so it
   already sits inside the padding, so lifting it costs nothing. */
.card:has(.peek) { overflow: visible; }

/* Raised only while open, so the shots cross the cards around them rather
   than sliding under whichever happens to come later in the DOM. Stays
   below the curtain handle (7) and the toolbar (8). */
.card:has(.peek):focus-within,
.card:has(.peek).is-peeking { z-index: 4; }
@media (hover: hover) {
  .card:has(.peek):hover { z-index: 4; }
}

/* The orbit never runs on a touch screen - it is hover-only, and the stat
   card holds nothing focusable for :focus-within to catch - but the four
   pictures are still laid out, and they are positioned to hang off the card
   on purpose. On a desktop there is room for that; on a phone there is not,
   and they pushed the whole document sideways: measured at 360px the page
   was 408px wide, so every page in the site slid 48px horizontally. Taking
   them out below the one-column breakpoint removes the overflow and spares
   a phone four images it was never going to show. */
@media (max-width: 39.99rem) {
  .peek { display: none; }
}
/* Above that breakpoint the orbit is wanted, but it is drawn to hang off
   the card on purpose and the shuffle can deal that card to the edge of the
   grid, where what hangs off runs past the window: measured across 620-1400px
   it gave the page up to 19px of horizontal scroll, at whichever widths the
   permutation happened to put it on the right. clip stops the decoration
   scrolling the document without stopping it being drawn where there is
   room, and unlike hidden it creates no scroll container, so the sticky
   header and the page's own scrolling are untouched. */
.layer { overflow-x: clip; }

/* A raw URL used as its own link text is one unbreakable word. Two articles
   list their sources that way - 13 of them - and the longest is 428px wide,
   so on a 320px screen it alone pushed the page 124px sideways. anywhere
   lets the browser break inside the token when it has to, and changes
   nothing for ordinary link text, which has spaces to break at. */
.article__body a,
.link-list a { overflow-wrap: anywhere; }

/* The last guard. Everything above fixes a cause; this makes sure a future
   one cannot hand a visitor a sideways-scrolling page. clip rather than
   hidden: it creates no scroll container, so sticky headers, the sticky
   table of contents and the stacked deck all keep working, and the
   galleries keep their own horizontal scrolling because that scrolling
   happens inside them, not on the document. */
body { overflow-x: clip; }

.peek {
  /* How much flatter than a circle the orbit is. 1 is a circle; this is
     roughly the 100% card's own height / width, so the path hugs the card's
     corners the whole way round instead of ballooning above and below it.
     Per-card: set --squash in the markup on a card of another shape. */
  --squash: 0.6;

  position: absolute;
  inset-inline: 0;
  /* Taller than the card by exactly 1 / --squash, and centred on it, so the
     squash below cancels out: a shot's --y lands where it always did. The
     orbit is flattened; the arrangement you tuned by hand is not. */
  top: calc(50% - 50% / var(--squash));
  bottom: calc(50% - 50% / var(--squash));
  /* Flatten. The orbit keyframes keep this and add the turn after it -
     scale first, then rotate, which is what bends the circle into an
     ellipse. Here on its own too, so the arrangement still holds when
     nothing is animating (reduced motion, or no hover at all). */
  transform: scaleY(var(--squash));
  /* Decoration over a card that is not itself a control: the shots must
     never eat a click meant for the page underneath. */
  pointer-events: none;
}

.peek__shot {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: var(--w, 190px);
  max-width: 190px;
  aspect-ratio: 4 / 3;
  border-radius: 14px;
  /* Hidden but laid out, so nothing reflows when they appear. They rise a
     little as they fade in - the same move the sheet makes on its way up,
     on the same --ease-out, so this reads as part of the same site rather
     than an effect of its own. --dur-base is the curtain's speed: slower
     than a hover tint because there is something here to watch. */
  opacity: 0;
  translate: 0 calc(var(--rest) + 10px);
  transition: opacity   var(--dur-base) var(--ease-out),
              translate var(--dur-base) var(--ease-out);

  /* The other half of the squash, so a photograph is never flattened: the
     layer scales the shot's position, this scales the shot back. The two
     cancel exactly, so the picture is drawn at its own proportions.
     --rest is the shift that --squash costs. top: var(--y) anchors the top
     edge, but scaling moves the centre, so every shot creeps up by half its
     own height x (1 - squash). This puts it back, in the shot's own
     pre-squash space - hence the extra / var(--squash). */
  --rest: calc((1 - var(--squash)) / var(--squash) * 50%);
  transform: scaleY(calc(1 / var(--squash)));
}

/* Placeholder fills, straight from the palette, plus a hairline so a shot
   still reads as its own rectangle when it lands on a card of the same
   colour - peach on the peach card, orange on the orange one. Both the
   fills and the hairline are scaffolding: swap the <span> for an <img> and
   none of this applies. (Not a shadow on a card - PRD section 4 - it is an
   inset rule on the placeholder itself.) */
.peek__shot { box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.12); }

/* A real photograph keeps its own proportions. The 4/3 above is the
   placeholder's shape; three of the four on the 100% card are portrait,
   and a magazine cover cropped to landscape is no longer a cover. The
   width still comes from --w, so the arrangement is unchanged - only the
   height follows the picture now. */
.peek__shot:has(img) {
  aspect-ratio: auto;
  height: auto;
  background: none;
  overflow: hidden;
}
.peek__shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: inherit;
}
.peek__shot:nth-child(4n + 1) { background: var(--accent); }
.peek__shot:nth-child(4n + 2) { background: var(--card-tint); }
.peek__shot:nth-child(4n + 3) { background: var(--card-strong); }
.peek__shot:nth-child(4n + 4) { background: var(--accent-soft); }

.card:focus-within .peek__shot,
.card.is-peeking .peek__shot { opacity: 1; translate: 0 var(--rest); }

/* Hover only where hovering is real. A touch browser leaves :hover stuck on
   whatever was tapped last, so without this the shots stay up after the tap
   that was meant to put them away - the second tap looks dead. */
@media (hover: hover) {
  .card:hover .peek__shot { opacity: 1; translate: 0 var(--rest); }
}

/* They arrive one after another rather than all at once. */
.peek__shot:nth-child(2) { transition-delay: 70ms; }
.peek__shot:nth-child(3) { transition-delay: 140ms; }
.peek__shot:nth-child(4) { transition-delay: 210ms; }

/* --- The orbit ---------------------------------------------------------
   While the shots are up they drift round the card together, as one
   arrangement rather than four separate pictures.

   Two animations, not one. The container turns, which is what carries the
   shots around; each shot turns the same amount the other way, which is
   what keeps the photographs upright - without it they tumble, and a
   tumbling magazine cover reads as a bug. Same duration and the same
   linear timing on both, started by the same selectors, so they stay in
   step whatever the shots' own staggered fade is doing.

   Only while open. An animation left running on something invisible is
   work the compositor does for nobody, and on this page that is the kind
   of thing that costs frames elsewhere. */
/* scaleY then rotate: the turn happens in flattened space, so each shot
   travels an ellipse rather than a circle. The shot's own keyframes are the
   exact inverse, in the opposite order, which is what leaves it upright and
   undistorted while its position goes round. */
@keyframes peek-orbit {
  from { transform: scaleY(var(--squash)) rotate(0turn); }
  to   { transform: scaleY(var(--squash)) rotate(1turn); }
}
@keyframes peek-upright {
  from { transform: rotate(0turn)  scaleY(calc(1 / var(--squash))); }
  to   { transform: rotate(-1turn) scaleY(calc(1 / var(--squash))); }
}

/* The animation is always ON the elements and starts PAUSED; pointing at
   the card only sets it running. It used to be added on hover and removed
   again, which meant that on the way out the shots snapped back to where
   the orbit began and faded from there - a jump, right at the moment you
   stopped looking. Paused, they hold exactly where they were and fade from
   that spot, and the next hover picks the orbit up rather than restarting
   it.

   Paused at 0% the keyframe is identical to the static transform above, so
   nothing moves at rest, and a paused animation produces no frames - the
   compositor is not doing work for an invisible thing. */
.peek       { animation: peek-orbit 40s linear infinite paused; }
.peek__shot { animation: peek-upright 40s linear infinite paused; }

.card:focus-within .peek,
.card:focus-within .peek__shot,
.card.is-peeking .peek,
.card.is-peeking .peek__shot { animation-play-state: running; }
@media (hover: hover) {
  .card:hover .peek,
  .card:hover .peek__shot { animation-play-state: running; }
}

@media (prefers-reduced-motion: reduce) {
  .peek__shot { translate: 0 var(--rest); transition-delay: 0s; }
  /* No drift either - the shots simply appear where they are. */
  .peek, .peek__shot { animation: none !important; }
}

/* --- Interactive cards --------------------------------------------------- */
/* The ring: what a card does when you point at it or tab to it. It stays
   put and a 2px orange ring is drawn 8px outside it.

   An outline rather than a border, for three reasons: it is painted outside
   the box so nothing reflows, it follows the 40px radius on its own, and it
   is not clipped by an ancestor's overflow the way a pseudo-element would
   be. The ring is always there and only its colour changes, so it can fade
   in - outline-style cannot be animated, outline-color can.

   Hover and focus draw the SAME ring, deliberately: they are never really
   simultaneous, and a keyboard user is owed the mark a pointer gets. It
   lands on the page behind the card - white on one side, #131313 on the
   other - where this orange is 3.06:1 and 5.86:1, both clear of the 3:1
   that 1.4.11 asks of a focus indicator.

   Hover sits behind (hover: hover) because a touch browser leaves :hover on
   whatever was tapped last and the ring would stay up after the tap. Focus
   does not: a keyboard can be attached to anything. */
.card--link {
  outline: 2px solid transparent;
  outline-offset: 8px;
  transition: outline-color var(--dur-fast) var(--ease-out);
}
.card--link:has(a:focus-visible) { outline-color: var(--accent); }
@media (hover: hover) {
  .card--link:hover { outline-color: var(--accent); }
}

/* --- Shared bits of card furniture -------------------------------------- */
.card__label {
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  /* 0.85, not 0.7: at 0.7 this drops to 3.47:1 on the orange card. */
  opacity: 0.85;
}
.card__title {
  font-size: var(--fs-title);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  text-wrap: balance;
}
.card__text { max-width: 34ch; }
/* A card title that is also the link into the card - underlined on hover
   like the work wordmarks, not at rest. */
.card__title a { text-decoration: none; }
.card--link:hover .card__title a { text-decoration: underline; text-underline-offset: 0.15em; }
.card__foot { margin-top: auto; }   /* pins a link or meta line to the bottom */
/* No underline at rest, one on hover - the same move the contact rows make.
   The LINE is always there and only its colour changes, so the row does not
   shift by a pixel when you point at it. */
.card__foot a {
  text-decoration-line: underline;
  text-decoration-color: transparent;
  text-underline-offset: 0.2em;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}
.card__foot a:hover { text-decoration-color: currentColor; }


/* 7. CARD CONTENT PATTERNS ================================================= */

/* --- Hero: headline plus a supporting paragraph -------------------------
   There is no small LEADER. / DESIGNER. label on this card: the two header
   buttons already say which side you are on. */
.hero__line {
  font-size: var(--fs-headline);
  font-weight: 600;
  line-height: var(--lh-headline);
  letter-spacing: var(--ls-headline);
  text-wrap: pretty;
}
.hero__body {
  max-width: 34ch;
  font-size: var(--fs-lead);
  line-height: var(--lh-lead);
  margin-top: var(--space-lead);
}

/* A phone column is already narrower than a comfortable measure, so the
   34ch cap only wastes the right-hand side. Must come AFTER the rule it
   overrides - same specificity, so source order decides. */
@media (max-width: 63.99rem) {
  .hero__body { max-width: none; }
}

.hero__body em { font-style: italic; }

/* --- Portrait: image fills the card, no padding -------------------------
   aspect-ratio rather than a fixed height: the photo decides how tall the
   card is, and because the card spans two rows the grid hands that height
   to the hero card beside it too, which is what keeps the top row even. */
.card--portrait { padding: 0; }
.card--portrait picture { display: block; height: 100%; }

.card--portrait img {
  width: 100%;
  height: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
}

/* On a phone the portrait is a 3:4 slab of head and shoulders that eats most
   of the screen for a lot of jumper. Cropped to the face instead: a wider
   box, and object-position pulled up so the frame lands between the forehead
   and the chin rather than centring on the chest.

   height:auto matters - the base rule's height:100% would otherwise let the
   card decide, and the aspect-ratio would never get a say. And this has to
   come AFTER that rule, not before, or source order hands the win back. */
@media (max-width: 63.99rem) {
  .card--portrait img {
    aspect-ratio: 4 / 3;
    height: auto;
    object-position: 50% 24%;
  }
}

/* The wink. The same frame with one eye shut, cross-faded in while the
   pointer is on the card. Absolutely positioned so it costs no height - the
   first picture still decides how tall the card is - and inset to 0 so it
   lands exactly over it. The two frames were shot close enough to match:
   measured, the head sits at the same height in both and is 6px wider here,
   which a fade of this length hides.

   Hover only. On a touch screen :hover sticks to whatever was tapped last,
   and a portrait stuck mid-wink is a bug rather than a joke. It is also why
   this is not in the markup twice: aria-hidden keeps it out of the
   accessibility tree, so the card still announces one portrait.

   height: 100% rather than the auto the phone rule above hands every
   portrait image: this one's box is already the right shape, because its
   insets copy the picture underneath. Same specificity as that rule, so it
   has to come after it to win. */
.portrait__wink {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}
.portrait__wink img { height: 100%; }
@media (hover: hover) {
  .card--portrait:hover .portrait__wink { opacity: 1; }
}

/* --- Stat cards: the big numerals --------------------------------------- */
/* --- The stat fill ------------------------------------------------------
   Hovering a stat card fills its numeral from the left to the figure it is
   quoting - 80% fills four fifths of the way across - and the edge runs
   back the other way when the pointer leaves. One stop moving in both
   directions, so the retreat is the arrival reversed rather than a second
   animation to keep in step. The percentage is read off the numeral itself by
   js/main.js, so the number on screen and the height of the fill can never
   disagree.

   Done by clipping a gradient to the glyphs rather than by stacking an
   element behind them: a two-stop gradient with both stops at the same
   place is a hard edge, and moving that one stop is the whole animation.

   Guarded by @supports because -webkit-text-fill-color: transparent is what
   lets the gradient show through, and without background-clip: text that
   would leave the numeral invisible rather than merely unfilled. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .stat__value {
    /* The quiet end of the palette, not the accent. The sweep is meant to
       be noticed on second glance, so the filled part shifts warm rather
       than lighting up: peach against the dark cards, and against the pale
       ghosts where it is barely more than a wash. Two surfaces cannot take
       it and are handled below. */
    --stat-fill: var(--accent-soft);
    --stat-stop: 0%;
    /* Two layers. The gradient on top is measured against the CONTENT box,
       so the fill maps to the glyphs' advance width and the percentages
       stay honest. Underneath it, a flat sheet of the text colour spanning
       the padding box, which is what paints the ink that overhangs the
       advance. Repeating the gradient instead would tile it into the
       padding and put a sliver of the fill colour past the end of it. */
    background-image:
      linear-gradient(to right,
        var(--stat-fill) var(--stat-stop),
        currentColor var(--stat-stop)),
      linear-gradient(currentColor, currentColor);
    -webkit-background-clip: text;
            background-clip: text;
    -webkit-text-fill-color: transparent;
    /* The gradient resolves its percentages against this box, so the box has
       to be the glyphs and nothing else. Left alone the numeral is a block
       and fills the card - 300px wide where "20%" only inks 143 - and the
       stop landed at 20% of THAT, which is 42% of the visible figure. Every
       value under 100 over-filled, and by a different amount each time
       (40% reached 83% of its glyphs, 80% reached 96%). Shrinking the box
       to its content makes the percentage mean what it says. */
    width: fit-content;
    /* ...but a glyph can ink wider than the advance it is given, and with
       background-clip: text anything outside the box has no background to
       paint through and simply is not drawn. Cormorant's "%" does exactly
       that: the lower bowl overhangs on the right and came out sliced flat.

       So the box is widened on the END side only - the start side carries
       the optical pull that lines the numeral up with its caption, and
       padding there would undo it - and pulled back by the same amount so
       nothing moves. background-origin keeps the gradient measured against
       the CONTENT box, so the fill still maps to the advance width and the
       percentages stay honest; repeat then covers the new padding. */
    padding-inline-end: 0.12em;
    margin-inline-end: -0.12em;
    background-origin: content-box, padding-box;
    background-repeat: no-repeat;
    /* The second layer is sized to the end padding and nothing else. As a
       full sheet it sat under the whole numeral, and because both layers
       are clipped to the same antialiased text mask, every glyph edge
       composited the top layer over it and let a little of it through -
       a hairline of the under-colour tracing each glyph. Invisible while
       the two colours were close, obvious the moment a fill was much
       darker than the ink: the dark side's 60% drew a light 1px stroke
       around its figures. Confined to the strip it was there for, the
       layers no longer overlap and the fringe has nothing to come from. */
    background-size: auto auto, 0.12em 100%;
    background-position: 0 0, 100% 0;
    /* Slower than a hover tint: this one is meant to be watched arriving. */
    transition: --stat-stop 800ms var(--ease-out);
  }
  /* Set per side, not once globally: the same card colour appears on both
     pages here and does not want the same treatment on each. --bg is the
     page behind the cards, which is what differs - #FFFFFF on the light
     side, #131313 on the dark. Where the fill is --bg the numeral does not
     change colour so much as empty out, the sweep erasing it rather than
     tinting it. */
  .layer--light .card--accent-soft .stat__value { --stat-fill: var(--bg); }
  .layer--light .stat--muted .stat__value       { --stat-fill: var(--card-strong); }
  .layer--light .card--strong .stat__value      { --stat-fill: var(--ink-ghost-tint); }

  .layer--dark .card--accent .stat__value { --stat-fill: var(--ink-on-dark); }
  .layer--dark .card--strong .stat__value { --stat-fill: var(--bg); }
  .layer--dark .card--tint   .stat__value { --stat-fill: var(--bg); }

  /* Hover is on the CARD, not the numeral - a 380x200 target rather than a
     few glyphs. --stat-pct is set per numeral by js/main.js; without it
     nothing moves, which is the no-JS state. */
  @media (hover: hover) {
    .card:hover .stat__value { --stat-stop: var(--stat-pct, 0%); }
  }

  @media (prefers-reduced-motion: reduce) {
    .stat__value { transition-duration: 1ms; }
  }
}

.stat__value {
  /* 30% of the card's own width, never past --fs-display. "100%" is the
     widest string at about 2.8em, so 30cqw always leaves room inside the
     padding, at every column count and every card size. */
  font-size: clamp(3rem, 30cqw, var(--fs-display));
  font-weight: 600;
  line-height: var(--lh-display);
  letter-spacing: -0.03em;
  font-variant-numeric: var(--figures) tabular-nums;
}
/* The display size above is drawn for a tall 1x2 card. A 1x1 card clips
   rather than grows, so the numeral has to come down to fit one. */
[data-size="1x1"] .stat__value { font-size: clamp(2.5rem, 26cqw, 6rem); }

/* The secondary numeral is a ghost: almost the card colour, there as texture
   rather than as information. It is aria-hidden in the markup and the figure
   is repeated in the card's visually-hidden heading, so nothing is lost by
   its being this quiet - and no contrast rule applies to it. */
.stat--muted .stat__value { color: var(--ink-ghost); }

/* The lead numeral runs off the left edge of its card and is clipped by it.
   The card already has overflow: hidden, so the negative margin is the whole
   trick. em units keep the bleed proportional when the numeral resizes. */
/* The lead numeral. It used to be pulled past the card's left edge and
   clipped by it; now it starts on the same line as the caption underneath,
   so the card reads as one left-aligned column. 45cqw is a share of the
   card's CONTENT box, so "80%" lands at about 85% of the available width -
   as large as it can be and still sit inside the text column. */
.card--stat-lead .stat__value {
  font-size: clamp(4rem, 45cqw, 11rem);
  /* Optical, not geometric. Both boxes start on the same edge, but a digit
     carries a left side bearing the caption's first letter does not, so the
     numeral reads as indented by 6px at this size. Measured at 0.0444em and
     pulled back by it; in em so it holds as the numeral resizes. */
  margin-inline-start: -0.044em;
}

/* The asterisked note under the advisory split. */
.card__note {
  margin-top: auto;
  font-size: 0.875rem;
  opacity: 0.85;
}

.stat__caption { font-size: var(--fs-caption); max-width: 28ch; }


/* A journey written as one line. The steps are separated by middots rather
   than drawn as a diagram, so it survives being read aloud and reflows on a
   phone instead of scrolling sideways. The <strong> steps are the ones the
   case is about - the waiting the app removes - so they carry the emphasis
   rather than a colour nobody could name. */
.case__timeline {
  font-family: var(--font-ui);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
  line-height: 1.9;
  text-wrap: pretty;
  padding: 1rem 1.25rem;
  border-left: 2px solid var(--line);
}
.case__timeline strong { color: var(--ink); font-weight: 600; }

/* --- The tag ------------------------------------------------------------
   A rounded square that hangs off the top-right corner of a card. Sizes come from the
   spec: a 62px rounded square, hung off the top-right CORNER - it clears
   both edges, 16px above and 19px out, and that overhang is the point.

   .card is overflow: hidden so image cards can clip to the 40px radius, so
   the card carrying a tag opts out. Safe here: this card's only image is a
   centred wordmark well inside its bounds, nothing that relies on the clip.

   Not a control and not inside the heading, so it neither swallows a click
   meant for the card's stretched link nor edits the card's accessible name.
   It still reads in order, which is the point of it being real text. */
.card:has(.card__tag) { overflow: visible; }

.card__tag {
  position: absolute;
  top: -16px;
  right: -19px;
  /* Both axes given outright. min-width plus aspect-ratio leaves an
     absolutely positioned box with no width to resolve the ratio against,
     and the engines disagree about what to do: Chrome drew 62, WebKit 42.
     A fixed 62 is the same box everywhere. A longer word than "New"
     means changing this number rather than the box growing to suit. */
  width: 62px;
  height: 62px;
  margin: 0;
  display: grid;
  place-items: center;
  /* Trimmed so the drawn width is the one that shows: at 16px the word plus
     0.75rem either side measured 65px, and min-width can only ever push a
     pill wider, never pull it in. */
  padding-inline: 0.625rem;
  border-radius: 30%;
  background: var(--accent);
  color: #FFF;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 0.05em;        /* 5%; letter-spacing takes no percentage */
  text-transform: uppercase;
  pointer-events: none;          /* the whole card is the link */
}

/* --- Work cards: a wordmark or a cover, centred, and nothing else ------- */
.card--work {
  justify-content: center;
  align-items: center;
  text-align: center;
  min-height: 12.5rem;
}
.work__mark {
  font-size: clamp(1.375rem, 2vw, 1.75rem);
  font-weight: 600;
  letter-spacing: -0.02em;
}
/* The whole card is the click target, so the wordmark is not underlined at
   rest; the underline is the hover affordance. Focus is handled by the ring
   on the card (see .card--link above), not by the text. */
.work__mark a { text-decoration: none; }

/* A wordmark supplied as artwork rather than set as text. The file is flat
   black, so the same crush-and-lift the header logo uses turns it white -
   one asset, no second file to keep in sync. The alt text carries the name,
   so the heading still has one. */
/* A heading holding artwork rather than set text. Stretched to the card's
   content box so that a percentage width on the image below means a share
   of the CARD - left alone, the heading shrink-wraps to the file's own
   260px and any percentage would be a share of the artwork instead. */
.work__mark:has(.work__logo) { align-self: stretch; }

.work__logo {
  width: 40%;
  height: auto;
  /* A block image does not centre itself in the space left over - it sits
     flush left. This puts it back on the card's axis. */
  margin-inline: auto;
}

/* Most wordmarks already ship in a colour that suits their card - ING is
   near-black and sits on a tinted one, Booking is white and sits on an ink
   one - so they are left alone. This is for artwork that does not: crush it
   to pure black, then lift that to white. The same one-asset trick the
   header wordmark uses, and the reason it is opt-in rather than automatic
   is that applying it to ING would erase it. */
/* Black artwork lifted to white, but only where it sits on a dark surface:
   inside the dark layer on the home page, or on an ink or strong card
   anywhere. On a light card the original black is what you want - forcing
   it white there leaves the logo invisible, which is exactly what happened
   to Memento in the More work strip when these pages went light. */
.layer--dark .work__logo--invert,
.card--ink   .work__logo--invert,
.card--strong .work__logo--invert { filter: brightness(0) invert(1); }

/* Normalises artwork to black on light surfaces and white on dark ones,
   whatever colour the file itself is. Two reasons to reach for it over
   --invert above: artwork that ships near-WHITE, which is invisible on a
   light card if left alone (London Street Type is #F5F5F5 on the strip's
   #F4F3F1); and artwork with a brand colour that is not wanted, since
   brightness(0) flattens any hue to black before the lift (Voila is
   #020083). One asset, both sides, no second file. */
.work__logo--crush { filter: brightness(0); }
.layer--dark .work__logo--crush,
.card--ink   .work__logo--crush,
.card--strong .work__logo--crush { filter: brightness(0) invert(1); }

/* Greige and accent cards are LIGHT surfaces wherever they sit - including
   inside the dark layer, whose rule above would otherwise lift the artwork to white and
   leave it at 3.4:1 on the orange. Crushed to ink instead, which is the same
   direction --card-fg goes on these cards. */
.layer--dark :is(.card--tint, .card--accent, .card--accent-soft) :is(.work__logo--crush, .work__logo--invert) {
  filter: brightness(0);
}

/* A square emblem rather than a wide wordmark. The 60% width above is meant
   for something roughly 4:1 - a near-square mark at that width stands 171px
   tall and bursts a 200px card. Capped by height instead so it sits in the
   same optical band as the wordmarks, with the width cap for narrow cards. */
/* Per-wordmark width. 40% above is the baseline; these three carry less
   ink at the same width - Booking's is 6:1, ING's has the lion beside it,
   Memento's is a light script - so they get more of the card to read at the
   same strength. Matched pairs of selectors because a card is identified by
   data-card in the grid and by data-slug in the More work strip, and the
   logo has to be the same size in both. */
[data-card="work-ing"]     .work__logo, [data-slug="ing"]     .work__logo,
[data-card="work-memento"] .work__logo, [data-slug="memento"] .work__logo {
  width: 50%;
}
/* The widest and thinnest of them - Booking's is 6:1, New Motion's 3.8:1 -
   so they need the most of the card to carry the same weight. */
[data-card="work-booking"]        .work__logo, [data-slug="booking"]        .work__logo,
[data-card="work-the-new-motion"] .work__logo, [data-slug="the-new-motion"] .work__logo {
  width: 60%;
}

.work__logo--emblem {
  width: auto;
  /* A definite height, not a max. With `auto` on both axes an image that
     has not loaded yet has nothing to size from and lays out 0 wide, so the
     More work strip reflowed as this scrolled into view. Measured: 0x0
     before load, 105x100 after. A definite height gives the width something
     to resolve against, and the space is reserved from the first frame.
     (aspect-ratio alone does not fix it - width still has no basis.) */
  height: 100px;
  /* Only binds on a very narrow card. contain rather than a squash if it
     ever does. */
  max-width: 60%;
  object-fit: contain;
}

/* Smaller in the More work strip. Those cards are shorter than the grid's,
   so the grid size reads as the largest thing in the row. Width is definite
   here and height follows - either axis being definite is enough to reserve
   the space before the image loads; it was both being `auto` that left it
   0 wide. Measured 80x76 before and after load, no reflow. */
.more__item .work__logo--emblem {
  width: 80px;
  height: auto;
}
.card--link:hover .work__mark a { text-decoration: underline; text-underline-offset: 0.15em; }
.work__meta { font-size: var(--fs-label); letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.85; }

/* --- Writing card ------------------------------------------------------- */
.writing-list { display: flex; flex-direction: column; gap: 1rem; }
.writing-list li { padding-bottom: 1rem; border-bottom: 1px solid rgb(255 255 255 / .12); }
/* The last row keeps its rule: it is what separates the list from the
   "+ more" link under it, which is a different kind of thing. */
.writing-list a { text-decoration: none; font-weight: 600; }
.writing-list a:hover { text-decoration: underline; }
.writing-list .deck { display: block; font-weight: 400; font-size: 0.9375rem; opacity: 0.85; }

/* --- Contact card ------------------------------------------------------- */
.contact-list { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; }
.contact-list :where(a, button) {
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  text-decoration-color: transparent;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}
.contact-list :where(a, button):hover { text-decoration-color: currentColor; }

/* --- Icons on the contact links ----------------------------------------
   Each one is a SOLID rounded box with the brand mark cut out of it. Box
   and mark live in a single <path>: the box is written first, the mark's
   own path data is appended after it, and fill-rule='evenodd' turns the
   mark into a hole - two shapes, one fill, no compositing. Each viewBox is
   computed so the mark sits centred at a fixed share of the box (62% for
   the chunky marks, more for X and Instagram, whose logos are drawn with
   much thinner strokes and vanish at 20px otherwise). Corner radius is 22%
   of the box throughout. Regenerate rather than hand-edit: the numbers are
   derived from each mark's bounding box.

   They are MASKS, not images: the box takes the link's own currentColor
   and the cut-out mark shows whatever is behind the link,
   so one definition covers the light side, the dark side and every card
   surface with no second asset and no filter. Purely decorative - each
   link still carries its own visible text, which is what names it.

   The ::before only exists on a row that carries an ico- class, so a row
   without one is left exactly as it was rather than growing an empty box. */
.contact-list li[class*="ico-"] > :is(a, button)::before {
  content: "";
  flex: none;
  width: 20px;
  height: 20px;
  margin-right: 0.5rem;
  background-color: currentColor;
  -webkit-mask: var(--ico) center / 20px 20px no-repeat;
          mask: var(--ico) center / 20px 20px no-repeat;
}

.ico-mail { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-2.903 -2.903 25.806 25.806'%3E%3Cpath fill-rule='evenodd' d='M2.774 -2.903h14.452a5.677 5.677 0 0 1 5.677 5.677v14.452a5.677 5.677 0 0 1 -5.677 5.677h-14.452a5.677 5.677 0 0 1 -5.677 -5.677v-14.452a5.677 5.677 0 0 1 5.677 -5.677zM3.7 4.5h12.6a1.7 1.7 0 0 1 1.7 1.7v7.6a1.7 1.7 0 0 1 -1.7 1.7h-12.6a1.7 1.7 0 0 1 -1.7 -1.7v-7.6a1.7 1.7 0 0 1 1.7 -1.7zM3.3 6.0L10 10.9L16.7 6.0L16.7 7.9L10 12.8L3.3 7.9Z'/%3E%3C/svg%3E"); }
.ico-linkedin { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-1.465 -1.765 506.129 506.129'%3E%3Cpath fill-rule='evenodd' d='M109.884 -1.765h283.432a111.348 111.348 0 0 1 111.348 111.348v283.432a111.348 111.348 0 0 1 -111.348 111.348h-283.432a111.348 111.348 0 0 1 -111.348 -111.348v-283.432a111.348 111.348 0 0 1 111.348 -111.348zM168,408.2H96v-208h72V408.2z M131.6,168.2c-20.4,0-36.8-16.4-36.8-36.8c0-20.4,16.4-36.8,36.8-36.8 c20.4,0,36.8,16.4,36.8,36.8C168,151.8,151.6,168.2,131.6,168.2z M408.4,408.2H408h-60V307.4c0-24.4-3.2-55.6-36.4-55.6 c-34,0-39.6,26.4-39.6,54v102.4h-60v-208h56v28h1.6c8.8-16,29.2-28.4,61.2-28.4c66,0,77.6,38,77.6,94.4V408.2z'/%3E%3C/svg%3E"); }
.ico-medium { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-2.826 -3.226 26.452 26.452'%3E%3Cpath fill-rule='evenodd' d='M2.994 -3.226h14.813a5.819 5.819 0 0 1 5.819 5.819v14.813a5.819 5.819 0 0 1 -5.819 5.819h-14.813a5.819 5.819 0 0 1 -5.819 -5.819v-14.813a5.819 5.819 0 0 1 5.819 -5.819zM2.2 10a3.7 3.7 0 1 0 7.4 0a3.7 3.7 0 1 0 -7.4 0zM11.4 10a1.9 3.7 0 1 0 3.8 0a1.9 3.7 0 1 0 -3.8 0zM17.1 10a0.75 3.6 0 1 0 1.5 0a0.75 3.6 0 1 0 -1.5 0z'/%3E%3C/svg%3E"); }
/* The one icon in this set that is a solid mark rather than a 2px stroke:
   it is X's own logo, and redrawing it as line art would make it a
   different mark. The viewBox is inset by 2.5 on each side because the
   path fills its 24-box edge to edge, where the stroked icons above sit
   at about 70% of theirs - without the inset it out-weighs its
   neighbours. The mask only reads coverage, so the fill colour is
   irrelevant; currentColor tints it. */
.ico-x { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-5.049 -4.667 33.333 33.333'%3E%3Cpath fill-rule='evenodd' d='M2.284 -4.667h18.667a7.333 7.333 0 0 1 7.333 7.333v18.667a7.333 7.333 0 0 1 -7.333 7.333h-18.667a7.333 7.333 0 0 1 -7.333 -7.333v-18.667a7.333 7.333 0 0 1 7.333 -7.333zM14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z'/%3E%3C/svg%3E"); }
.ico-instagram { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4.438 -4.438 32.877 32.877'%3E%3Cpath fill-rule='evenodd' d='M2.795 -4.438h18.411a7.233 7.233 0 0 1 7.233 7.233v18.411a7.233 7.233 0 0 1 -7.233 7.233h-18.411a7.233 7.233 0 0 1 -7.233 -7.233v-18.411a7.233 7.233 0 0 1 7.233 -7.233zM7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077'/%3E%3C/svg%3E"); }


/* The email slot. Before JavaScript runs it holds a LinkedIn link; after,
   a "Show email" button; after a click, the address itself as a mailto.
   All three look the same, so the card does not jump as it changes. */
.email__btn,
.email__addr { padding: 0; }
.email__btn { font: inherit; color: inherit; cursor: pointer; }
/* On the book slide the slot sits in a sentence rather than in the contact
   row, so it needs the underline colour the .contact-list rules give it
   there. */
.slide .email__btn,
.slide .email__addr { color: inherit; text-decoration-color: currentColor; }

/* The button that says "Email" reads like the rows beside it: no underline
   at rest, one on hover. Only the LINE is set here - the colour is left to
   the .contact-list rules above, which keep it transparent until hover. A
   button carries no underline of its own, so without the line there would
   be nothing for the hover to colour and this row alone would stay flat. */
.email__btn {
  text-decoration-line: underline;
  text-underline-offset: 0.2em;
}

/* The revealed address is a mailto and stays underlined, as it was. */
.email__addr {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}


/* --- Simple text pages (placeholders, the writing index) ----------------
   Case-study and article layout proper (.case, .article, .gallery,
   .pull-quote, .figure, .progress, .toc) arrives with M4 and M5; these are
   the few rules the placeholder pages need until then. */
.stub {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  align-items: flex-start;
  padding-block: clamp(2rem, 8vw, 5rem);
  padding-bottom: clamp(3rem, 10vw, 6rem);
}
.stub > .card { align-self: stretch; }
/* .stub is a column flex with align-items: flex-start, so anything that
   does not say otherwise is only as wide as its content. For the strip
   that meant 6 cards wide on a work stub and 13 wide on a writing one -
   the second overflowing the window and scrolling the whole page
   sideways. It belongs to the column like everything else. */
.stub > .more { align-self: stretch; }
/* The placeholder cover on a case-study stub. */
.stub__cover { align-self: stretch; }
/* A cover inside a case column: the same treatment the stub cover had, but
   in normal flow under the head rather than as a flex item. */
.case__cover { display: block; margin-bottom: clamp(1.5rem, 4vw, 2.5rem); }
.case__text { max-width: var(--measure); }
.case__cover img,
.stub__cover img {
  width: 100%;
  height: auto;
  border-radius: var(--radius);
}
.case__back a, .stub > p a { min-height: var(--tap); display: inline-flex; align-items: center; }


/* 8. FOOTER ================================================================ */

.site-footer {
  padding-block: clamp(2rem, 5vw, 3rem);
  border-top: 1px solid var(--line);
  color: var(--ink-muted);
  font-size: 0.9375rem;
}
.site-footer__inner {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap) 2rem;
  justify-content: space-between;
  align-items: center;
}

/* --- The time machine ---------------------------------------------------
   A door in the copyright line: point at the year and the old site offers
   itself. An easter egg, so nothing marks the year as interactive - the
   reward is for the curious - but it is not hover-only. The panel holds a
   real link, and :focus-within brings it up when that link is tabbed to,
   so a keyboard reaches the same door.

   The panel is faded out rather than hidden, because visibility: hidden or
   display: none would take the link out of the tab order and the egg would
   be unreachable without a mouse. pointer-events: none keeps it from
   catching clicks meant for the footer while it is invisible.

   It sits ABOVE the year: the footer is the last thing on the page, and a
   panel below it would hang off the bottom. It is inside the hover area, so
   the pointer can travel from the year to the button without it closing -
   which is what makes the button usable at all. */
/* The paragraph, not the year, is what the panel is positioned against.
   Anchored to the year it was centred on a word that sits wherever the
   sentence happens to wrap, and near a narrow window's right edge it ran
   off the page: measured at 360px it reached 408px, which gave every page
   in the site 48px of horizontal scroll even though the panel is invisible
   at rest. The paragraph is inside .wrap, so anything centred in it is
   inside the page margins at every width. */
.site-footer p { position: relative; }
.time-machine {
  --tm-gap: 0.75rem;
  display: inline-block;
}
.time-machine__tip {
  position: absolute;
  bottom: calc(100% + var(--tm-gap));
  /* Centred in the paragraph and never wider than it: inset-inline: 0 gives
     the box the paragraph's width to sit in, width: max-content keeps it
     only as wide as its sentence, and margin-inline: auto centres what is
     left. On a phone the paragraph is the full column, so the panel centres
     on the screen; on a desktop the paragraph is one line beside the social
     links, so it lands where it always did. */
  inset-inline: 0;
  width: max-content;
  margin-inline: auto;
  translate: 0 0.25rem;
  z-index: 5;

  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  max-width: min(20rem, 100%);
  padding: 1rem 1.25rem;
  border-radius: var(--radius-sm, 16px);
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink);
  text-align: center;
  text-wrap: balance;

  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out),
              translate var(--dur-fast) var(--ease-out);
}
/* The gap between the year and the panel is empty space, and a pointer
   crossing it is over neither - which closed the panel on the way to the
   button and made it impossible to click. This covers that strip with an
   invisible extension of the panel itself, so the hover never lapses. It
   inherits pointer-events from the panel, so it catches nothing while the
   panel is hidden. */
.time-machine__tip::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 100%;
  /* The gap, plus the paragraph the panel now sits above - on a phone that
     sentence runs to two or three lines, and the pointer crosses them on
     its way up from the year. The paragraph holds no links, so an invisible
     strip over it while the panel is open costs nothing. */
  height: calc(var(--tm-gap) + 4em);
}
.time-machine:hover .time-machine__tip,
.time-machine:focus-within .time-machine__tip {
  opacity: 1;
  translate: 0;
  pointer-events: auto;
}
/* Smaller than the pill a case study uses: this one lives in 15px type. */
.time-machine .btn {
  min-height: 2.25rem;
  padding-inline: 1rem;
  font-size: 0.875rem;
}
.site-footer a {
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  text-decoration-color: transparent;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}
.site-footer a:hover { text-decoration-color: currentColor; }

/* The easter-egg toolbar is NOT reserved here. It has to sit outside both
   .layer pages - anything inside one is clipped by the curtain and goes
   inert when that page is hidden - so it belongs next to the curtain handle
   instead. See "Post-MVP: the easter eggs" in README.md. */


/* 8b. ARTICLES =============================================================
   Typography-first reading. The measure, not the window, decides the line
   length: --measure (68ch) is the only thing setting the text width, so the
   column stays readable however wide the screen gets.

   The serif is declared on the article pages themselves rather than here, so
   it never loads on the home page. See _templates/article.html.
   ========================================================================== */

.article {
  /* Flat top, so the gap under the header is the same at every width; the
     bottom still grows with the window. */
  padding-block: 44px clamp(3rem, 10vw, 6rem);
}

/* The reading column is centred in the page, not flush left. --measure is
   still the only thing setting its width; margin-inline just puts the
   leftover space on both sides instead of all of it on the right. */
.article__head,
/* The writing index's head is the same object - same column, same rhythm -
   so it shares the rules rather than restating them and drifting. */
.writing-index__head {
  /* width:100% matters more than it looks. On an article this is a grid
     ITEM, and a grid item with only a max-width shrink-wraps to its own
     content - so the head was as wide as its title, and the title started
     wherever that left it. Measured across five articles it ranged from
     282px to 708px, putting "Big ideas" 200px right of "1000 Questions".
     This pins every head to the measure instead. */
  width: 100%;
  max-width: var(--measure);
  margin-inline: auto;
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
}

/* The index carries more air under its head than an article does: it opens
   onto a grid of cards rather than straight into running text. Flat rather
   than clamped, so it is the same gap at every width. */
.writing-index__head { margin-bottom: 86px; }


.article__title { grid-column: 1; grid-row: 1; }
.case__title    { grid-column: 1; grid-row: 1; }
.article__title {
  font-size: clamp(2rem, 5vw, 3.25rem);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: var(--ls-headline);
  text-wrap: balance;
}
.article__deck {
  margin-top: 0.75rem;
  font-size: clamp(1.125rem, 2vw, 1.375rem);
  color: var(--ink-muted);
  text-wrap: pretty;
}
.article__meta {
  margin-top: 1.25rem;
  font-size: var(--fs-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

/* --- ARTICLE CONTENTS ---------------------------------------------------
   Built by js/toc.js from the <h2> elements; never hand-written.

   It lives in the left gutter of a three-column article and nowhere else.
   Below 1280px there is no gutter to put it in - the article is a single
   centred column - and a contents list stacked above the prose only pushes
   the piece down the screen, so there it is not rendered at all.

   In the gutter it has two states. At rest it is a column of marks: one
   pill per section, the one you are reading longer and taller than the
   rest, which reads as a position in the piece rather than as a list of
   words competing with the words you came for. Point at it and the marks
   become the headings they stand for and the label grows from TOC to the
   whole phrase.

   The text is never removed to draw the marks - font-size: 0 collapses it
   while leaving it in the accessibility tree - so what a screen reader is
   handed is the same list of links in both states.
   ------------------------------------------------------------------------ */

.toc { font-family: var(--font-ui); }

/* No gutter, no contents list. Phones and tablets both land here. */
@media (max-width: 79.99rem) {
  .toc { display: none; }
}

@media (min-width: 80rem) {
  /* Gutter, reading column, gutter. The column keeps its centred position
     because the two gutters are equal; the contents list just stops the
     left one from being empty.

     The grid runs to the window's edges - max-width off, 20px of padding in
     its place - so that the left gutter reaches them and the list can sit
     at the edge without being pushed there by hand. The reading column does
     not move an inch when this happens: it is centred by the two equal
     gutters either way, and they simply absorb the difference. Measured at
     1280, 1440 and 1920, it lands on the same pixel as before. */
  .article {
    display: grid;
    grid-template-columns: 1fr minmax(0, var(--measure)) 1fr;
    column-gap: clamp(2rem, 4vw, 4rem);
    align-items: start;
    max-width: none;
    padding-inline: var(--toc-edge);
  }
  .article > :not(.toc) { grid-column: 2; }

  /* The two of them share a row, said twice so it cannot drift: the list
     starts exactly where the article's content starts because the grid puts
     them on the same line, not because anything measured the header. Row 1
     is the title block, which is auto-placed above both.

     Sticky rather than fixed, which is what makes the start position mean
     anything: it begins at the top of the body's row and then rides down
     with the page until it reaches --toc-edge from the top. A grid item's
     sticky travel is bounded by its own grid area, and that area is the
     body's row - the full height of the article - so it stays with you to
     the end of the piece. It needs no special case inside the sheet either:
     sticky answers to whatever is scrolling, the page or the sheet. */
  .article__body { grid-row: 2; }
  .toc {
    grid-column: 1;
    grid-row: 2;
    position: sticky;
    top: var(--toc-edge);
    /* Stretched to the gutter, then capped: at 1280 the gutter is narrower
       than the cap and the list follows it rather than overflowing. 12rem
       is the 15rem this started at, less a fifth. */
    max-width: 12rem;
    margin: 0;
    /* At rest the box hugs its marks. Stretched to the gutter it would be a
       240px invisible hover target beside the prose, and the list would open
       whenever the pointer drifted left of the text. */
    width: fit-content;
    justify-self: start;
  }
  .toc:hover,
  .toc:focus-within { width: 100%; }

  /* In the gutter it is always open, and the disclosure triangle would be
     noise - there is nothing to disclose. */
  .toc details { display: contents; }
  .toc summary { list-style: none; }
  .toc summary::-webkit-details-marker { display: none; }

  .toc summary {
    /* Idle, the column is marks and nothing else - no heading over them.
       Hidden rather than emptied so the nav keeps its name (see toc.js).
       The pills sit at the top of the gutter until you point at the list,
       and the label arrives with the words it belongs to. */
    display: none;
    /* Nothing toggles, so the pointer should not promise that it does. */
    cursor: default;
    padding-block: 0.5rem;
    font-size: 0.75rem;                    /* 12px, with the list */
    font-weight: 600;
    letter-spacing: var(--ls-label);
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--ink-muted);
  }
  .toc:hover summary,
  .toc:focus-within summary { display: block; }

  .toc ol {
    list-style: none;
    padding: 0;
    /* Flush to the top while there is no label above it. */
    margin: 0;
    display: grid;
    gap: 8px;
    border-left: 0;
  }
  .toc li { padding-left: 0; }

  /* The mark. font-size: 0 collapses the heading inside to nothing, so the
     box is exactly the pill and the words are still there to be read by
     anything that does not draw them. */
  .toc a {
    display: block;
    width: 20px;
    height: 6px;
    border-radius: var(--radius-pill);
    background: var(--line);
    font-size: 0;
    overflow: hidden;
    white-space: nowrap;
    text-decoration: none;
    color: transparent;
    transition: width var(--dur-fast) var(--ease-out),
                height var(--dur-fast) var(--ease-out),
                background-color var(--dur-fast) var(--ease-out),
                font-size var(--dur-fast) var(--ease-out),
                color var(--dur-fast) var(--ease-out);
  }
  /* The section you are actually in: longer, taller, and the ink colour.
     aria-current carries the meaning, so a screen reader is told the same
     thing the shape says. */
  .toc a[aria-current="true"] {
    width: 30px;
    height: 10px;
    background: var(--ink);
  }

  /* --- Pointed at: the marks become the headings ----------------------- */
  .toc:hover a,
  .toc:focus-within a {
    width: 100%;
    height: auto;
    background: none;
    border-radius: 0;
    font-size: 0.75rem;                    /* 12px */
    line-height: 1.45;
    color: var(--ink-muted);
    white-space: normal;
    text-wrap: pretty;
  }
  /* The one under the pointer, underlined. The marks carry no underline at
     rest - there is no text to sit under - so this only ever appears on the
     expanded list. */
  .toc:hover a:hover,
  .toc:focus-within a:hover {
    color: var(--ink);
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.25em;
  }
  /* The one you are reading, bold. 700 rather than 600: at 12px the step
     from regular to semibold is nearly invisible in the serif faces the
     typography chooser offers, and this is the only thing distinguishing
     the current section once the marks have become words. */
  .toc:hover a[aria-current="true"],
  .toc:focus-within a[aria-current="true"] {
    color: var(--ink);
    font-weight: 700;
  }
  /* The rule down the side comes back with the words, which is the only
     state where there is a column of text for it to run beside. */
  .toc:hover ol,
  .toc:focus-within ol {
    margin-top: 0.75rem;
    gap: 0.5rem;
    border-left: 2px solid var(--line);
  }
  .toc:hover li,
  .toc:focus-within li { padding-left: 1rem; }
}

/* The reading column. Everything inside inherits the serif and the measure;
   only figures are allowed to break out of it. */
.article__body {
  font-family: var(--font-body);
  font-size: var(--fs-article);
  line-height: var(--lh-article);
  max-width: var(--measure);
  margin-inline: auto;
}
.article__body > * + * { margin-top: 1.4em; }

/* Room above a heading you have just jumped to. js/toc.js gives every h2 in
   the body an id and links to it, and without this the heading lands hard
   against the top edge with no sign of what it belongs under. Written on
   [id] rather than h2 so anything else given an anchor gets it too.

   This is the scroll position, not a margin: nothing moves on the page, the
   jump just stops 30px short. Works the same in the sheet, where .sheet is
   the scroll container rather than the document. */
.article__body [id] { scroll-margin-top: 30px; }
.article__body p { text-wrap: pretty; }
/* Lists in the body. Only bare ul/ol - anything carrying a class is one of
   the site's own components and keeps its own reset. The indent is small on
   purpose: the browser default 40px pushes a list out of the reading column
   it belongs to. */
.article__body :is(ul, ol) { padding-left: 1.25em; }
.article__body li + li { margin-top: 0.6em; }
.article__body li > :is(ul, ol) { margin-top: 0.6em; }
/* A list of bare URLs, as the author published them. They are longer than
   the column, so they are allowed to break rather than push the page
   sideways. */
.article__body .link-list a { overflow-wrap: anywhere; }

.article__body h2,
.article__body h3 {
  font-family: var(--font-ui);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  margin-top: 2.2em;
  text-wrap: balance;
}
.article__body h2 { font-size: var(--fs-article-h2); }
.article__body h3 { font-size: 1.375rem; }

.article__body a {
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: from-font;
}

/* A line worth stopping on. Not a quotation from elsewhere - it is the
   article's own sentence, set larger and pulled out of the flow. */
.pull-quote {
  margin-block: 2em;
  padding-left: 1.25rem;
  border-left: 3px solid var(--accent);
  font-size: clamp(1.25rem, 2.4vw, 1.625rem);
  line-height: 1.35;
  font-weight: 600;
  font-family: var(--font-ui);
  letter-spacing: var(--ls-headline);
}
.pull-quote p { margin: 0; }

/* Figures break the measure - the text stays narrow, the pictures do not -
   and they break it EVENLY on both sides now that the column is centred.
   margin-left: 50% puts the figure's left edge on the column's centre line,
   then translate pulls it back by half its own width. Capped against the
   viewport so it never causes a sideways scroll. */
.figure {
  margin-block: 2.5em;
  width: min(52rem, calc(100vw - 2 * var(--page-margin)));
  max-width: none;
  margin-left: 50%;
  translate: -50% 0;
}
/* The FIRST figure in an article opens it, and the head above already
   carries its own margin-bottom - so the 2.5em here landed on top of that
   and put 106px between the meta line and the picture. Only the leading one
   loses it: every figure further down still needs the full space to
   separate it from the prose on both sides, and some of these articles have
   thirty of them. The second selector is for a body whose figure sits
   inside a tab panel rather than directly in it. */
.article__body > .figure:first-child,
.article__body .tabs__panel > .figure:first-child { margin-top: 0; }

.figure img { width: 100%; height: auto; border-radius: var(--radius); }
/* ...but never bigger than the file actually is. The breakout column is
   832px wide and some of these are 500px GIFs; stretched to fill they go
   soft. attr() with a type is not usable here, so a small figure opts out
   by carrying the class and is centred at its own size instead. */
.figure--small {
  width: fit-content;
  max-width: 100%;
  /* The breakout above offsets by half the column and pulls back with a
     translate. fit-content measures itself against what is left AFTER that
     margin, so it halved. A small figure keeps the reading column and
     simply centres in it. */
  margin-inline: auto;
  translate: none;
}
.figure--small img { width: auto; max-width: 100%; }
.figure figcaption {
  margin-top: 0.75rem;
  font-family: var(--font-ui);
  font-size: 0.9375rem;
  color: var(--ink-muted);
  /* Centred under the picture, matching the gallery captions. The figure is
     the image's width, so this centres on the image itself. */
  text-align: center;
}
/* Inside a case study the figure stays in the text column. The breakout
   above is drawn for an ARTICLE, where the measure is deliberately narrow
   and a wider picture is a relief; a case study's column is already wide,
   so the same rule only breaks the alignment with the paragraphs above and
   below it - 41px proud on each side, at every width. Nine article pages
   still use the breakout, which is why this is scoped rather than removed. */
.case__text .figure {
  width: auto;
  margin-left: 0;
  translate: none;
}

/* Where an illustration belongs to someone else it is credited and linked,
   not copied onto this domain. */
.figure-note {
  font-family: var(--font-ui);
  font-size: 0.9375rem;
  color: var(--ink-muted);
}

/* Required on every mirrored article, alongside <link rel="canonical">.
   Between them they tell a reader and a search engine the same thing: this
   is a copy, and the original lives elsewhere. */
.article__origin {
  margin-top: clamp(2.5rem, 6vw, 4rem);
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  max-width: var(--measure);
  margin-inline: auto;
  font-family: var(--font-ui);
  color: var(--ink-muted);
}
.article__origin a { color: var(--ink); }

/* 9. THE SHEET AND CASE STUDIES ===========================================
   A native <dialog>. showModal() gives focus trapping, Esc to close and the
   top layer for free - none of that is re-implemented in JavaScript.

   The page behind pulls back: scaled to 0.94, darkened, and given the card
   radius, so the sheet reads as a card lifted off a stack rather than a new
   page. That is the whole iOS-sheet illusion and it is three properties.
   ========================================================================== */

.sheet {
  width: min(100%, 100vw);
  max-width: none;
  /* Stops --sheet-top short of the top of the window, so the page behind
     shows above it. The height has to come off with it or the sheet would
     run off the bottom. */
  margin: var(--sheet-top) 0 0 auto;
  height: calc(100svh - var(--sheet-top));
  max-height: calc(100svh - var(--sheet-top));
  padding: 0;
  border: 0;
  border-radius: var(--radius) var(--radius) 0 0;
  background: var(--bg);
  color: var(--ink);
  box-shadow: var(--shadow-lift);
  /* The dialog no longer scrolls - js/sheet.js puts a .sheet__scroll inside
     it and that does the scrolling. The two jobs were one element, and a
     scrolling box with a radius has to re-apply that clip on every frame;
     worse, a clipped layer cannot be used to cover what is behind it, so
     deep in a deck with several sticky slides stacked the page behind
     started showing through. Clipping here now happens once, on a box that
     never moves. */
  overflow: hidden;
}

/* The scrolling half of the sheet: a plain rectangle, no radius, nothing
   for the compositor to re-clip as it moves. Everything that scrolls or
   sticks lives in here. */
.sheet__scroll {
  height: 100%;
  /* Same reason as body above: the gallery inside is sized in vw and this
     has its own scrollbar to account for. */
  overflow-x: clip;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.sheet::backdrop { background: rgb(0 0 0 / .5); }

/* Slides up from the bottom on open and back down on close, the same move
   in both directions over the same duration.

   .is-closing has to come after .sheet[open] and matches with the same
   weight, so source order is what lets it win - the dialog is still open
   while it plays. js/sheet.js does the real .close() when it ends.

   --sheet-from is where the slide starts: 0 normally, or wherever a finger
   left the sheet when the grab rail was dragged down. */
.sheet[open] {
  animation: sheet-in var(--dur-sheet) var(--ease-out);
  /* This is what makes the sheet the containing block for the deck's
     navigation, which is position: fixed - so the arrows sit against the
     overlay's right edge and centre on the overlay, rather than on the
     window behind it. See .deck-nav in this file and the deck block in
     js/main.js.

     It started life as an attempt on the scroll tearing, which it did not
     fix; the sheet no longer scrolls at all - .sheet__scroll does - so
     that reason is gone and this one replaces it. Only on [open], so it
     costs a layer while a sheet is up and nothing when it is shut. */
  will-change: transform;
}
@keyframes sheet-in {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}
.sheet.is-closing {
  animation: sheet-out var(--dur-sheet) var(--ease-out) forwards;
}
@keyframes sheet-out {
  from { transform: translateY(var(--sheet-from, 0px)); }
  to   { transform: translateY(100%); }
}
/* The dimming goes with it. Left alone the backdrop would blink out while
   the sheet was still on its way down. */
.sheet.is-closing::backdrop {
  animation: sheet-backdrop-out var(--dur-sheet) var(--ease-out) forwards;
}
@keyframes sheet-backdrop-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* The page behind stays at full size - it is not scaled back. It only dims,
   so the sheet reads as something laid over the page rather than the page
   shrinking away from it. PRD section 3.3 called for a 0.94 scale; that was
   dropped deliberately.

   The transition lives inside .sheet-open, so it only runs on the way IN.
   Removing the class removes the transition with it, and the page is back to
   normal the instant the sheet closes. */
.sheet-open .layer {
  filter: brightness(0.7);
  transition: filter var(--dur-sheet) var(--ease-out);
}
/* Nothing behind the sheet should be reachable, and the curtain must not be
   draggable underneath it. */
.sheet-open .curtain__handle { opacity: 0; pointer-events: none; }
.sheet-open { overflow: hidden; }          /* scroll locked behind */

/* --- The sheet's own furniture ----------------------------------------- */
.sheet__bar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 2rem;
  padding: 0.75rem var(--page-margin);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(12px);
}
/* The grab rail. Dragging it down dismisses the sheet; it is decorative to
   a screen reader because the close button does the same job accessibly. */
.sheet__grab {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  width: 44px;
  height: 5px;
  border-radius: var(--radius-pill);
  background: var(--ink-muted);
  opacity: 0.4;
  touch-action: none;
  cursor: grab;
}
/* There is no close button up here. The way out is "Back to grid", beside
   the title in the content itself - see .sheet-exit - which is the same
   control whether the page is in the sheet or standing on its own. */

/* Reading progress, pinned to the top of the sheet. Filled by js/sheet.js;
   inert and invisible without JavaScript. */
.progress {
  position: sticky;
  top: 0;
  z-index: 3;
  height: 4px;
  background: transparent;
}
/* Scaled, not resized. --read used to drive width, and width is a LAYOUT
   property: rewriting it on every scroll frame invalidated layout, so the
   next frame's read of scrollTop had to flush it. That one line was the
   whole cost of scrolling a sheet - 29 layouts across 30 wheel steps
   against 8 for the same page standing on its own, every frame, whatever
   the sheet happened to contain.

   A transform is handled by the compositor and invalidates nothing, so
   --read is now a ratio from 0 to 1 rather than a percentage. */
.progress__bar {
  display: block;
  height: 100%;
  width: 100%;
  transform: scaleX(var(--read, 0));
  transform-origin: left center;
  background: var(--accent);
  will-change: transform;
}

/* --- Case-study layout -------------------------------------------------
   8/12 running text against a 4/12 sticky sidebar, collapsing to one column
   below 900px. The same markup renders standalone at /work/<slug>/ and
   inside the sheet, which is why none of it depends on the dialog.
   ----------------------------------------------------------------------- */
/* The stacked layout's head and its trailing content sit in this same
   column - one selector rather than a second copy of the number - so the
   title and the More-work strip line up with every other case study. Only
   the deck itself is wider, because those panels are full-bleed. */
.case,
.stack__head,
.stack__after > .wrap {
  max-width: 1200px;
  margin-inline: auto;
}
.case {
  padding-block: clamp(2rem, 6vw, 4rem) clamp(3rem, 10vw, 6rem);
}

/* Title on the left, the way out on the right edge of the container, and
   everything else spanning underneath both. */
.case__head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  column-gap: 2rem;
  margin-bottom: clamp(2rem, 5vw, 3rem);
}
.case__head > :not(.case__title):not(.sheet-exit) { grid-column: 1 / -1; }

/* Back to grid. One control, two jobs: a real link to the home page when
   this page is standing on its own, and the sheet's close button when it is
   not - js/sheet.js intercepts it. Never two different controls. */
/* Only shown inside the sheet. A page reached by its own URL has the side
   nav in its header instead, which offers somewhere to go rather than just
   a way back; in the sheet there is no header of ours, and this is the way
   out. The markup carries it either way, so one page serves both. */
/* --- The sheet's furniture over a stacked page --------------------------
   Every other page in the sheet wants the frosted band: text scrolls under
   the grab rail and needs something to separate them. The stacked layout
   does not - its first slide is a full-bleed photograph, and a band of
   page above it reads as a white margin the design never has when the page
   stands on its own. So here the rail and the reading progress come out of
   the flow (sticky still, but with their own height pulled back off the
   bottom) and ride over the deck, which starts at the very top of the
   sheet and runs its full height. */
.sheet:has(.stack) { --sheet-chrome: 3.5rem; }
.sheet:has(.stack) .sheet__bar {
  height: var(--sheet-chrome);
  min-height: 0;
  margin-bottom: calc(-1 * var(--sheet-chrome));
  background: transparent;
  backdrop-filter: none;
}
.sheet:has(.stack) .progress { margin-bottom: -4px; }

/* --- The sheet's header on a phone --------------------------------------
   Two adjustments that only matter at phone width, where the sheet is the
   whole screen and its header is the first thing read. */
@media (max-width: 39.99rem) {
  /* The grab rail is centred in the bar. On a stacked page that bar is
     pulled out of the flow - see above - so the content starts at the very
     top of the sheet while the rail stays in the middle of a band that is
     no longer there, and the two collide: measured on ING and Booking, the
     rail's bottom edge sat 2px above both the title and the Back button.
     Anchoring it near the top of the band puts it where a sheet's grabber
     belongs, 10px below the sheet's own edge, and gives the header back the
     space it was missing. The unstacked pages keep the centred rail: their
     bar is real, and the gap there is already 50px. */
  .sheet:has(.stack) .sheet__grab { top: 10px; }

  /* Title and Back share a grid row and both begin at the same y, but Back
     is a 44px pill with 13px type centred inside it, so its text sits lower
     than a 36px title that starts at the top of its box. The two line up
     optically 5px apart, not 0. Both layouts use .case__head, so this is
     one rule for all seven case studies. */
  .sheet .case__head .case__title { margin-top: 5px; }
}

.sheet-exit { display: none; }
.sheet .sheet-exit { display: inline-flex; }

.sheet-exit {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  align-items: center;
  gap: 0.5rem;
  min-height: var(--tap);
  padding-inline: 0.875rem;
  border-radius: var(--radius-pill);
  /* Outline only, no fill. currentColor rather than a literal black: the
     same rule then draws black on the light page and near-white on the
     dark one, instead of disappearing on half the site. */
  border: 2px solid currentColor;
  background: transparent;
  color: var(--ink);
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  transition: background-color var(--dur-fast) var(--ease-out),
              color            var(--dur-fast) var(--ease-out);
}
.sheet-exit:hover { background: var(--ink); color: var(--bg); }
.sheet-exit svg { flex: none; }

/* Articles put it on the same row as their title too. */
.article__head,
.writing-index__head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  column-gap: 2rem;
}
.article__head > :not(.article__title):not(.sheet-exit),
.writing-index__head > :not(.writing-index__title) { grid-column: 1 / -1; }

/* A centred head, for an index whose title is the whole of it.

   It has to sit AFTER the two rules above, not with the other
   .writing-index__head declarations further up: every one of these is a
   single class, so source order decides, and theirs would otherwise win.

   max-width goes because the head is normally held to --measure and
   centred in the wrap, which is narrower than the grid of cards below -
   centring the text within THAT would sit it left of the cards it belongs
   to. The single track goes with it: the second column is for an article's
   Back link, and empty it still costs the 2rem column-gap, which narrows
   column one and lands the title 16px off centre. */
.writing-index__head--center {
  max-width: none;
  grid-template-columns: 1fr;
  text-align: center;
}
.case__title {
  font-size: clamp(2.25rem, 6vw, 4rem);
  font-weight: 600;
  line-height: 1.02;
  letter-spacing: -0.02em;
  text-wrap: balance;
}
.case__lede {
  margin-top: 0.75rem;
  font-size: clamp(1.125rem, 2vw, 1.5rem);
  color: var(--ink-muted);
  /* Full width of the head rather than the 40ch it used to hold to. The
     measure is still doing its job in the body below; up here the line is
     two or three long and reads as a standfirst, not as prose. */
  max-width: none;
  text-wrap: pretty;
}
.case__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 2.5rem;
  margin-top: 2rem;
  font-size: var(--fs-label);
}
.case__meta dt {
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
}
.case__meta dd { margin: 0; font-weight: 600; }

/* --- The gallery: native scroll-snap, no carousel script --------------- */
.gallery {
  display: flex;
  gap: var(--gap);
  overflow-x: auto;

  /* No snapping. It used to be `x mandatory`, which pulled the row back to
     the nearest snap point after any gesture that did not pass the halfway
     mark: measured, four consecutive 300px nudges all settled on the same
     pixel, in all three galleries that had it. `proximity` was no better -
     the resting place is still inside the current snap point's zone, so it
     still gets taken back.

     The deck galleries have run without snapping for a while, for a
     different reason, and they are the ones that scroll freely: the same
     four nudges move them 300, 600, 900, 1200. So the rows now agree with
     each other, and a wheel, a drag and the arrow keys all do what they
     say. What goes is the magnetic settle at the end of a flick. */
  scroll-snap-type: none;
  scroll-behavior: smooth;

  /* A horizontal swipe that runs past the end of the row stays in the row.
     Without this the browser takes the leftover as a back-gesture, and you
     lose the page while looking at the last picture. */
  overscroll-behavior-inline: contain;
  /* No visible bar. It is the one part of the gallery that is chrome rather
     than content, and under an edge-to-edge strip it draws a line across
     the whole page. What it was telling you is already being said better:
     the next image is cut by the edge, and the More work strip fades at it.

     Nothing about scrolling changes - trackpad, shift+wheel, touch, and the
     arrow keys with the strip focused all still work. The one thing that
     goes is a bar for a mouse-only user to drag; see the README. */
  scrollbar-width: none;
  padding-bottom: 1rem;
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
}
/* Firefox honours scrollbar-width above; these two need telling. */
.gallery::-webkit-scrollbar { display: none; }

.gallery figure {
  flex: 0 0 min(88%, var(--fig-max));
  scroll-snap-align: center;
  margin: 0;
}
/* CASE-STUDY GALLERIES fit their images; they do not crop them.
   Scoped away from the two strips that borrow .gallery only for the
   scrolling - the More work row dresses cards, not photographs, and the
   TLDR row sets its own size.

   Every figure shares ONE HEIGHT and takes whatever width its own ratio
   asks for. The alternative, which this replaced, was the reverse: one
   width for every figure and a forced 1.9 ratio with object-fit: cover.
   That reads well for photographs of roughly that shape and destroys a
   screenshot - Circles' screens are 1.17:1, so a third of each one was cut
   away, and on a UI the cut part is the content.

   The height expression is that old box rearranged, which is why the row
   sits in exactly the same band as before: figures were min(88%, --fig-max)
   wide inside a 100vw strip, and the image was that over --fig-ratio.

   No aspect-ratio is declared, deliberately. Setting one - even `auto` -
   overrides the ratio the browser derives from the img's own width and
   height attributes, and a lazy image then has nothing to size from until
   it arrives: it lays out 0 wide and the row reflows as you scroll past it.
   Leaving it undeclared keeps the attributes in charge, so the space is
   reserved from the first frame. Always set width and height on the img.

   No max-width either. The row scrolls sideways, so a wide picture is meant
   to be wide - capping it only made object-fit letterbox the thing inside a
   box it no longer filled, which is the ragged edge this rule exists to
   avoid. Voila's seven-screen strip is 3.26:1 and wants 1363px at this row
   height; that is correct, not a problem to solve. */
.gallery:not(.more__list):not(.tldr__list) figure { flex: 0 0 auto; }

/* The caption is chrome, not content: quieter than the prose around it, and
   centred on its picture. That centring works because the figure is sized by
   the image above - the caption box and the image are the same width, so
   text-align does the rest with nothing to measure. */
.gallery:not(.more__list):not(.tldr__list) figcaption {
  margin-top: 0.75rem;
  font-size: 14px;
  color: var(--ink-muted);
  text-align: center;
  text-wrap: pretty;
  /* Wrap at the picture's edge instead of running past it.
     -----------------------------------------------------------------------
     A figure in the strip is flex: 0 0 auto, so its width is whatever its
     widest child asks for. A caption on one line asks for its full sentence,
     and that - not the image - was deciding how wide the figure got: 33 of
     them on a phone, the worst turning a 322px image into a 729px figure
     with the text hanging off both sides.

     width: 0 takes the caption out of that measurement, so the image alone
     sets the figure's width; min-width: 100% then fills it back out to that
     width, and the sentence wraps inside it. Both halves are needed - either
     one on its own does nothing. */
  width: 0;
  min-width: 100%;
}

/* A phone has a narrower picture to fit the words under, and 14px in a
   column that can be 160px wide spends most of it on hyphens. Matches the
   one-column breakpoint the index uses. */
@media (max-width: 39.99rem) {
  .gallery:not(.more__list):not(.tldr__list) figcaption {
    font-size: 12px;
    /* Breathing room between the words and the picture's edges. box-sizing
       is border-box, so the caption box stays exactly the image's width and
       the text insets instead of the box growing. */
    padding-inline: 12px;
  }
}
.gallery:not(.more__list):not(.tldr__list):not(.gallery--inline):not(.gallery--full) img {
  width: auto;
  height: calc(min(88vw, var(--fig-max)) / var(--fig-ratio));
  object-fit: contain;
  background: var(--card);
}

/* A ceiling on how large an image may be drawn, written on the image itself
   in the markup because it is a fact about the FILE and not about the
   design: half its own pixel size, so a retina screen still has two image
   pixels for every CSS pixel it paints.

   Without it the layout stretches a small export to fill its place like any
   other picture, and the browser invents the pixels that are missing - which
   is what made a handful of these soft. Only the images whose originals are
   gone carry one; delete it the day a bigger export replaces the file and
   the picture goes back to filling its box.

   Two properties, because an image is sized on one axis and follows on the
   other, and capping the wrong one is worse than not capping at all. A
   picture in a gallery row is given its height and takes the width its
   proportions ask for, so it caps with --cap-h. One in the reading column or
   on a slide is given width: 100% with height: auto, and capping its height
   there squashes it, so it caps with --cap-w. The ceiling only ever lowers
   what the rules above set; the other axis follows and the proportions hold.

   Full-bleed slide backdrops carry neither. They are a ground for text to
   sit on rather than something to read, and they have to reach both edges -
   a ceiling there letterboxes the band instead of sharpening it.

   Where most of a row needed a ceiling, every picture in that row carries
   the same value rather than its own, so the row stays level. */
img {
  max-height: var(--cap-h, none);
  max-width: var(--cap-w, none);
}
/* A capped picture is narrower than the box it was given, so it is centred
   in it rather than left hanging on one side. No effect without a ceiling:
   at width 100% there is nothing spare to share out. */
.figure img,
.slide__shot img,
.case__cover img { margin-inline: auto; }

.case .gallery:not(.more__list):not(.tldr__list):not(.gallery--inline) {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
}

/* The same gallery, inside a deck slide: the component voila and circles
   use, breaking out of its column the same way they do. A slide's ground
   already runs edge to edge, so the row sits on the slide's own colour
   rather than on the page behind it.

   100% rather than 100vw, unlike the .case rule above. A deck can be
   opened inside the sheet, and there 100vw is the WINDOW - it ignores the
   sheet's own scrollbar, so the row ran wider than the thing holding it
   and pushed the page sideways. The percentage is resolved against the
   actual container either way.

   The gallery also carries a bottom margin for the prose that follows it
   on a case page; in a slide the grid already spaces its blocks, so that
   margin would double up. And --fig-max is only raised for .case at
   desktop, which would have left the deck on the 46rem default however
   wide the window got. */
.slide .gallery {
  width: 100%;
  margin-inline: 0;
  margin-bottom: 0;
  /* No snapping, only here. Everywhere else a gallery snaps by figure, and
     it should - it is a nice thing to have when you flick through one.

     But a slide is a sticky layer that moves on every frame of a page
     scroll, and `scroll-snap-type: mandatory` has to re-resolve its snap
     positions every time its container moves. With six of these inside six
     sticky slides, that was tearing the deck while scrolling - worst going
     UPWARDS, which is the direction where slides un-stick and their
     contents have to be painted again. Booking, the one deck with no
     gallery inside a slide, never glitched.

     The galleries still scroll, by drag, trackpad, shift+wheel and the
     arrow keys. What goes is the magnetic pull when you let go. */
  scroll-snap-type: none;
}
.slide__wide:has(> .gallery) {
  width: 100cqw;
  margin-inline: calc(50% - 50cqw);
}

/* Photographs in a case study run close together: a 10px channel rather than
   the page's 24px (40px on desktop), so a row of screens reads as one strip
   instead of a line of separate pictures.

   Not London. Its galleries are .gallery--full - edge to edge, no channel at
   all - and that is the whole idea of them, so they are excluded by the class
   that makes them that way rather than by the page they are on. The More work
   strip is excluded for the same reason it always is: it is navigation
   borrowing the gallery's scrolling, not photographs. */
.case .gallery:not(.more__list):not(.tldr__list):not(.gallery--full),
.slide .gallery:not(.gallery--full) {
  gap: 10px;
}
@media (min-width: 64rem) {
  .stack { --fig-max: clamp(46rem, 58vw, 76rem); }
}
@media (min-width: 64rem) {
  .case {
    /* Grows with the window, then stops. 86rem is about as far as the
       source images can be pushed before they start to soften. Only the
       SIZE of a figure is a desktop concern - the breakout above applies at
       every width, so the strip runs to the screen edges on a phone too. */
    --fig-max: clamp(46rem, 62vw, 86rem);
  }
}

/* --- The writing index: one card per article ----------------------------
   The same object as a home-page card - same radius, same padding, same
   surfaces - laid out in a plain auto-filling grid rather than the home
   page's three hand-tuned columns. That machinery exists to keep two
   stacked layers in step behind a curtain; there is one layer here, so it
   would be weight for nothing.
   ------------------------------------------------------------------------ */
.writing-index { padding-block: 44px clamp(3rem, 10vw, 6rem); }
/* Type to match the article's title and deck exactly; the column and rhythm
   come from sharing .article__head's own rules further down this file. */
.writing-index__title {
  font-size: clamp(2rem, 5vw, 3.25rem);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: var(--ls-headline);
  margin: 0;
  text-wrap: balance;
}
.writing-index__lede {
  /* Flat, like the 86px under the head above it, so the index keeps the
     same spacing whatever the window does. */
  margin: 22px 0 0;
  font-size: clamp(1.125rem, 2vw, 1.375rem);
  color: var(--ink-muted);
  text-wrap: pretty;
}

.post-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* min() so a card never demands more than the column it is in - without
     it the track floor wins at phone widths and the page scrolls sideways. */
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
  gap: var(--gap);
  /* Every card the same height, not just the ones sharing a row: 1fr makes
     each row as tall as the tallest row, and stretch (the default) fills
     each card to its row. Without the auto-rows the cards would match
     within a row and step between rows. */
  grid-auto-rows: 1fr;
}
.post-grid > li { display: flex; }
.post-grid .card { width: 100%; }

/* The covers are every shape between 1:1 and 3.5:1, so they are cropped to
   one ratio - a row of cards only reads as a row if the pictures line up.
   --post-cover is that ratio; raise it for a longer, thinner crop. */
.post__cover {
  display: block;
  /* The reset caps every picture at 100% of its container, which here is the
     card's CONTENT box - so without this the negative margins below pull the
     cover left but cannot widen it, and it stops short of the right edge. */
  max-width: none;
  margin: calc(var(--pad) * -1) calc(var(--pad) * -1) 0;
  border-radius: var(--radius) var(--radius) 0 0;
  overflow: hidden;
}
.post__cover img {
  width: 100%;
  aspect-ratio: var(--post-cover, 3 / 2);
  height: auto;
  object-fit: cover;
}
.post__title {
  font-size: 1.125rem;
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  /* On top of the card's own 0.75rem gap, so the title sits 32px below the
     cover it belongs to. */
  margin: 20px 0 0;
  text-wrap: pretty;
}
.post__title a { text-decoration: none; }
.card--link:hover .post__title a { text-decoration: underline; text-underline-offset: 0.15em; }
.post__deck {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.45;
  color: var(--ink-muted);
}
/* On a dark or accent card the muted grey is the wrong grey - it is mixed
   for a pale ground. Lean on the card's own foreground instead. */
.card--ink .post__deck,
.card--strong .post__deck,
.card--accent-soft .post__deck { color: inherit; opacity: 0.8; }

/* The orange card is the tight one in this palette - its ink already sits
   close to its ground, so the same 0.8 takes it to 4.18:1 and under AA.
   Full strength here. (The same card's label is capped at 0.85 elsewhere in
   this file for the same reason.) */
.card--accent .post__deck { color: inherit; }

/* --- Article tabs -------------------------------------------------------
   Two views of one article: the whole thing, or the short version as a
   strip of slides.

   Radio inputs, not buttons. It keeps working with JavaScript off, which is
   the rule here, and a radio group already does the keyboard behaviour a
   tab strip is supposed to have - arrows move between the options and the
   view follows. The inputs sit before the row and the panels because the
   switching is done with ~, which only looks forwards. */
.tabs__row {
  display: flex;
  /* The full-width line the tabs sit on. The open tab breaks through it. */
  border-bottom: 1px solid var(--line);
  /* The row is a direct child of .article__body, so the body's own
     `> * + *` rhythm was putting 1.4em above it. The tabs open the article;
     nothing precedes them to be spaced from. */
  margin-top: 0;
  margin-bottom: clamp(1.5rem, 4vw, 2.5rem);
}

.tabs__tab {
  padding: 0.625rem 1.125rem;
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  color: var(--ink-muted);
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  /* Square and borderless while closed. */
  border: 1px solid transparent;
  border-bottom: 0;
  border-radius: 0;
  /* Pulls the tab down onto the row's line so the open one can cover it. */
  margin-bottom: -1px;
  transition: color var(--dur-fast) var(--ease-out);
}
.tabs__tab:hover { color: var(--ink); }


/* The open tab: rounded at the top, ruled on three sides, and no line
   underneath - its own background paints over the row's, which is what
   joins it to the panel below rather than fencing it off. */
#tve-full:checked ~ .tabs__row [for="tve-full"],
#tve-tldr:checked ~ .tabs__row [for="tve-tldr"] {
  border-color: var(--line);
  border-radius: 12px 12px 0 0;
  background: var(--bg);
  color: var(--accent);
  font-weight: 700;
}

/* The radios are off-screen, so the ring has to be put back on the label
   they name - and only for a keyboard, the way every other control here
   behaves. */
.tabs__input:focus-visible ~ .tabs__row [for="tve-full"],
.tabs__input:focus-visible ~ .tabs__row [for="tve-tldr"] {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
#tve-full:focus-visible ~ .tabs__row [for="tve-tldr"],
#tve-tldr:focus-visible ~ .tabs__row [for="tve-full"] { outline: none; }

/* The contents list belongs to the full article. With the TLDR open its
   targets are inside a display:none panel, so every link in it is a link
   that does nothing - it goes away with them. It sits outside .article__body
   and so is not a sibling of the radios, which is why this reaches for it
   with :has() rather than ~. */
.article:has(#tve-tldr:checked) .toc { display: none; }

.tabs__panel { display: none; }
#tve-full:checked ~ .tabs__panel--full,
#tve-tldr:checked ~ .tabs__panel--tldr { display: block; }

/* The body's rhythm is set on direct children of .article__body, and the
   panel is now that child - so it has to hand the rule on to what it
   wraps, or every paragraph inside would lose its spacing. */
.article__body .tabs__panel > * + * { margin-top: 1.4em; }

/* --- The TLDR strip -----------------------------------------------------
   Borrows .gallery for the scrolling and the More work strip's fade, which
   softens the slide the right edge cuts through so the row reads as
   continuing. js/main.js clears it once you reach the end. */
.tldr__list {
  list-style: none;
  margin: 0;
  padding: 0 0 1rem;
  mask-image: linear-gradient(to right, #000 calc(100% - 4.5rem), transparent);
}
.tldr__list.is-at-end { mask-image: none; }

.tldr__item {
  flex: none;
  width: min(22rem, 72vw);
  scroll-snap-align: start;
}
.tldr__item img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-sm, 16px);
  background: var(--card);
}

.tldr__note {
  color: var(--ink-muted);
  font-size: var(--fs-label);
  max-width: var(--measure);
}
/* The note offers a way back without a second control: the label points at
   the radio the other tab already uses. */
.tldr__switch {
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 0.18em;
}

/* --- A filled pill ------------------------------------------------------
   The counterpart to .sheet-exit above, which is the same shape drawn as an
   outline. That one is a way back; this one is a way out to somewhere, so
   it takes the fill. Not scoped to a case study - it is just a button. */
.btn {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding-inline: 1.25rem;
  border-radius: var(--radius-pill);
  background: var(--ink);
  color: var(--bg);
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  transition: background-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
.btn:hover { background: var(--accent); color: var(--ink-on-accent); }
.case__cta { margin-top: 1.5rem; }

/* --- The journal layout -------------------------------------------------
   One case study opens on a photograph rather than the scrolling strip the
   others lead with, because it is a book of pictures and should open like
   one. Full-bleed, same trick the hero gallery uses. */
.case__opener {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  margin-block: 0 clamp(2rem, 5vw, 3.5rem);
}
.case__opener img { display: block; width: 100%; height: auto; }
.case__opener figcaption {
  margin-top: 0.75rem;
  font-size: 14px;
  color: var(--ink-muted);
  text-align: center;
}

/* A gallery that sits INSIDE the reading column rather than breaking out of
   it, and stands shorter than the hero strip - a set within the argument,
   not the argument itself. There will be several of these down one page, so
   they have to read as punctuation. */
.gallery--inline {
  margin-block: clamp(1.5rem, 4vw, 2.25rem);
  /* No gap. These slides are frames of one designed set rather than
     separate pictures - each already carries its own margin - so a gap
     between them reads as a seam through a single layout. */
  gap: 0;
}
.gallery--inline img {
  /* Its own height, and no aspect-ratio declared - the width/height
     attributes on the img supply the ratio, which is what reserves each
     slide before it loads. Same rule as the hero strips. */
  width: auto;
  height: 320px;
  object-fit: contain;
  background: var(--card);
}

/* Where the account goes. See the note in the markup: an actual embed wants
   Instagram's script, which this site does not load. */
.case__embed {
  margin-top: clamp(3rem, 8vw, 5rem);
  padding-top: clamp(2rem, 5vw, 3rem);
  border-top: 1px solid var(--line);
  text-align: center;
}
.case__embed p { color: var(--ink-muted); }
.case__embed .case__cta { margin-top: 1.25rem; }

/* --- The stacked layout (an alternative case-study treatment) -----------
   Each slide sticks to the top of the window and the next one rides up over
   it, so the page reads as a deck being dealt rather than a column being
   scrolled. It is position: sticky and nothing else - no scroll listener,
   no observer, and it behaves with JavaScript off.

   Two things make it work. Every slide needs an opaque background, or the
   one underneath shows through. And no slide may set overflow: hidden,
   because that would kill the stickiness of the aside inside slide 2 - the
   rounded top corner is drawn with a radius on the slide itself instead. */
.slide {
  /* The query container the full-bleed galleries inside measure against.
     100cqw, not 100vw: a deck can be opened in the sheet, and there a
     viewport unit counts the window - including the sheet's own scrollbar -
     so a row sized that way runs wider than the thing holding it and pushes
     the page sideways. The slide's own inline size is right in both places. */
  container-type: inline-size;

  /* A slide is never capped at a screenful: min-height is a floor, so it
     fills the window when it has little to say and grows as tall as its
     content needs when it has more.

     That growth is why the pin is not a plain top: 0. A slide taller than
     the window would pin the instant its top reached the top - with its
     foot still below the fold, never to be read. So it is pinned by
     however much it overflows instead: --slide-top is min(0, window -
     height), which lets a tall slide scroll all the way through and only
     pins it once its last line is on screen. js/main.js measures it; with
     JavaScript off every slide falls back to 0 and pins at the top.

     The block-end padding carries an extra 30px on top of its own: clear
     space under the last line before the next slide starts to climb. */
  position: sticky;
  top: var(--slide-top, 0px);
  min-height: var(--slide-h, 100svh);
  display: grid;
  align-content: center;
  padding-block: clamp(3rem, 9vh, 5.5rem) calc(clamp(3rem, 9vh, 5.5rem) + 30px);
  background: var(--bg);
  /* Rounded at the top so each slide reads as a card dealt over the last,
     and no rule along that edge: every ground here is a flat field of
     colour, and a hairline on a saturated one reads as a stroke rather
     than a seam.

     A rounded corner is a mask, and a masked layer cannot be used to
     cover what is behind it - ten of these stacked inside an overlay that
     is itself masked is what let the page behind show through. js/main.js
     answers that by hiding the slides that are buried: only the one
     directly beneath shows through these corners, so from two down they
     are painting nothing anybody can see. See .slide.is-buried below. */
  border-radius: var(--radius) var(--radius) 0 0;
}
.slide__inner { width: 100%; }
/* A deck is a case study, so its prose is sized by the case-study token
   rather than by the site's 17px base: 20px in the three typefaces that
   take it, 17px in Raleway and Inter. Set on the slide so it reaches every
   kind of text block a slide holds - .slide__prose in ING, .slide__introtext
   and .slide__col in London, and the loose paragraphs beside them - without
   naming each one. Everything with a size of its own (the headings, the big
   line, the kicker, the aside, the nav, figure captions) keeps it. */
.slide { font-size: var(--fs-case); }
/* The slides carry prose but are not .case__text, so they do not inherit
   its rhythm - without this the paragraphs butt together. */
.slide__inner p + p,
.slide__inner .wrap > * + * { margin-top: 1em; }
.slide h2 {
  margin: 0 0 0.75rem;
  font-size: clamp(1.5rem, 3vw, 2.25rem);
  line-height: 1.15;
}
.slide__inner .slide__big { margin-top: 0; }
.slide__inner p + .slide__big { margin-top: 1em; }
.slide__aside h2 { font-size: var(--fs-label); margin-bottom: 0.75rem; }
.slide > .wrap, .slide__inner.wrap { max-width: var(--page-max); }

/* The title block, on the page ground above the deck. It does not stick -
   the first slide rides up over it and it does not come back. */
.stack__head {
  padding-block: clamp(2rem, 6vw, 4rem) clamp(2.5rem, 7vw, 4rem);
  margin-bottom: 0;
}
.stack__head .case__cta { margin-top: 2rem; }

/* Slide 1: the photograph, with the title over it. */
.slide--photo {
  /* No position: relative here - that would replace the sticky above and
     this slide alone would scroll away instead of being stacked on. Sticky
     is itself a positioned element, so the photograph inside still has
     something to be absolute against. */
  border-radius: 0;
  /* The ground under the photograph while it loads. */
  background: var(--card-ink);
  min-height: var(--slide-h, 100svh);
}
.slide__bg { position: absolute; inset: 0; }
.slide__bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Slide 2: the text, with the facts beside it. */
.slide--text {
  grid-template-columns: minmax(0, 1fr);
  column-gap: var(--gap-col);
}
/* 22px flat rather than the 20-28px it used to scale across: at the top of
   a slide this is a sentence to read, not a headline to fill the width.
   In rem so a reader's own font size still carries. */
.slide__big { font-size: 1.375rem; font-weight: 600; line-height: 1.4; }
/* A project's subtitle sits at regular weight - it is a sentence, not a
   second heading. (The London deck keeps 600; this is the project pages.) */
.slide--project .slide__big { font-weight: 400; }
.slide--text .slide__inner { max-width: var(--measure); }
.slide__aside { font-size: 0.9375rem; }
@media (min-width: 64rem) {
  .slide--text {
    grid-template-columns: minmax(0, 1fr) 16rem;
    align-content: center;
    justify-content: center;
    padding-inline: var(--page-margin);
  }
  .slide__aside {
    /* Sticks within its own slide, so it travels with the text and leaves
       with it. */
    position: sticky;
    top: 30vh;
    align-self: start;
  }
}

/* Slide 3: the set, edge to edge. */
.slide--gallery { background: var(--card-ink); color: var(--ink-on-dark); }
/* A ground per set, each with the type colour its contrast demands. */
.slide--gallery.slide--clay {
  background: var(--clay);
  color: var(--ink-on-dark);
}
.slide--gallery.slide--umber {
  background: var(--umber);
  color: var(--ink-on-dark);
}
/* The one mid-tone ground: dark ink rather than white - white would be
   2.76:1 on it. */
.slide--gallery.slide--stone {
  background: var(--stone);
  color: var(--ink);
}
.slide--gallery.slide--oxblood {
  background: var(--oxblood);
  color: var(--ink-on-dark);
}
.slide--gallery .slide__inner { display: grid; gap: clamp(1.5rem, 4vw, 2.5rem); }
/* The third takes the page's own ground, the fourth the greige. The
   compound selectors are deliberate: .slide--tint is defined for the
   project stack further down and would otherwise lose to .slide--gallery
   on source order alone. */
.slide--gallery.slide--plain { background: var(--bg); color: var(--ink); }
.slide--gallery.slide--tint { background: var(--card-tint); color: var(--ink-on-tint); }
.slide--gallery.slide--card { background: var(--card); color: var(--ink); }

/* The strips grew now that no paragraph follows them: the strip drops the
   bottom margin it carries in article flow, and the frames take the space
   that frees plus what the second paragraph used to hold - 52svh to 60svh,
   about 15% taller.

   Deliberately still a definite height rather than a 1fr row filling the
   slide. That was tried: with the row sized by the frames and the frames
   sized by the row, the two have no fixed point, and the browser falls
   back to the images' intrinsic 1125px - slides went to 1483px tall and
   every frame reflowed on load because nothing reserved its space. A
   viewport-relative height stays dynamic and keeps that reservation. */
.slide--gallery .gallery--full { margin-bottom: 0; }
.slide--gallery .gallery--full img { height: min(60svh, 34rem); }
.gallery--full { width: 100%; gap: 0; }
.gallery--full img {
  width: auto;
  height: min(52svh, 30rem);
  object-fit: contain;
  /* No placeholder card here: the slide is black and a pale block per
     frame would flash before the pictures arrive. The width and height
     attributes still reserve the space. */
}

/* Slide 4: the book. */
/* The top corners are rounded like every slide above it; what shows
   through them is the gallery slide still pinned behind. */
.slide--book {
  background: var(--card-strong);
  color: var(--ink-on-dark);
}
.slide__split { display: grid; gap: clamp(1.75rem, 5vw, 3.5rem); align-items: center; }
.slide__col { max-width: var(--measure); }
.slide__figure { margin: 0; }
.slide__figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
}
@media (min-width: 64rem) {
  .slide__split { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); }
}
.slide--book .btn { background: var(--ink-on-dark); color: var(--card-strong); }
.slide--book .btn:hover { background: var(--accent); color: var(--ink-on-accent); }

/* --- A stack of projects (the ING / Booking treatment) -----------------
   Same deck as above, but each slide is a whole project rather than a beat
   in one story, and a project can run for several screens. The rail of
   arrows is what makes that navigable: it rides down the right margin for
   the whole length of a project and jumps to its neighbours. Plain anchor
   links, so it works with JavaScript off. */
/* --- Tabs inside a project slide ---------------------------------------
   The same component the articles use, but driven by position instead of
   by id, so a set can be added without another pair of selectors here: the
   nth radio opens the nth tab and the nth panel. The article's own tabs
   keep their id rules and are untouched by this - they carry no
   .tabs__panel--1 / --2.

   A panel has to re-establish the project layout, because it is now the
   grid item the blocks used to be. */
.tabs__input:nth-of-type(1):checked ~ .tabs__row .tabs__tab:nth-of-type(1),
.tabs__input:nth-of-type(2):checked ~ .tabs__row .tabs__tab:nth-of-type(2) {
  border-color: var(--line);
  border-radius: 12px 12px 0 0;
  background: var(--bg);
  color: var(--accent);
  font-weight: 700;
}
.tabs__input:nth-of-type(1):focus-visible ~ .tabs__row .tabs__tab:nth-of-type(1),
.tabs__input:nth-of-type(2):focus-visible ~ .tabs__row .tabs__tab:nth-of-type(2) {
  /* The site's ring, not the browser's: `revert` handed back the UA default,
     which paints black and white and was the one focus style here that did
     not match the rest. Closer in than the 8px cards use - these sit in a
     row a few pixels apart. */
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
.tabs__input:nth-of-type(1):checked ~ .tabs__panel--1,
.tabs__input:nth-of-type(2):checked ~ .tabs__panel--2 { display: block; }

.slide--project .tabs { width: 100%; }
/* The row is ruled across its whole width, so it is held to the reading
   column rather than the full slide - otherwise the line runs far wider
   than anything under it. */
.slide--project .tabs__row { max-width: var(--measure); margin-inline: auto; }
.slide--project .tabs__input:nth-of-type(1):checked ~ .tabs__panel--1,
.slide--project .tabs__input:nth-of-type(2):checked ~ .tabs__panel--2 {
  display: grid;
  justify-items: center;
}
.slide--project .tabs__panel > * + * { margin-top: clamp(1.5rem, 2.8vw, 2.25rem); }
/* Process is all text-and-a-thing-beside-it, so it is held to the reading
   column like the tab row above it rather than running the full slide. */
.slide--project .tabs__panel--2 { max-width: var(--measure); margin-inline: auto; }
/* Less air above a picture than between two blocks of prose. */
.slide--project .slide__wide { margin-top: clamp(0.75rem, 1.5vw, 1.25rem); }

/* The deck's own ground. Every slide paints its own background, so this is
   never seen in a correct frame - it is here for the frames that are not.
   A stack this size is a lot of composited sticky layers (eight slides, a
   sticky rail inside each project), and if one of them misses a paint the
   window shows whatever is behind: on a page of its own that is the body,
   but inside the sheet it is the home grid. With a ground of its own the
   worst case is a flash of page colour instead of the index showing
   through. Costs nothing - it is the colour that would be there anyway. */
.stack { background: var(--bg); }

/* Anchor targets sit OUTSIDE the slides. A pinned sticky slide is already
   at the top of the window, so a fragment link to it scrolls nowhere -
   which is why the up arrows did nothing while the down ones worked. These
   zero-height markers stay at each slide's natural place in the flow, so a
   link to one scrolls to where that slide begins, in both directions. */
.slide__anchor { height: 0; }

/* A slide with at least two more stacked on top of it cannot be seen: the
   one directly above shows through its own rounded corners, and nothing
   deeper than that does. visibility rather than display, so the slide keeps
   its place in the flow and every pin above it stays where it was - the
   only thing that changes is that the browser stops painting it. */
.slide.is-buried { visibility: hidden; }

/* Square once a slide has reached the top of the window. js/main.js only
   sets this on a page standing on its own: in the overlay a pinned slide's
   corners sit inside the sheet's own rounded top, which is the look, but
   against the top of the browser window there is nothing behind them and
   the corner reads as a notch taken out of the colour. No transition on
   purpose - animating a radius re-makes the mask on every frame, which is
   the work this whole arrangement exists to avoid. */
.slide.is-pinned { border-radius: 0; }

/* --- The deck's navigation --------------------------------------------
   One control for the whole deck, fixed against the right edge and centred
   on the window, rather than one riding along inside each slide.

   The markup still carries a .slide__nav per slide: that is what works
   with no JavaScript, where it scrolls along with its own slide. When
   js/main.js is running it hides those and mirrors the current slide's
   two links into this one instead, so what the arrows do is decided by
   the markup either way.
   ---------------------------------------------------------------------- */
.deck-nav {
  position: fixed;
  top: 50%;
  right: var(--page-margin);
  translate: 0 -50%;
  z-index: 3;
}
.deck-nav[hidden] { display: none; }
:root.has-deck-nav .slide__nav { display: none; }

/* Below the breakpoint the in-slide rails were already hidden, and a
   control pinned over a phone screen would sit on the content. */
@media (max-width: 63.99rem) { .deck-nav { display: none; } }

.slide--overview { background: var(--card-tint); color: var(--ink-on-tint); }
.slide--tint     { background: var(--card-tint); color: var(--ink-on-tint); }
/* The quieter of the two grounds: the card grey rather than the warm tint.
   The gallery slides have their own --card further up, scoped tighter, so
   this does not reach them. */
.slide--card     { background: var(--card); color: var(--ink); }
.slide--project  { align-content: start; }

/* A project reads like an article: one measure-wide column down the middle
   of the page, the same width the articles use, rather than prose running
   the full width of the slide.

   Three kinds of block sit in that column. .slide__prose is text on its
   own, centred. .slide__split keeps the text at its measure and hands
   whatever is left to a figure or a set of numbers beside it. .slide__wide
   is the escape hatch for the few images that are unreadable at anything
   less than the full column - the dense multi-panel slides. */
.slide--project .slide__inner {
  display: grid;
  justify-items: center;
}
.slide--project .slide__inner > * + * { margin-top: clamp(1.5rem, 2.8vw, 2.25rem); }
.slide--project .slide__inner > .slide__nav + * { margin-top: 0; }
.slide__prose,
.slide__split,
.slide__wide { width: 100%; }
.slide__prose { max-width: var(--measure); }
.slide__split { display: grid; gap: clamp(1.5rem, 3.5vw, 3rem); align-items: start; }
.slide__prose > :first-child,
.slide__split > * > :first-child { margin-top: 0; }
.slide__beside > .slide__shot { margin-top: 0; }
@media (min-width: 64rem) {
  /* Symmetric, so the column really is centred, and wide enough on both
     sides that the arrow rail never lands on the content. */
  .slide--project .slide__inner {
    padding-inline: calc(var(--page-margin) + var(--tap) + 1rem);
  }
  /* Not measure-plus-leftovers: that left the side column ~295px, too
     small for a figure to say anything. The text gives up a little width
     - about 53 characters, still an easy line - so the thing beside it is
     big enough to read. */
  .slide__split { grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr); }
  .slide__split > :first-child { max-width: var(--measure); }
}
.slide h3 {
  margin: 1.75rem 0 0.5rem;
  font-size: clamp(1.125rem, 1.8vw, 1.375rem);
  line-height: 1.25;
}
.slide__kicker {
  margin: 0 0 0.5rem;
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  /* Not --accent: orange on white is 3.06:1 and on the greige 2.3:1, and
     this is small text, which wants 4.5. Muted ink clears both. */
  color: var(--ink-muted);
}

/* An opener whose image is a screenshot, not a photograph: shown whole on
   a ground instead of cropped to fill. */
.slide--shot { background: var(--card); }
.slide--shot .slide__bg { inset: clamp(1rem, 4vw, 3.5rem); }
.slide--shot .slide__bg img { object-fit: contain; }

/* The overview: the map of the work. */
.highlights {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: clamp(1.5rem, 3vw, 2.5rem);
  margin-top: clamp(2rem, 4vw, 3rem);
}
.highlights__group h3 {
  margin: 0 0 0.5rem;
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
}
.highlights__group ul { margin: 0; padding: 0; list-style: none; }
.highlights__group li + li { margin-top: 0.35rem; }
/* The lines that have a slide of their own further down, and link to it.
   The orange is in the underline rather than the text: as text it is 2.3:1
   on this ground. */
.highlights .is-covered a {
  font-weight: 600;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}

/* The research numbers, and the result of testing them. */
.slide__stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
  gap: clamp(1rem, 2.5vw, 2rem);
  margin: 1.5rem 0 0;
  padding: 0;
  list-style: none;
}
.slide__stats li { font-size: var(--fs-label); }
.slide__stats b {
  display: block;
  font-size: clamp(2.25rem, 5vw, 3.25rem);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--accent);
  font-variant-numeric: var(--figures) tabular-nums;
}
/* Beside the prose they read as a block of four, not a row of three with
   an orphan under it. */
.slide__beside .slide__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* The accent is 3.06:1 on white, which clears the 3:1 these big numerals
   are held to - but only 2.3:1 on the greige. A tinted slide takes the
   dark brown instead, at 9.71:1. */
.slide--tint .slide__stats b { color: var(--card-strong); }
.slide__results { margin: 1rem 0 0; padding-left: 1.1em; }
.slide__results li + li { margin-top: 0.5em; }

/* A wide image inside a project. No top margin of its own: every block in a
   project slide is already spaced by .slide--project .slide__inner > * + *,
   and the figure's wrapper is one of those blocks. Carrying both put 76px
   between a subtitle and the picture under it where a gallery, which has no
   margin to add, sat at 36. */
.slide__shot { margin: 0; }
.slide__shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
  background: var(--card);
}
.slide__shot figcaption {
  margin-top: 0.75rem;
  font-size: 14px;
  color: var(--ink-muted);
  text-align: center;
}
.slide--tint .slide__shot figcaption { color: inherit; opacity: 0.75; }

/* The arrows. A row above the title on a phone; on a wide screen a rail in
   the right margin, given height: 0 so it costs the column nothing and
   sticky so it stays put for the whole project. */
/* Previous / next. Always the same vertical stack - up, counter, down -
   on every page and at every width. Only where it sits changes:

     narrow, everywhere   above the slide's title, in the flow
     wide, ING + Booking  a sticky rail riding the right margin for the
                          length of the project
     wide, London         beside that slide's own intro, in a column to
                          the right of the title and standfirst

   The galleries are full-bleed, which is why London cannot use the margin
   rail: there is no margin to put it in. */
/* Wide screens only. On a phone the arrows say nothing the scroll does not
   already do, and a vertical pair of them costs real height above every
   title.

   Two elements, on purpose. The nav is the layout slot - a grid cell on
   London, a zero-height sticky strip on the project pages - and the pill
   inside it is the thing you see. Keeping them apart is what lets the
   project rail take up no room in its column while still drawing a pill:
   a background on a zero-height box paints nothing. */
.slide__nav {
  display: none;
  width: 100%;
  max-width: var(--page-max);
  margin: 0 auto 1.5rem;
  padding-inline: var(--page-margin);
}

/* The toolbar on the home page, stood on its end: same pill, same padding
   and gap. No rule and no lift - just the frosted fill, mixed from the
   slide's own ink rather than a fixed white so it reads on the umber and
   on the near-white grounds alike. */
.slide__nav-pill {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.375rem;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, currentColor 10%, transparent);
  backdrop-filter: blur(14px);
}
/* No outline of its own: the pill is the container, the way it is in the
   toolbar. flex: none, or the column squashes them into ovals. */
.slide__nav-btn {
  display: grid;
  place-items: center;
  flex: none;
  width: var(--tap);
  height: var(--tap);
  border-radius: var(--radius-pill);
  color: inherit;
  text-decoration: none;
  transition: background-color var(--dur-fast) var(--ease-out);
}
.slide__nav-btn:hover { background: color-mix(in srgb, currentColor 18%, transparent); }
/* Already inside a .wrap, so it does not need that padding twice. */
.slide__intro > .slide__nav { max-width: none; padding-inline: 0; margin: 0; }
@media (min-width: 64rem) {
  /* flex-start, not the default stretch: the project rail's slot is zero
     tall, and a stretched pill would be squashed to its own padding. */
  .slide__nav { display: flex; align-items: flex-start; }

  /* The rail becomes a column in the right margin only where there IS a
     margin. A project slide has one; a gallery slide is full-bleed edge to
     edge, so there the rail stays a row and sits with the heading instead
     of floating over the photographs. */
  .slide--project > .slide__nav {
    position: sticky;
    top: 42vh;
    z-index: 2;
    height: 0;
    margin-block: 0;
    justify-content: flex-end;
  }

  /* London: the intro becomes two columns and the rail takes the second,
     so the text gives up exactly the width the arrows need. */
  /* Two items, one row, both centred: the middle of the arrows lands on
     the middle of the title-and-standfirst block.

     The text is wrapped for exactly this reason. With the heading and the
     paragraph as separate grid items, the rail had to span two auto rows -
     and a spanning item grows the rows it spans, so the span was taller
     than the text inside it and centring in the span sat 16px low. */
  .slide__intro {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    column-gap: clamp(1.75rem, 4vw, 3.5rem);
    align-items: center;
  }
  .slide__intro > .slide__introtext {
    grid-column: 1;
    grid-row: 1;
    /* The wrap's sibling rhythm gives this a 1em top margin, which is what
       separates it from the rail stacked above it on a narrow screen. Side
       by side there is nothing above it, and align-items centres the
       MARGIN box - so leaving it on sat the arrows half that margin high. */
    margin-top: 0;
  }
  .slide__intro > .slide__nav {
    grid-column: 2;
    grid-row: 1;
    width: auto;
    max-width: none;
    margin: 0;
    padding-inline: 0;
  }
}

/* The last slide lets go of the mechanic. Every slide above it pins and is
   ridden over by the next; this one is static, so it reveals in full, grows
   to whatever its content needs, and then scrolls away like an ordinary
   section. The deck is over before the page continues - which is why the
   trailing block below still needs its opaque ground: the second-to-last
   slide is the one still pinned behind it.

   relative, not static. Sticky siblings are positioned and so paint above
   anything static - a static last slide is laid out correctly and then
   covered by the pinned slide above it, which is to say never seen. This
   lifts it over them without giving it an offset, so it still does not
   pin. */
/* The last slide stops sticking and scrolls away like ordinary content, so
   the deck ends on a full reveal rather than another pin.

   top: auto matters as much as the position. Every slide carries
   top: var(--slide-top), which js/main.js sets to a NEGATIVE value for any
   slide taller than the viewport - that is what lets a tall slide scroll
   through before it pins. On a relatively positioned box the same offset
   just shifts it upwards and leaves its space behind: ING's last slide is
   990px against a 900px window, so it rode 90px up the page and left a
   90px band through which the pinned slide behind showed. London and
   Booking never showed it only because their last slides happen to be
   exactly a screen tall, which makes the offset zero. */
.slide--last { position: relative; top: auto; z-index: 1; }

/* London's closing slide: the account, as a full stop in black - with two
   screenshots of the Instagram grid behind it, faded right back so they
   read as a texture of the archive rather than a picture to look at.

   The images go on a ::before rather than on the slide itself, because the
   fade has to apply to them alone: opacity on the slide would take the
   heading and the button down with it. The black stays on the slide, so
   what the images fade INTO is that black. */
.slide--account {
  background: var(--card-ink);
  color: var(--ink-on-dark);
  text-align: center;
  /* The slide's top corners are rounded and the images must not square
     them off. overflow is not an option here - the deck needs it visible -
     so the pseudo-element inherits the radius instead. */
  isolation: isolate;
}
.slide--account::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  /* One grid on a phone, at full width: two side by side would put the
     tiles at 30px each and the mosaic would read as noise. The second
     joins it at 40rem, where there is room for both at a legible size. */
  background-image: image-set(url("../img/london-street-type/account-01.webp") type("image/webp"),
                              url("../img/london-street-type/account-01.jpg") type("image/jpeg"));
  background-position: center;
  background-size: 100% auto;
  background-repeat: repeat-y;
  opacity: 0.2;
  pointer-events: none;
}
@media (min-width: 40rem) {
  .slide--account::before {
    background-image: image-set(url("../img/london-street-type/account-01.webp") type("image/webp"),
                                url("../img/london-street-type/account-01.jpg") type("image/jpeg")),
                      image-set(url("../img/london-street-type/account-02.webp") type("image/webp"),
                                url("../img/london-street-type/account-02.jpg") type("image/jpeg"));
    background-position: left center, right center;
    background-size: 50% auto;
  }
}
/* Above the images. Without this the positioned ::before would paint over
   the slide's own static content. */
.slide--account .slide__inner { position: relative; z-index: 1; }
.slide--account .btn { background: var(--ink-on-dark); color: var(--card-ink); }
.slide--account .btn:hover { background: var(--accent); color: var(--ink-on-accent); }
.slide--account .case__cta { margin-top: 1.5rem; }

/* What follows the stack sits on an ordinary page again - and needs its own
   opaque ground, because the last slide is still pinned behind it. */
.stack__after {
  position: relative;
  z-index: 1;
  background: var(--bg);
  color: var(--ink);
  /* No radius and no rule along the top: the deck is finished, and the rest
     of the page should read as the page continuing rather than as one more
     card. The opaque ground is still needed - the book slide is pinned
     behind this. */
  /* Same block padding as .case, so the More-work strip has the same air
     under it before the footer as it does on every other case study. */
  padding-block: clamp(2rem, 6vw, 4rem) clamp(3rem, 10vw, 6rem);
}

/* --- More work: the strip at the foot of a case study -------------------
   Every project, in a swipeable row. It reuses .gallery for the scrolling -
   native scroll-snap, no carousel script - and only adds what a row of
   cards needs on top of that.

   The block is identical on all seven pages; each page hides its own card
   with the rules at the bottom of this section. That is what keeps it a
   copy-paste partial rather than seven lists that drift apart.
   ------------------------------------------------------------------------ */
.more {
  margin-top: clamp(3rem, 7vw, 5rem);
  padding-top: clamp(2rem, 5vw, 3rem);
  border-top: 1px solid var(--line);
}
/* In the decks the strip is the first thing in the trailing block, so there
   is nothing above it for a rule to separate it from, and the block's own
   top padding already holds the space. The written case studies keep both:
   there the strip follows the body copy. */
.stack__after .more {
  margin-top: 0;
  border-top: 0;
}
.more__title {
  font-size: var(--fs-title);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  margin: 0 0 clamp(1.25rem, 3vw, 2rem);
}

/* ul.more__list, not .more__list: the reset's `ul[class] { padding: 0 }` is
   a type AND an attribute, which outranks a lone class - so the padding
   this rule used to declare never actually applied. Matching the reset's
   specificity and coming later in the file is what makes it stick.

   The vertical padding is headroom. overflow-x: auto forces overflow-y to
   clip, and the mask below clips to the border box as well, so anything
   that leaves the box is sliced off, including the ring, which reaches
   10px. The negative margins hand the space
   straight back, so the strip occupies exactly the box it did before. */
ul.more__list {
  list-style: none;
  /* All four sides, not just top and bottom: the strip scrolls and is
     masked, both of which clip to its box, and the first card's ring was
     being sliced flat against the left edge. The negative margin hands the
     space straight back, so the cards stay lined up with the column above. */
  padding: var(--more-air);
  margin: calc(var(--more-air) * -1);
  /* The snap start has to move with the padding. Without this the row snaps
     the first card to the BORDER edge, scrolling itself right by exactly the
     padding that was just added and clipping the ring all over again. */
  scroll-padding-inline: var(--more-air);
  /* Softens the card that the right edge cuts through, so the strip reads
     as continuing rather than being sliced off. A mask rather than a
     gradient overlay: it fades to transparent, so it works on whatever is
     behind it without having to be told the background colour. */
  mask-image: linear-gradient(to right, #000 calc(100% - 4.5rem), transparent);
  transition: mask-image var(--dur-fast) linear;
}
/* The ring is the biggest thing that leaves a card: 2px of outline at 8px
   offset, so it reaches 10px. Anything smaller than that clips it. */
.more__list { --more-air: 12px; }
/* Nothing is cut off at the end of the strip, so nothing should look faded.
   js/main.js sets this; without JS the fade just stays, which is harmless. */
.more__list.is-at-end { mask-image: none; }
/* Narrower than the hero gallery's near-full-width figures: these are cards,
   and you want to see that there are more of them waiting. */
.more__item {
  flex: 0 0 min(74%, 17rem);
  scroll-snap-align: start;
}
/* The card fills its slot, so a row of them lines up whatever is inside. */
.more__item .card {
  height: 100%;
  min-height: 9rem;
}

/* No focus rule here on purpose: the strip carries tabindex="0", so the
   global :where([tabindex]) ring at the foot of this file already gives it
   the same treatment the hero gallery gets. */

/* --- More writing: the same strip, with articles in it ------------------
   The cards carry words rather than a wordmark, so they are plain cards
   with a heading and a deck. Everything about the strip itself - the
   scrolling, the sizing, the fade - is shared with More work above.
   ------------------------------------------------------------------------ */
.more__heading {
  font-size: 1.0625rem;
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  margin: 0;
  text-wrap: pretty;
}
.more__heading a { text-decoration: none; }
.card--link:hover .more__heading a { text-decoration: underline; text-underline-offset: 0.15em; }

/* Clipped rather than allowed to set the card's height: a row of cards is
   only a row if they all end in the same place. Three lines is enough to
   tell one piece from another; the article itself is one tap away. */
.more__deck {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.45;
  color: var(--ink-muted);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
}

/* You are already reading this one. Matched against the data-project that
   each case study sets on its <main>; add a line when you add a project. */
[data-project="ing"]                [data-slug="ing"],
[data-project="booking"]            [data-slug="booking"],
[data-project="the-new-motion"]     [data-slug="the-new-motion"],
[data-project="circles"]            [data-slug="circles"],
[data-project="memento"]            [data-slug="memento"],
[data-project="london-street-type"] [data-slug="london-street-type"],
[data-project="voila"]              [data-slug="voila"] { display: none; }

/* Same idea for the article you are reading. */
[data-article="my-recipe-for-a-good-ux-case-study"] [data-slug="my-recipe-for-a-good-ux-case-study"],
[data-article="big-ideas"] [data-slug="big-ideas"],
[data-article="the-many-paths-in-the-life-of-a-designer"] [data-slug="the-many-paths-in-the-life-of-a-designer"],
[data-article="the-natural-design-process"] [data-slug="the-natural-design-process"],
[data-article="1000-questions-1000-answers"] [data-slug="1000-questions-1000-answers"],
[data-article="why-designers-dream-of-becoming-baristas"] [data-slug="why-designers-dream-of-becoming-baristas"],
[data-article="on-purpose"] [data-slug="on-purpose"],
[data-article="ancient-design-prompt-sentient-ai"] [data-slug="ancient-design-prompt-sentient-ai"],
[data-article="limitless-design-in-the-age-of-ai"] [data-slug="limitless-design-in-the-age-of-ai"],
[data-article="taste-vs-empathy"] [data-slug="taste-vs-empathy"],
[data-article="team-review-process-booking"] [data-slug="team-review-process-booking"],
[data-article="what-do-we-mean-when-we-say-design"] [data-slug="what-do-we-mean-when-we-say-design"],
[data-article="roi-of-ux-design"] [data-slug="roi-of-ux-design"],
[data-article="designing-inga-banking-chatbot"] [data-slug="designing-inga-banking-chatbot"] { display: none; }

/* The CV diagram. It carries fine print, so it gets the full width the
   sheet allows and a light ground - the drawing is black line work and
   would disappear into a dark page. */
.cv-figure {
  margin: 0 0 clamp(2rem, 5vw, 3.5rem);
}
.cv-figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
  background: #FFFFFF;
}
.cv-figure figcaption {
  margin-top: 0.75rem;
  font-size: 0.9375rem;
  color: var(--ink-muted);
}

.case__body { display: grid; gap: clamp(2rem, 5vw, 4rem); }
/* A grid item's automatic minimum is its min-content width, and a gallery
   inside one is as wide as its strip - so the column sized itself to the
   pictures instead of to the page. On the archived London page that made
   the text column 708px inside a 360px window. min-width: 0 lets the track
   shrink to the page and hands the horizontal scrolling back to the strip,
   which is where it belongs. */
.case__body > * { min-width: 0; }
@media (min-width: 56.25rem) {          /* 900px */
  .case__body { grid-template-columns: 8fr 4fr; }
  .case__aside { position: sticky; top: 5rem; align-self: start; }
}

/* 18px in the four typefaces that need it, 17px in Raleway, which reads a
   size larger than the rest at the same number - the same adjustment the
   article prose gets, made the same way: by token, so a case study and an
   article move together. The deck slides are already at 17px and are left
   alone. */
.case__text { max-width: var(--measure); font-size: var(--fs-case); line-height: 1.6; }
.case__text > * + * { margin-top: 1.4em; }
.case__text h2 {
  font-size: var(--fs-article-h2);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-headline);
  margin-top: 2.2em;
  text-wrap: balance;
}
/* The only heading on the site that had no rule of its own: it was taking
   the browser's 1.17em and the reading column's 1.6 line-height, which put
   6px of empty line box under every one before the 24px margin even began.
   The gap read as 30px where every other heading reads 26, and 19.89px was
   not a size anywhere in the scale. */
.case__text h3 {
  font-size: 1.25rem;
  line-height: var(--lh-tight);
}
/* A subheading belongs to the paragraph under it, so it sits close to it
   rather than halfway between the two. The column's own 1.4em rhythm still
   governs everything else, including the space ABOVE the heading, which is
   what keeps the sections apart.

   11px, not 12: the heading's line box already leaves a pixel of its own
   under the glyphs, so this is what measures 12px on screen. */
.case__text h3 + * { margin-top: 0.6875rem; }
/* The opening paragraph carries the whole case in three lines, so it is set
   apart from the prose under it - larger, and at the weight this site uses
   for bold everywhere else. */
.case__summary { font-size: 1.3125rem; line-height: 1.5; font-weight: 600; }

.case__facts { display: grid; gap: 0.875rem; }
.case__facts > div { display: grid; gap: 0.15rem; }
.case__facts dt {
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ink-muted);
}
.case__facts dd { margin: 0; font-weight: 600; }

.case__back { margin-top: clamp(2.5rem, 6vw, 4rem); }
.article .case__back { max-width: var(--measure); margin-inline: auto; }
.case__back a { font-weight: 600; }

/* 9b. THE DISTORTION LAYER ================================================
   Built and removed entirely by js/distortion.js. If that file is deleted or
   its config says enabled:false, none of these rules ever match anything.

   Everything here is transform, opacity or a gradient - nothing that forces
   a repaint of the page underneath. That is what keeps a drag at 60fps.
   ========================================================================== */

.distortion {
  position: fixed;
  inset: 0;
  z-index: 7;                 /* over the pages, under the curtain handle */
  pointer-events: none;
  overflow: hidden;
}

/* The burst, on a switch. Each slice is a band of translucent ink shoved
   sideways and fading out - it reads as the page tearing without ever
   touching the page. */
.distortion__slice {
  position: absolute;
  left: 0;
  width: 100%;
  background: rgb(255 255 255 / .1);
  mix-blend-mode: overlay;
  animation: distort-slice var(--dur, 220ms) var(--ease-out) forwards;
}
@keyframes distort-slice {
  0%   { transform: translateX(0);         opacity: 0; }
  20%  { transform: translateX(var(--dx)); opacity: 1; }
  100% { transform: translateX(0);         opacity: 0; }
}

.distortion__flash {
  position: absolute;
  inset: 0;
  background: #FFFFFF;
  mix-blend-mode: overlay;
  animation: distort-flash var(--dur, 220ms) var(--ease-out) forwards;
}
@keyframes distort-flash {
  0%   { opacity: 0; }
  15%  { opacity: var(--peak, 0.3); }
  100% { opacity: 0; }
}

.distortion--scanlines::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom, rgb(0 0 0 / .05) 0 1px, transparent 1px 3px);
}

/* Belt and braces. js/distortion.js already refuses to run under reduced
   motion; this guarantees it even if that check is ever edited out. */
@media (prefers-reduced-motion: reduce) {
  .distortion { display: none; }
}

/* 9c. THE TOOLBAR AND THE EXTRAS ==========================================
   Built entirely by js/eggs.js. Delete that file and none of this matches
   anything. It sits outside both .layer pages, beside the curtain handle,
   because anything inside a layer is clipped by the curtain and goes inert
   when its page is hidden.
   ========================================================================== */

.dock {
  position: fixed;
  bottom: clamp(1rem, 3vw, 2rem);
  left: 50%;
  /* --dock-lift is written by js/eggs.js and is 0 until the footer's top
     rule reaches the toolbar's middle, after which it goes negative and the
     toolbar rides that line instead of covering the footer. */
  translate: -50% var(--dock-lift, 0px);
  z-index: 8;                    /* above the pages and the curtain handle */
}

.dock__row {
  display: flex;
  gap: 0.25rem;
  padding: 0.375rem;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow-lift);
  border: 1px solid var(--line);
}

.dock__btn {
  position: relative;
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  border-radius: var(--radius-pill);
  /* Full ink when idle, not --ink-muted: the marks are hairlines on a
     translucent pill, and at muted they read as half-there. The colour
     transition stays for the pressed state below, which does change it. */
  color: var(--ink);
  transition: color var(--dur-fast) var(--ease-out),
              background-color var(--dur-fast) var(--ease-out);
}
/* Idle is already full ink, so hover is the highlight and nothing else. */
.dock__btn:hover { background: var(--card); }
.dock__btn[aria-pressed="true"] { color: var(--bg); background: var(--ink); }

/* The label. aria-label already names the button for a screen reader, so
   this is purely for people who can see it - hence aria-hidden on nothing
   and no role: it is decoration over an already-labelled control. */
.dock__tip {
  position: absolute;
  bottom: calc(100% + 0.5rem);
  left: 50%;
  translate: -50% 0;
  padding: 0.375rem 0.625rem;
  border-radius: var(--radius-pill);
  background: var(--ink);
  color: var(--bg);
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out);
}
.dock__tip em { font-style: normal; opacity: 0.6; }
.dock__btn:hover .dock__tip,
.dock__btn:focus-visible .dock__tip { opacity: 1; }

/* The toolbar shows on every size now. Which buttons appear is decided in
   js/eggs.js by each egg's platform, not here - a pointer-only extra like
   the lens simply is not built on a phone. */

/* --- THE WALLET STACK  (mobile) ----------------------------------------
   The cards overlap like passes in a wallet: each sits --stack-step below
   the one before and covers its body, leaving the top strip showing. Only
   transform, top and z-index move, so the whole thing stays composited.
   ------------------------------------------------------------------------ */
.grid[data-layout="stack"] {
  --stack-step: 62px;
  --stack-card: 230px;
  /* Breathing room between an opened card and the stack pushed below it, so
     the two read as separate things rather than one long card. */
  --stack-gap: 16px;

  display: block;
  position: relative;
  /* Air above and below the whole fan, so it does not butt up against the
     header or whatever follows it. On the container rather than the cards:
     they are absolutely positioned, so a margin on them would do nothing. */
  margin-block: 30px;
  /* Tall enough for the fan plus the last card in full. While a card is
     open, js/eggs.js measures the new total and writes --h-total. */
  height: var(--h-total, calc((var(--n) - 1) * var(--stack-step) + var(--stack-card)));
  transition: height var(--dur-base) var(--ease-out);
}
.grid[data-layout="stack"] .col { display: contents; }

.grid[data-layout="stack"] .card {
  position: absolute;
  inset-inline: 0;
  /* Where a card sits and how tall it is. The fallbacks are the resting
     fan; opening a card replaces both on every card in the grid. */
  top: var(--top, calc(var(--i) * var(--stack-step)));
  height: var(--h, var(--stack-card));
  /* Later cards sit on top, so what you see of each one is its top edge.
     The opened card leaves the order, but the rest keep it, so the one
     lower down still overlaps the one above it. */
  z-index: calc(var(--i) + 1);
  overflow: hidden;
  box-shadow: 0 -8px 24px rgb(0 0 0 / .22);
  transition: top var(--dur-base) var(--ease-out),
              height var(--dur-base) var(--ease-out),
              box-shadow var(--dur-base) var(--ease-out);
}

/* The one you tapped. It rises to the top of the stack and opens to its
   full height, so you read the whole card; everything else is pushed down
   below it, keeping its order. Heights and offsets come from JS, which is
   the only place the content height can actually be measured. */
.grid[data-layout="stack"] .card.is-front {
  z-index: 999;
  box-shadow: 0 18px 44px rgb(0 0 0 / .38);
}

/* A card in the stack is a target, so nothing inside it should look
   separately tappable until it is at the front. */
.grid[data-layout="stack"] .card:not(.is-front) a { pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
  .grid[data-layout="stack"],
  .grid[data-layout="stack"] .card { transition: none; }
}

/* --- The layout grid ---------------------------------------------------
   Drawn BEHIND the content, inside each page, so it reads as paper the site
   is printed on rather than a film laid over it. Because it lives inside the
   layer it inherits that page's tokens - the dark page gets a dark grid with
   no colour written twice.

   Four things stacked, all neutral grey so they sit on either background:
   a dot lattice, a fine ruling, a heavier ruling on the row gap, and the
   real column structure on top. The columns use the same tokens the grid
   itself uses, so it cannot show a grid the page is not built on.
   ------------------------------------------------------------------------ */
.egg-grid {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Set on the whole overlay rather than on each of the four layers below,
     so the balance between dots, rules and columns stays as it was drawn
     and only the overall weight changes. Kept as two rules, one per side,
     even though both now sit at the same value: the grey behaves
     differently on white than it does on near-black, so this is the knob
     to reach for if one side ever needs to part from the other. */
  opacity: 0.5;
}
.layer--light .egg-grid { opacity: 0.5; }
.layer--dark  .egg-grid { opacity: 0.5; }

/* .layer is a static grid item, so inset: 0 above was resolving against the
   initial containing block - viewport-sized - and the grid stopped dead at
   the fold while the page ran on to full height. Making the layer the
   containing block lets the overlay take its real height, footer included.
   Scoped to .is-gridding so nothing else inherits a new containing block
   for the sake of an overlay that is off most of the time. */
.is-gridding .layer { position: relative; }

/* Everything else in the page floats above it. */
.is-gridding .layer > :not(.egg-grid) { position: relative; z-index: 1; }

.egg-grid__paper {
  position: absolute;
  inset: 0;
  background-image:
    /* The dot lattice: nine dots to a 40x40 cell, three to a side, evenly
       spaced across the whole page rather than only within each cell.

       At 10, 20 and 30 the spacing inside a cell was 10 but the step over
       a rule was 20, so the dots read as clusters of three. The pitch has
       to divide the 40 and be offset by half itself for the rhythm to
       carry across the rules AND keep clear of them: 40/3 gives 13.333,
       putting the three at 6.667, 20 and 33.333. Every gap is now 13.333,
       the one spanning a rule included, with 6.667 of clear ground either
       side of it.

       One tile of nine rather than nine stacked gradients, because a
       radial-gradient draws a single dot per tile: three rows alone took
       three layers, and three columns as well would have taken nine. The
       grey and its alpha are the same numbers the rules use; the overlay's
       own opacity still dims the whole thing per side. */
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Cg fill='%23808080' fill-opacity='.34'%3E%3Ccircle cx='6.667' cy='6.667' r='1'/%3E%3Ccircle cx='20' cy='6.667' r='1'/%3E%3Ccircle cx='33.333' cy='6.667' r='1'/%3E%3Ccircle cx='6.667' cy='20' r='1'/%3E%3Ccircle cx='20' cy='20' r='1'/%3E%3Ccircle cx='33.333' cy='20' r='1'/%3E%3Ccircle cx='6.667' cy='33.333' r='1'/%3E%3Ccircle cx='20' cy='33.333' r='1'/%3E%3Ccircle cx='33.333' cy='33.333' r='1'/%3E%3C/g%3E%3C/svg%3E"),
    /* fine horizontal ruling */
    repeating-linear-gradient(to bottom, rgb(128 128 128 / .09) 0 1px, transparent 1px 8px),
    /* Vertical ruling, one line per logo height, so the page is measured
       against something on it rather than an arbitrary 8px. */
    repeating-linear-gradient(to right,  rgb(128 128 128 / .06) 0 1px, transparent 1px var(--logo-h)),
    /* the heavier rule, one per row gap */
    repeating-linear-gradient(to bottom, rgb(128 128 128 / .20) 0 1px, transparent 1px 40px);
  background-size: 40px 40px, auto, auto, auto;
  background-position: 0 0;
}

.egg-grid .wrap { height: 100%; }
.egg-grid__cols {
  height: 100%;
  display: grid;
  grid-template-columns: minmax(0, var(--card-max));
  gap: 0 var(--gap-col);
  justify-content: center;
}
.egg-grid__cols span {
  background:
    /* a subdivision inside each column, so a wide card reads as two */
    repeating-linear-gradient(to right, rgb(128 128 128 / .07) 0 1px, transparent 1px 20px),
    rgb(128 128 128 / .07);
  border-inline: 1px solid rgb(128 128 128 / .32);
}
@media (min-width: 40rem) {
  .egg-grid__cols { grid-template-columns: repeat(2, minmax(0, var(--card-max))); }
}
@media (min-width: 80rem) {
  .egg-grid__cols { grid-template-columns: repeat(3, minmax(0, var(--card-max))); }
}

/* --- The inspector -----------------------------------------------------
   The measure-distance overlay from a design tool, on the real page. Three
   colours, each meaning one thing:

     blue    the element itself - its true shape, its handles, its size
     red     its bounding box, square-cornered, ignoring the radius
     orange  the distances to whatever is next to it

   js/eggs.js only ever measures and positions; everything about how it
   looks is here.
   ------------------------------------------------------------------------ */
.egg-inspect {
  position: fixed;
  inset: 0;
  z-index: 9;
  pointer-events: none;
  --ins-shape: #2F6BFF;
  --ins-box:   #B03A2E;
  --ins-dist:  #E8502A;
}
.is-inspecting, .is-inspecting * { cursor: crosshair !important; }

.egg-inspect > * { position: absolute; top: 0; left: 0; }

/* The element's own shape, radius and all. */
/* Both outlines are drawn INSIDE the bounds they describe. An outline grows
   outward from its offset, so at offset 0 it sits entirely beyond the edge
   and the highlight reads a couple of px bigger than the thing it is
   measuring - and every hop to the next element looks like a jump. A
   negative offset equal to the width puts the line just within the edge,
   so what you see is the element's real size. */
.ins-shape {
  outline: 1.5px solid var(--ins-shape);
  outline-offset: -1.5px;
}

/* Its bounding box: square corners, so the difference between the two lines
   IS the corner radius, drawn. */
.ins-box { outline: 1px solid var(--ins-box); outline-offset: -1px; }

/* Selection handles at the four corners of the box. */
.ins-box i {
  position: absolute;
  width: 7px;
  height: 7px;
  background: #fff;
  border: 1.5px solid var(--ins-shape);
  border-radius: 1px;
}
.ins-box i:nth-child(1) { top: -4px;    left: -4px; }
.ins-box i:nth-child(2) { top: -4px;    right: -4px; }
.ins-box i:nth-child(3) { bottom: -4px; left: -4px; }
.ins-box i:nth-child(4) { bottom: -4px; right: -4px; }

/* The size, centred under the element. Above the distance labels: it sits
   under the element's bottom edge, which is exactly where a gap measured
   downwards puts its own number, and the two were overlapping. Everything
   in here is a positioned sibling at z-index auto, so paint order was DOM
   order and the size happened to lose. */
.ins-size {
  z-index: 1;
  translate: -50% 0.5rem;
  padding: 0.1875rem 0.5rem;
  border-radius: 5px;
  background: var(--ins-shape);
  color: #fff;
  font: 400 0.8125rem/1.4 var(--font-ui);
  font-variant-numeric: var(--figures) tabular-nums;
  white-space: nowrap;
}

/* A dashed line spanning each measured gap... */
.ins-guide {
  border-top: 1px dashed var(--ins-dist);
  border-left: 1px dashed var(--ins-dist);
}
.ins-guide[data-dir="up"], .ins-guide[data-dir="down"] { border-top: 0; }
.ins-guide[data-dir="left"], .ins-guide[data-dir="right"] { border-left: 0; }

/* ...and the number sitting on the middle of it. */
.ins-dist {
  translate: -50% -50%;
  padding: 0.0625rem 0.3125rem;
  border-radius: 4px;
  background: var(--ins-dist);
  color: #fff;
  font: 400 0.75rem/1.5 var(--font-ui);
  font-variant-numeric: var(--figures) tabular-nums;
}

/* --- BENTO -------------------------------------------------------------
   The compressed layout. Nothing moves in the DOM; the grid just stops
   being three independent columns and becomes one grid with uniform rows,
   so every card lines up with every other one.

   The cost of uniform rows is that a card can no longer be as tall as its
   content, so prose is clamped rather than allowed to overflow. That is the
   trade the layout makes, and it is why this is a toggle and not the
   default.
   ------------------------------------------------------------------------ */
@media (min-width: 80rem) {
  .grid[data-layout="bento"] {
    /* The columns dissolve so the cards become grid items directly. */
    grid-template-columns: repeat(3, minmax(0, var(--card-max)));
    grid-auto-rows: var(--bento-row, 200px);
    align-items: stretch;
  }
  .grid[data-layout="bento"] .col { display: contents; }
  .grid[data-layout="bento"] .col--start,
  .grid[data-layout="bento"] .col--end { padding-top: 0; }

  /* The stagger is a column trick; with one grid there are no columns to
     stagger, so it goes. Sizes come from the same data-size the rest of
     the site uses. */
  .grid[data-layout="bento"] [data-size="1x2"] { grid-row: span 2; }
  .grid[data-layout="bento"] [data-size="1x1"] { grid-row: span 1; }
  .grid[data-layout="bento"] [data-size="2x1"] { grid-column: span 2; }

  /* The two lead stats come down to one row, so every stat card in the
     grid is the same object - same height, same numeral, same caption.
     It has to sit after the [data-size] rules: they carry the same
     specificity, and theirs says span 2. */
  .grid[data-layout="bento"] .card--stat-lead { grid-row: span 1; }

  /* The portrait is the one card with a place of its own: middle column,
     second slot down, whatever the load-time shuffle did to the order.

     Grid places explicitly positioned items BEFORE it auto-places the rest,
     so this is a reservation rather than a fight - everything else flows
     around the hole it leaves. It has to sit after the [data-size] rules
     above, which carry the same specificity and would otherwise win on
     source order and hand the row back to `span 2` from an auto row.

     Three columns only. This block is already inside the 80rem query, and
     below it the grid is 1 or 2 columns, where a "middle" does not exist. */
  .grid[data-layout="bento"] .card--portrait {
    grid-column: 2;
    grid-row: 2 / span 2;
  }
}

/* Prose clamps instead of overflowing. Applied at every width so the rules
   do not have to be repeated in the breakpoint above.

   --clamp is the line budget. The values here are per card SIZE, because a
   1x2 card is 440px against a 1x1's 200px and a single number for both cut
   the tall ones with a hundred pixels standing empty underneath. They are
   still only an opening guess: how many lines actually fit depends on what
   else is in the card - a numeral, a title, a button - which a stylesheet
   cannot see. js/eggs.js measures that and overwrites --clamp per element.
   These are what you get before it runs, and what you keep without JS. */
.grid[data-layout="bento"] .hero__body,
.grid[data-layout="bento"] .stat__caption,
.grid[data-layout="bento"] .card__text {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--clamp, 3);
  overflow: hidden;
}
/* A stat card spends most of its row on the numeral, so its caption gets
   two lines, not three. */
.grid[data-layout="bento"] [data-size="1x1"] .stat__caption { --clamp: 2; }
/* Twice the row, and the numeral does not grow with it, so the caption
   gets the whole of the second row. The hero keeps less: its card opens
   with a display-size line that can run to five lines on its own. */
.grid[data-layout="bento"] [data-size="1x2"] .stat__caption,
.grid[data-layout="bento"] [data-size="1x2"] .card__text { --clamp: 7; }
.grid[data-layout="bento"] [data-size="1x2"] .hero__body { --clamp: 4; }

/* ...and the numeral comes down to make room for them. At its masonry size
   it ate 70 of the 120 usable pixels in a 200px row, leaving space for one
   line while the clamp asked for two - so the second was sliced through the
   middle of the glyphs instead of ending in an ellipsis. Smaller here is
   not a style choice; it is what makes the clamp honest. */
.grid[data-layout="bento"] :is([data-size="1x1"], .card--stat-lead) .stat__value {
  font-size: clamp(1.75rem, 11cqw, 2.75rem);
  /* The lead cards carry an optical pull-back tuned for an 11rem numeral.
     At this size it is noise, and the other stat cards do not have it. */
  margin-inline-start: 0;
}

/* Truncated text fades out instead of stopping dead. js/eggs.js adds this
   only to elements that are actually cut, so text that fits is untouched -
   a mask applied unconditionally would fade short captions too.

   The fade is one line deep and measured from the BOTTOM, so it always
   begins on a line boundary and never touches the line above. A share of
   the box cannot do that: at 62% a tall five-line body faded from line
   four, which was about right, but a two-line caption faded from halfway
   down line two - so the last readable line was half gone. Every line but
   the final one is now at full strength, whatever the budget turns out to
   be. The percentage underneath is for browsers without the lh unit. */
.grid[data-layout="bento"] .is-truncated {
  -webkit-mask-image: linear-gradient(to bottom, #000 62%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 62%, transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1lh), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 1lh), transparent 100%);
}
/* The writing card holds five titles, each with a deck, and they do not fit
   a fixed row. The decks go first and the titles stay: a list of what is
   there is worth more at this size than a shorter list with descriptions. */
.grid[data-layout="bento"] .writing-list .deck { display: none; }
.grid[data-layout="bento"] .writing-list { gap: 0.625rem; }
.grid[data-layout="bento"] .writing-list li:nth-child(n+5) { display: none; }

/* The portrait fills its slot rather than setting its own height. */
.grid[data-layout="bento"] .card--portrait picture,
.grid[data-layout="bento"] .card--portrait img {
  height: 100%;
  aspect-ratio: auto;
  object-fit: cover;
}

/* --- The lens ----------------------------------------------------------
   A glass object following the pointer, with the page refracting through
   its rim. The bending is real: js/eggs.js generates a displacement map of
   the lens shape and an SVG filter pushes pixels along it, three times at
   slightly different strengths so the channels separate into a colour
   fringe. The settings that drive it are the GLASS block in js/eggs.js,
   named after Figma's Glass panel.

   What is left here is the part light does rather than glass: the specular
   highlight, the tint, and the thickness at the edge. Those are gradients
   and inset shadows, which cost nothing.
   ------------------------------------------------------------------------ */
.lens {
  position: fixed;
  top: 0; left: 0;
  width: var(--lens, 260px);
  height: var(--lens, 260px);
  z-index: 9;
  pointer-events: none;
  /* The same corner as the cards, so the lens reads as another object from
     the same set rather than a foreign circle laid on top. */
  border-radius: var(--radius);
  /* Rounds the refracted result. The filter runs inside, on a larger box. */
  overflow: hidden;
  box-shadow: 0 24px 60px rgb(0 0 0 / .45);
}
.is-lensing, .is-lensing * { cursor: none !important; }

/* Bigger than the lens on every side. The filter is applied here, so
   refraction at the rim samples page that carries on past the edge rather
   than the nothing beyond a clip. --bleed is computed in js/eggs.js from
   the glass settings: it is always at least the furthest any pixel moves. */
.lens__bleed {
  position: absolute;
  inset: calc(var(--bleed, 40px) * -1);
  overflow: hidden;
}
.lens__inner {
  position: absolute;
  top: 0; left: 0;
  width: 100vw;
  height: 100vh;
  transform-origin: 0 0;
  will-change: transform;
}

/* The glass on top. The middle stays clear - it is something you look
   through, and anything painted over the centre is in the way. The light
   lives at the rim, where a real lens is thickest.

   --hl-x / --hl-y come from the light angle in js/eggs.js. Thickness is
   inset shadows rather than a radial gradient: a gradient is elliptical and
   pools in the corners of a rounded rectangle, while an inset shadow
   follows the shape exactly. */
.lens__glass {
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
  background:
    radial-gradient(34% 22% at var(--hl-x, 20%) var(--hl-y, 20%),
      rgb(255 255 255 / calc(0.55 * var(--hl-level, 1))) 0%,
      rgb(255 255 255 / calc(0.14 * var(--hl-level, 1))) 45%,
      transparent 78%);
  box-shadow:
    /* the lit rim itself */
    inset 0 0 0 1px rgb(255 255 255 / .40),
    inset 0 1px 14px rgb(255 255 255 / .18),
    /* the bevel: wide and dark, which is what makes it read as a thick
       object rather than a hole cut in the page */
    inset 0 0 22px 2px rgb(0 0 0 / .34),
    inset 0 0 46px 16px rgb(0 0 0 / .26),
    inset 0 -10px 26px rgb(0 0 0 / .22);
}

/* The bright edge opposite the light: where a thick edge catches a second,
   weaker highlight. No colour fringe here any more - the filter produces a
   real one from the dispersion setting. */
.lens__glass::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
  background:
    radial-gradient(30% 20% at calc(100% - var(--hl-x, 20%)) calc(100% - var(--hl-y, 20%)),
      rgb(255 255 255 / calc(0.26 * var(--hl-level, 1))) 0%, transparent 70%);
}

@media (prefers-reduced-motion: reduce) { .lens { display: none; } }

/* --- The player --------------------------------------------------------
   A small window in the spirit of a late-nineties desktop audio player -
   chunky bar, drag it around, close it - drawn entirely from this site's own
   tokens. The artwork is original; none of it is anybody's skin.
   ------------------------------------------------------------------------ */
.player {
  position: fixed;
  /* Opens centred on the toolbar that summoned it, the same 10px above it
     as the filter menu - derived from the dock's own geometry rather than
     guessed. Drag it anywhere from there; the title bar clears these. */
  left: 50%;
  right: auto;
  translate: -50% 0;
  bottom: calc(clamp(1rem, 3vw, 2rem) + var(--tap) + 0.75rem + 0.75rem);
  z-index: 9;
  width: min(22rem, calc(100vw - 2rem));
  border-radius: 14px;
  overflow: hidden;
  background: var(--card-strong);
  color: var(--ink-on-dark);
  box-shadow: var(--shadow-lift);
  /* The bevel: a light inner edge on top, a dark one underneath. Two inset
     shadows, which is how those windows got their plastic look. */
  box-shadow: var(--shadow-lift),
              inset 0 1px 0 rgb(255 255 255 / .18),
              inset 0 -1px 0 rgb(0 0 0 / .35);
}

.player__bar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.5rem 0.5rem 0.875rem;
  cursor: grab;
  touch-action: none;
  /* The site's own ink, not a colour of its own. The player sits outside
     both pages, so --ink resolves from :root - the near-black the light
     page sets its text in - and stays put whichever side is showing. */
  background: var(--ink);
  color: #fff;
}
.player__bar:active { cursor: grabbing; }
.player__title {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
/* Three bars that bounce while something is playing. Decoration, so it is
   aria-hidden, and it holds still under reduced motion. */
.player__beat { display: flex; align-items: flex-end; gap: 2px; height: 12px; }
.player__beat i {
  width: 3px;
  background: currentColor;
  border-radius: 1px;
  animation: beat 900ms var(--ease-out) infinite alternate;
}
.player__beat i:nth-child(1) { height: 60%; animation-delay: 0ms; }
.player__beat i:nth-child(2) { height: 100%; animation-delay: 160ms; }
.player__beat i:nth-child(3) { height: 45%; animation-delay: 320ms; }
@keyframes beat { from { transform: scaleY(0.4); } to { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .player__beat i { animation: none; } }

.player__min,
.player__close {
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 4px;
  color: #fff;
  /* Lifted off the bar, not punched into it: a dark wash was invisible
     once the bar itself became near-black. */
  background: rgb(255 255 255 / .14);
}
/* The pair sits at the right end of the bar; this is what pushes them
   there, so the title keeps the left. */
.player__min { margin-left: auto; }
/* The upright stroke of the plus. Hidden while the player is open, so the
   button reads as a minus; shown once it is collapsed, so it reads as a
   plus. The label changes with it - see js/eggs.js. */
.player__plus { display: none; }
.player.is-min .player__plus { display: block; }
.player__min:hover,
.player__close:hover { background: rgb(255 255 255 / .28); }

/* --- Minimised: the island ---------------------------------------------
   The window collapses to a pill at the top of the page, in the space the
   header leaves between the logo and the nav.

   The iframe is NOT removed and NOT display:none - it keeps its size and
   stays rendered, just clipped and transparent, because anything that
   unmounts it stops the music. That is the whole reason minimise and close
   are different things.
   ------------------------------------------------------------------------ */
.player.is-min {
  /* Centred on the logo's own centre line, worked out from the same two
     tokens the header uses - so the three line up whatever the header
     padding does at a given width, and none of them is a magic number. */
  top: calc(var(--header-pad) + var(--logo-h) / 2);
  left: 50%;
  right: auto;
  bottom: auto;
  translate: -50% -50%;
  width: auto;
  border-radius: var(--radius-pill);
  overflow: hidden;
}
.player.is-min .player__bar {
  cursor: default;
  padding: 0.5rem 0.5rem 0.5rem 0.875rem;
  gap: 0.625rem;
}
.player.is-min .player__foot { display: none; }

/* Kept at full size so playback is untouched; simply not visible.
   -------------------------------------------------------------------------
   Moved OUT of the pill rather than faded out inside it. It used to sit at
   top: 0 - directly over the title bar - and rely on opacity: 0 to hide it,
   which held until the track changed and then failed: the bar turned into a
   plain block of colour, which was the top-left corner of Spotify's artwork
   showing through.

   opacity cannot be trusted here. The frame is cross-origin and it is
   playing media, so the browser gives it its own compositing layer, and a
   composited layer re-promoted mid-playback - which is what a track change
   does - can paint straight through an ancestor's opacity. It is hidden on
   open and visible a song later, which is exactly the shape of the bug.

   top: 100% puts it below the pill instead, where the pill's own
   overflow: hidden clips it away completely. Nothing is faded, nothing is
   unmounted: the iframe keeps its size, stays in the document and keeps
   playing, and there is no longer any geometry in which it can cover the
   bar. It stays just under the pill rather than off at -9999px so the
   browser still counts it as on-screen and does not throttle it. */
.player.is-min .player__screen {
  position: absolute;
  /* Parked off the side of the screen, not hidden and not clipped.
     -----------------------------------------------------------------------
     Two previous attempts both failed on a real browser playing real audio.
     First opacity: 0 on top of the bar; then top: 100%, relying on the
     pill's own overflow: hidden to clip it away. Each looked right in
     testing and each let Spotify's player through once a track changed.

     The reason both failed is the same. A cross-origin iframe playing media
     is promoted to its own compositing layer, and a composited layer is not
     reliably subject to an ancestor's opacity OR its overflow clip. Anything
     that leaves the frame inside the pill's box and then asks the browser
     to hide it is making a request the compositor is free to ignore.

     -9999px asks for nothing. The frame is simply not where the pill is -
     it is thousands of pixels off the left edge, so there is no pixel of
     the viewport it could paint into even if every clip were ignored. The
     iframe is still in the document, still its full size, still playing:
     off-screen does not pause audio, which is what keeps minimise different
     from close. */
  top: 0;
  left: -9999px;
  width: 20rem;
  height: 352px;
  pointer-events: none;
}
/* And a second line of defence, in case that geometry is ever changed back:
   the bar gets a stacking context of its own so it is painted above the
   frame whatever happens to the two boxes. */
.player.is-min .player__bar {
  position: relative;
  z-index: 1;
}

/* The window is a stack of flush panels, the way a hardware player is. So
   nothing here is inset and nothing is rounded - the window's own corners do
   the clipping, and the sections butt straight up against each other with no
   seam. (An EQ panel sat between the bar and the playlist; the artwork is
   still in img/finamp.* if it comes back.) */
.player__screen { padding: 0; }
.player__screen iframe { display: block; width: 100%; height: 352px; border: 0; border-radius: 0; }
/* The way out if the frame comes back empty. */
.player__foot {
  padding: 0.5rem 0.75rem;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
}
.player__foot a {
  color: color-mix(in srgb, var(--ink-on-dark) 70%, transparent);
  text-decoration-color: transparent;
  transition: color var(--dur-fast) var(--ease-out),
              text-decoration-color var(--dur-fast) var(--ease-out);
}
.player__foot a:hover { color: var(--ink-on-dark); text-decoration-color: currentColor; }

/* 10. FOCUS AND REDUCED MOTION ============================================= */

/* One focus style for everything, and the same ring a card draws on hover:
   2px of orange, 8px out. :focus-visible means it shows for keyboard users
   and not on mouse clicks. Never remove this without a replacement.

   It used to be sandwiched between a white and a near-black halo, because
   the orange alone manages 2.73:1 on the tinted card and disappears on the
   orange one. The halo is gone for consistency with hover, which means the
   ring now relies on what is behind it: 3.06:1 on the white page and
   5.86:1 on the dark one, both over 1.4.11's 3:1 - but under it on the
   tinted surfaces. Restoring the two box-shadow lines brings it back. */
:where(a, button, [tabindex], summary, input, [role="slider"]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 8px;
  border-radius: 4px;
}

/* A work card draws the ring on the card itself (see .card--link above), so
   the stretched link inside it must not draw a second one. The transparent
   outline is not a no-op: Windows High Contrast mode repaints it as a real
   colour, so the ring survives there. */
.card--link a:focus-visible {
  outline: 2px solid transparent;
  box-shadow: none;
}

/* Anyone who has asked their OS to reduce motion gets no motion at all:
   every duration collapses to zero and smooth scrolling is turned off.
   Because all durations are tokens, this single block covers the whole site,
   including anything added later. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 0.01ms;
    --dur-base: 0.01ms;
    --dur-intro: 0.01ms;
    --dur-sheet: 0.01ms;
  }
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}
