/* ==========================================================================
   Neomi Livshitz — UX Portfolio · colorful editorial

   One typeface, two weights, one ink: Nunito Sans 400 sets everything you
   read, Nunito Sans 600 sets the titles, and every character is black.
   Nothing is italic, nothing is uppercase, and no sentence carries a bolded
   phrase — so the only typographic difference between two pieces of text is
   how big they are. The colour stays where it always was, in the artwork and
   the surfaces: each project's tint is still promoted from a tile accent to
   the page's hero: one large rounded panel of colour with the illustration
   big inside it. Below the panel, a short story on paper with lots of air.
   One easing curve at two speeds; hover, press and focus answered everywhere.

   Enforced by site/audit.py — see GOAL-02-redesign.md for the budgets.
   ========================================================================== */

/* --------------------------------------------------------------- webfonts ---
   One variable file carries both weights. Served from this origin, not from
   Google's CDN, so the page makes no third-party request and the CSP can say
   font-src 'self'. Subset and licensed — see assets/fonts/README.md. */

@font-face {
  font-family: "Nunito Sans";
  src: url("assets/fonts/nunito-sans-400-600.woff2?v=1") format("woff2");
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

:root {
  color-scheme: light;

  /* Ground. White, site-wide. The warm paper was a page colour competing with
     eleven project tints; on white every tint reads as the page's own. */
  --paper:        #FFFFFF;
  --paper-raised: #FFFFFF;
  --paper-sunk:   #F4F1EB;
  /* The alternating section ground. A pale blue-grey rather than the sand of
     --paper-sunk, which is warm and would have read as a second page colour
     next to it. This is Saved Triggers' #DEE8F1 mixed roughly 25% into white,
     so the band belongs to the same family as the tints on the tiles above it
     rather than arriving from outside the palette. Lightened from a 40% mix on
     2026-08-17: the band only has to be felt, not seen. The About portrait's
     highlights are toned to exactly this value, so the studio backdrop behind
     her meets the band rather than sitting on it as a lighter square. */
  --paper-cool:   #F6F9FC;

  /* Ink — one value, and every character on the site is set in it. The two
     greys below it are gone from type; what is left of them draws the
     browser-chrome dots and the walkthrough ticks, which are not type.

     Not pure black since 2026-08-17. This is the dark end of the same cool
     family as --paper-cool: near-black with a deep blue in it, so the type and
     the section grounds belong to one temperature instead of warm ink sitting
     on a cool page. Contrast against white is about 19:1, well past AAA. The
     two greys and both rules were shifted with it; leaving them on the old warm
     base is what would have made the change look like a mistake. */
  --ink:    #10161F;
  --ink-2:  #4C5563;
  --ink-3:  #8992A0;

  /* Lines */
  --rule:        rgba(16, 22, 31, 0.10);
  --rule-strong: rgba(16, 22, 31, 0.18);

  /* Accent — the coral from the monogram; the site-level colour. */
  --accent:      #D8452A;
  --accent-soft: #FBEBE6;

  /* Per-project tint, overridden inline on each article. The page-level
     colour: the hero panel, the media grounds, the markers, the numbers. */
  --tint:     #F3E3DA;
  --tint-ink: #A85434;

  /* ---- Type — five sizes, two weights, one face ------------------------
     hero    the index greeting and the contact close
     title   project titles, band heads, the big statistics
     h2      section heads, quotes, tile titles
     base    body copy, deks, standfirsts
     small   captions, meta, chips, tables, chrome              */
  --t-hero:  clamp(38px, 5.5vw, 68px);
  --t-title: clamp(30px, 4vw, 48px);
  --t-h2:    clamp(21px, 2.4vw, 27px);
  --t-base:  16.5px;
  --t-small: 13.5px;

  /* Spacing — margins and paddings sit on this scale */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --sp-7: 48px;
  --sp-8: 64px;
  --sp-band: clamp(80px, 10vw, 128px);

  /* Motion — one curve, three speeds. The third is new in Round 5 and is not a
     third interaction speed: nothing responds at 5.2s. It is the idle loop for
     ambient art that runs on its own, deliberately slower than anything a
     person triggers, so it never competes with feedback or movement. */
  --ease:  cubic-bezier(.2, .7, .3, 1);
  --dur-1: .15s;   /* feedback: colour, borders, presses */
  --dur-2: .5s;    /* movement: lifts, crossfades, reveals */
  --dur-3: 5.2s;   /* ambient: unattended loops in illustration */

  --r-s: 12px;   /* product evidence: a tighter corner than a panel wants */
  --r-m: 18px;
  --r-l: 28px;
  --r-pill: 999px;

  --font: "Nunito Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
          "Helvetica Neue", Arial, sans-serif;

  --wrap: 1140px;
  --prose: 620px;
  --wide: 940px;
  --gutter: 28px;
  /* The hero panel's inset, and with it the page's text edge. One token,
     because the body column pads by the same amount so every section starts
     where the hero title starts. Round 141. */
  --pad-card: clamp(28px, 4.5vw, 60px);
}

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-base);
  line-height: 1.6;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  overflow-x: clip;
}

img, svg { display: block; max-width: 100%; }
a { color: inherit; }

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

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

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

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --------------------------------------------------------------------------
   Two roles, and only two

   A title is Nunito Sans 600. Every sentence, caption, label, table cell,
   chip and figure is Nunito Sans 400. This is the only rule that
   grants 600, so a heading is the one thing on a page that is heavier than
   its body, and inline emphasis inside a paragraph no longer exists as a
   weight. Hierarchy is size and space.
   -------------------------------------------------------------------------- */

.monogram,
h1, h2, h3,
.stat b,
.pager b,
.points b,
.project .solution-details dt,
.project .profile-details dt,
.project .file-front dt,
.project .case-snapshot dt,
.project .case-impact > .p-kicker {
  font-weight: 600;
}

/* The browser's own defaults are the other way a third style gets onto a page.
   <b>, <strong> and <th> come bolded, <em>, <cite> and <i> come slanted, and
   <code> comes in a monospace face nobody chose. Each of those marks something
   real in the copy and keeps its tag; none of them changes how the sentence is
   set. Weight inherits, so a <b> inside a heading is still a heading. */
b, strong, th {
  font-weight: inherit;
}
em, i, cite, q, dfn, address, blockquote {
  font-style: normal;
}
code, kbd, samp, pre {
  font-family: inherit;
}

/* Kickers and small wayfinding: small, upright, sentence case, one weight. */
.p-kicker { margin: 0 0 var(--sp-3); }

/* Inside a case study the kicker carries the section label, so the heading
   under it is free to say what happened. Same pattern as a solution card. */
.p-body .p-kicker {
  margin: 0 0 var(--sp-2);
  font-size: var(--t-small);
}

/* Deks and standfirsts — body size, held to a short measure. */
.hero-lede,
.p-dek,
.section-head p,
.contact p {
  line-height: 1.55;
}

/* --------------------------------------------------------------------------
   Header
   -------------------------------------------------------------------------- */

/* At rest the masthead is only ever over the hero, so it takes the hero's
   ground: a white bar above a cool field is the first thing a visitor would
   otherwise see. The moment anything scrolls it becomes the ordinary white
   chrome bar with its hairline. */
.masthead {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--paper-cool);
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-1) var(--ease),
              background-color var(--dur-1) var(--ease);
}

.masthead.is-stuck {
  background: var(--paper);
  border-bottom-color: var(--rule);
}

.masthead .wrap {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-5);
  height: 68px;
}

/* The mark alone. It is the drawing Neomi supplied, cut out of its blue ground
   and served as a file rather than inlined, so it caches once for all thirteen
   pages. The name is not repeated beside it: the link carries it as an
   aria-label, and the hero says it in 68px type one scroll below. */
.monogram {
  display: block;
  flex: none;
  text-decoration: none;
}
/* The mark is a wordmark now, not a square monogram, so it is set by cap
   height rather than by box height: 38px of square read as a small icon, and
   38px of wordmark is 377px of type shouting across the masthead. Round 150. */
.monogram img { display: block; width: auto; height: 18px; }

@media (max-width: 560px) {
  /* On a phone the wordmark is half the masthead and pushes the CV pill off
     the screen, so the mark stands in for it. The glyph is the leading square
     of the same file, cropped, so there is no second asset to keep in sync. */
  .monogram img {
    height: 20px;
    width: 20px;
    object-fit: cover;
    object-position: left center;
  }
}

.masthead nav {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
  font-size: var(--t-small);
}

.masthead nav a {
  text-decoration: none;
  position: relative;
  padding-block: 2px;
}
.masthead nav a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -1px;
  height: 1px;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-1) var(--ease);
}
.masthead nav a:hover::after { transform: scaleX(1); }

/* The one item in the masthead that is an action rather than a destination, so
   it is the one drawn as a button. Same ink pill as the hero CTA, and the pill
   is drawn once, in the rule grouped with .hero-cta in the hero section, so
   the surface and the press states cannot drift apart. Here it only gets sized
   to the nav's own type step, with the sliding underline turned off: a filled
   pill has nowhere to put it. */
.masthead nav a[data-cv] { padding: 8px 16px; }
.masthead nav a[data-cv]::after { content: none; }

/* --------------------------------------------------------------------------
   Index hero
   -------------------------------------------------------------------------- */

/* The landing screen is the whole screen. 68px is the masthead, which is
   sticky and therefore always over the top of it. svh rather than vh so a
   phone's collapsing address bar cannot push the button under the fold; the
   vh line above it is the fallback for anything that does not know svh. */
.hero {
  background: var(--paper-cool);
  display: grid;
  align-content: center;
  min-height: calc(100vh - 68px);
  min-height: calc(100svh - 68px);
  padding: clamp(48px, 8vw, 96px) 0;
  text-align: center;
}

.hero h1 {
  margin: 0 auto var(--sp-5);
  font-size: var(--t-hero);
  line-height: 1.04;
  letter-spacing: -0.01em;
  text-wrap: balance;
}
.nb { white-space: nowrap; }

/* The greeting, above the heading. Moved out of the h1 in Round 71: the largest
   type on the site now says what the work is, and the introduction it replaced
   was already carried by the tab title, the monogram and About. It sits at body
   size from Round 84, matching the lede under the heading, so the hello and the
   two sentences it introduces read as one voice rather than as a label over a
   paragraph. Everywhere else a .p-kicker is --t-small; this is the exception. */
.hero .p-kicker { margin: 0 auto var(--sp-4); }

.hero-lede {
  margin: 0 auto var(--sp-4);
  max-width: 46ch;
}
.hero-lede:last-of-type { margin-bottom: 0; }

.hero-cta {
  justify-self: center;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-3);
  margin-top: clamp(32px, 5vw, 48px);
  padding: var(--sp-4) var(--sp-6);
}

/* The site's one ink pill, worn by its two actions: this CTA and the masthead
   CV. One rule, so the surface and the press states cannot drift apart. */
.hero-cta,
.masthead nav a[data-cv] {
  border-radius: var(--r-pill);
  background: var(--ink);
  color: #fff;
  text-decoration: none;
  transition: opacity var(--dur-1) var(--ease);
}
.hero-cta:hover, .masthead nav a[data-cv]:hover { opacity: .82; }
.hero-cta:active, .masthead nav a[data-cv]:active { opacity: .7; }
.hero-cta svg {
  flex: none;
  width: 13px;
  height: 9px;
  transition: transform var(--dur-2) var(--ease);
}
.hero-cta:hover svg { transform: translateY(2px); }

.inline-link {
  text-decoration: none;
  background-image: linear-gradient(var(--ink), var(--ink));
  background-size: 100% 1px;
  background-position: 0 100%;
  background-repeat: no-repeat;
  padding-bottom: 1px;
  transition: opacity var(--dur-1) var(--ease);
}
.inline-link:hover { opacity: .65; }

/* --------------------------------------------------------------------------
   Work index — colour first
   -------------------------------------------------------------------------- */

.index { padding: clamp(64px, 8vw, 96px) 0 clamp(64px, 9vw, 112px); }

.index-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(36px, 5vw, 64px) clamp(24px, 3.4vw, 42px);
}

.tile {
  display: block;
  text-decoration: none;
  color: inherit;
}

.tile-art {
  position: relative;
  aspect-ratio: 16 / 10;
  border-radius: var(--r-l);
  background: var(--tint);
  border: 1px solid transparent;
  overflow: hidden;
  transition: transform var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
}
.tile-art svg { width: 100%; height: 100%; color: var(--tint-ink); }

/* An index tile carrying a screenshot rather than a drawing.

   The crop is scaled to the tile's height and anchored left, so the frame cuts
   it on the right. That is deliberate: at a size where a whole screen fits, an
   enterprise UI is grey mush, and the point of showing product is that someone
   can read it. Letting the tile cut the image buys the scale back, and the cut
   edge says there is more of this behind the tile.

   The tint stays visible as an L down the left and along the top and bottom, so
   the palette still does the separating, and a crop can be any shape because it
   is the frame, not the box, that decides where it ends. Author the crop so the
   part that survives the cut is the part worth reading.

   The shadow is `.solution-image`'s, not a new one: a case study already frames
   product evidence this way further down the same page, and an index tile showing
   a screenshot is the same kind of object. The corner is not shared, though. Down
   the page a screenshot is a rounded object sitting in the copy; here it is a
   window cut by its frame, and a rounded corner on an edge that is about to be
   sliced argues with itself. Square, all four. */
/* The tint's own edge, drawn a little stronger than the tint.

   Only the tiles whose art is a crop carry it, and that is the whole reason
   rather than a coincidence: on those four the tint is visible as an L around
   the crop, so an edge in a stronger mix of the same colour closes a shape the
   eye is already reading. A full-bleed tile below has no tint showing at all,
   and a stroke there would be framing a photograph, which is a different idea.

   A border rather than an inset shadow: `.tile:hover .tile-art` replaces
   `box-shadow` outright, and a stroke that vanished on hover would read as a
   bug. Every tile reserves the 1px so the crop does not shift by a pixel
   between one tile and the next. */
.tile-art.is-shot {
  border-color: color-mix(in srgb, var(--tint) 80%, var(--tint-ink) 20%);
}

.tile-art.is-shot img {
  /* Out of flow, with the height written out rather than inherited. Two simpler
     spellings both fail. `height: 100%` on a flex child falls back to the file's
     own pixel height, because `.tile-art` takes its height from `aspect-ratio`
     and that is not a height a percentage resolves against, so the crop lost its
     top rows off the tile. Pinning `top` and `bottom` and leaving both dimensions
     auto fails the other way: an absolutely positioned replaced element then
     takes its intrinsic pixel size and ignores the offsets. Out of flow with an
     explicit height, the percentage resolves against the containing block and
     `width: auto` follows the crop's own ratio, which is the whole point. The
     image is never squeezed, only cut. */
  position: absolute;
  top: var(--sp-5);
  left: var(--sp-5);
  height: calc(100% - var(--sp-5) * 2);
  width: auto;
  max-width: none;
  border-radius: 0;
  box-shadow: 0 18px 40px -26px rgba(16, 22, 31, .38);
}

/* A portrait subject bleeds down instead of across. A phone scaled to the tile's
   height is 135px wide and says nothing; sized by width instead, it fills the
   tile and the frame cuts it at the bottom. Same idea, other axis.

   Centred rather than anchored left, because a lone device reads as a subject and
   a subject sits in the middle of its frame. The other three are anchored left
   because their left edge is where the reading starts.

   No shadow here, because a device is not a rectangle: it is painted into the
   file along with the ground, which is why that file is the one piece of art
   coupled to its project's tint. Re-cut it if the tint moves. */
.tile-art.is-shot.is-tall img {
  left: 0;
  right: 0;
  margin-inline: auto;
  width: 74%;
  height: auto;
  box-shadow: none;
}

.tile:hover .tile-art,
.tile:focus-visible .tile-art {
  transform: translateY(-6px);
  box-shadow: 0 26px 48px -28px rgba(16, 22, 31, 0.45);
}

.tile-art img,
.tile-art video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Earlier work folds behind a native disclosure — no JS involved. */
.index-more {
  grid-column: 1 / -1;
  margin: clamp(12px, 2.5vw, 28px) 0 0;
}
.index-more .index-grid { margin-top: clamp(36px, 5vw, 64px); }

.index-break {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  cursor: pointer;
  list-style: none;
}
.index-break::-webkit-details-marker { display: none; }
.index-break::before,
.index-break::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--rule);
}
.index-break span {
  white-space: nowrap;
  transition: opacity var(--dur-1) var(--ease);
}
.index-break span::after {
  content: "+";
  display: inline-block;
  margin-left: var(--sp-2);
  color: var(--ink);
  transition: transform var(--dur-1) var(--ease);
}
.index-break:hover span { opacity: .65; }
.index-break:active span { opacity: .7; }
.index-more[open] .index-break span::after { transform: rotate(45deg); }

.tile-meta { padding: var(--sp-4) var(--sp-1) 0; }
.tile-meta h3 {
  margin: 0 0 var(--sp-1);
  font-size: var(--t-h2);
  line-height: 1.15;
}
.tile-meta p {
  margin: 0;
  font-size: var(--t-small);
  color: var(--ink);
}

/* --------------------------------------------------------------------------
   Project hero — the tint promoted to a full panel
   -------------------------------------------------------------------------- */

.p-hero { padding-top: var(--sp-5); }

.p-hero .panel {
  background: var(--tint);
  border-radius: var(--r-l);
  padding: var(--pad-card);
  display: grid;
  grid-template-columns: minmax(0, 1.02fr) minmax(0, 0.98fr);
  gap: clamp(24px, 4vw, 56px);
  align-items: center;
}

/* A case study opens on words. Its hero art was a diagram of the thing the page
   is about, sitting directly above a page full of the real screens, and it was
   saying nothing the story did not say better. So there is no art column here
   at all: title, dek, snapshot.

   Craft pages keep the rule above. Their art is a photograph of the work and
   there is no evidence trail under it, so it is the only thing showing the work
   at the top of the page.

   The measures are lifted rather than widened. Both were set to hold the copy
   inside half a panel, and with the art column gone there is nothing for them
   to clear: a title that stopped at 20ch and a dek that stopped at 56ch left
   the right half of the panel empty for no reason. The dek runs long at 1440px,
   which is the trade Neomi chose over the empty half; the snapshot's own
   columns are what break the panel back up. */
.p-hero .panel.is-plain {
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
}
.p-hero .panel.is-plain h1,
.p-hero .panel.is-plain .p-dek { max-width: none; }

/* No hero back-link on any case study. The masthead is sticky and carries
   "All work" at every scroll position and the colophon repeats it, so a third
   route spent the first line of every page on navigation. */

.project-index {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  color: var(--ink);
  margin-bottom: var(--sp-4);
}
.project-index .stage b { }

.p-hero h1 {
  margin: 0 0 var(--sp-4);
  font-size: var(--t-title);
  line-height: 1.04;
  letter-spacing: -0.01em;
  max-width: 16ch;
}

.p-dek {
  margin: 0;
  max-width: 44ch;
}

.p-dek + .p-dek { margin-top: var(--sp-3); }

.p-facts {
  margin: var(--sp-6) 0 0;
  font-size: var(--t-small);
  color: var(--ink);
}
.p-facts b { color: var(--ink); }

.p-art svg { width: 100%; height: auto; color: var(--tint-ink); }
.p-art img,
.p-art video {
  width: 100%;
  height: auto;
  border-radius: var(--r-m);
  box-shadow: 0 24px 48px -32px rgba(16, 22, 31, 0.4);
}

/* Labelled project facts. A project without snapshot data keeps the one-line
   facts treatment. The label matches every other label on a case study: body
   size, semi-bold.
   Round 134 cut this from six stacked fields to three inline ones. Six fields
   stacked as label-over-answer meant twelve lines of metadata above the fold,
   which read as a form rather than as a header, and three of the six (Team,
   Product area, Research sample) were evidence the body already carries and
   carries better. What is left is the reader's three questions: who were you on
   this, how long, what happened. Label and answer share a line, separated by a
   colon, so each field is one line instead of two. The row is a flex line
   rather than a grid cell so a long answer wraps under itself without the
   label stretching a column. */
.project .case-snapshot {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-3);
  margin: var(--sp-3) 0 0;
  padding: var(--sp-5) 0 0;
  border-top: 1px solid var(--rule-strong);
}
.project .case-snapshot div {
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--sp-2);
}
.project .case-snapshot dt {
  margin: 0;
  font-size: var(--t-base);
  line-height: 1.55;
}
.project .case-snapshot dt::after { content: ":"; }
.project .case-snapshot dd {
  margin: 0;
  line-height: 1.55;
}

/* Impact, as the page's opening statement. Round 132 put this in the header on
   the argument that a recruiter reads the outcome first; Round 134 kept that
   hook as the snapshot's one-line Outcome row and moved the three full
   statements to the close; Round 135 moved them back to the top, above the
   walkthrough, and gave the close a paragraph instead. Round 136 followed that
   move with the layout: at the foot of a page three statements are the last
   thing read and stack in the prose column, but at the top they are a row to
   scan, so they run as columns on the wide measure. The dash marker lifts above
   the lead rather than sitting to its left, which lines the columns up on their
   first word and needs no rule between them. */
.project .case-impact { margin: 0; }
.project .case-impact .points {
  margin: var(--sp-4) 0 0;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: var(--sp-6);
}

/* Round 137: inside the hero panel the impact block is the third band, under
   the snapshot, and takes the snapshot's own separator so the panel reads as
   identity, facts, impact rather than as three unrelated things sharing a
   tint. The statements drop to small type: at body size, three of them under a
   snapshot at body size gave the panel no hierarchy at all, and the lead of
   each statement is already doing the scanning work. */
/* Three bands in one panel need less air between them than two did. The panel's
   row gap was set for a header whose whole content was a title and a dek; with a
   one-line tagline over two ruled bands it was leaving a void above each rule.
   Scoped by :has so only a summary header tightens, and the dek headers on the
   other case studies keep the rhythm they were drawn for. */
.p-hero .panel:has(.case-impact) { row-gap: var(--sp-5); }

.p-hero .case-impact {
  grid-column: 1 / -1;
  margin: var(--sp-3) 0 0;
  padding: var(--sp-5) 0 0;
  border-top: 1px solid var(--rule-strong);
}
.p-hero .case-impact .points { margin: var(--sp-4) 0 0; }
.p-hero .case-impact .points li { font-size: var(--t-small); }

/* The header is one grid, not two blocks that happen to share a panel. The
   three snapshot fields take three tracks with the label stacked above its
   answer, which is the label treatment the rest of the case study uses, and the
   three impact statements sit on the same three tracks below them. The colon
   goes with the change: it was there to separate a label from an answer on the
   same line. Below the breakpoint both blocks stack and the label returns to
   its own line with the colon, because three tracks in 390px is not a grid.
   Round 139. */
@media (min-width: 900px) {
  .p-hero .case-snapshot {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--sp-6);
  }
  .p-hero .case-snapshot div { display: block; }
  .p-hero .case-snapshot dt { margin-bottom: var(--sp-1); }
  .p-hero .case-snapshot dt::after { content: ""; }
  .p-hero .case-impact .points {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
.project .case-impact .points li { line-height: 1.45; padding-left: 0; }
/* No marker on an impact statement. Round 136 lifted the dash above the lead so
   the columns would line up on their first word; Round 139 found the lead does
   that on its own, and three tinted dashes in a header that already carries a
   tint were the only decoration on the page. The lead is the marker. */
.project .case-impact .points li::before { display: none; }

/* --------------------------------------------------------------------------
   Project body — a short story with air

   Every block centres on the prose column; media and galleries break out
   wider. Section heads are serif; markers and numbers take the page's tint.
   -------------------------------------------------------------------------- */

.p-body {
  padding: clamp(56px, 8vw, 96px) 0 clamp(64px, 9vw, 112px);
  display: grid;
  gap: clamp(48px, 7vw, 80px);
}
.p-body > * {
  width: 100%;
  min-width: 0;
  max-width: var(--prose);
  margin-inline: auto;
}
.p-body > .breakout { max-width: var(--wide); }
.breakout > p, .breakout > .note { max-width: 66ch; }

/* One left edge for the whole page. The body used to run two centred measures,
   620px for a story and 940px for evidence, alternating all the way down, so at
   1440px the left edge of a heading moved 160px sideways every time the section
   type changed. A reader cannot tell that jump means "this is evidence", so it
   read as restlessness. Now every section fills the column and pads by
   --pad-card, the hero panel's own inset, so the first character of every
   section sits exactly under the first character of the title. The text keeps a
   reading measure inside that column and stops early on the right; the grids,
   figures and charts use the full width. Below 900px nothing changes, because
   there both measures are already wider than the viewport and every section is
   full width anyway. Round 141. */
@media (min-width: 900px) {
  .p-body > *,
  .p-body > .breakout {
    max-width: none;
    padding-inline: var(--pad-card);
  }
  /* Both margins, not just the left one. A reading measure inside the column
     left every paragraph stopping short of the screenshots beside it, so the
     right edge stayed as ragged as the left edge used to be. A section's own
     copy now runs the full column, which is the only way the two edges can
     agree. Copy nested inside a card or a figure still takes its measure from
     the card. Round 142. */
  .p-body > * > h2,
  .p-body > * > p,
  .p-body > * > ul,
  .p-body > * > ol,
  .p-body > * > cite,
  .p-body > * > .brief,
  .p-body > * > blockquote { max-width: none; }
  .breakout > p, .breakout > .note { max-width: none; }
  .p-body > .research-statement,
  .p-body > .bigquote { max-width: none; }
}

.p-body h2 {
  margin: 0 0 var(--sp-4);
  font-size: var(--t-h2);
  line-height: 1.2;
}

.story p {
  margin: 0 0 var(--sp-4);
  color: var(--ink);
}
.story p:last-child { margin-bottom: 0; }

/* An aside that qualifies the claim above it. */
.note {
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink);
  padding-left: var(--sp-4);
  border-left: 2px solid var(--rule-strong);
}

/* The brief — the mandate, set on the page's own colour. */
.brief {
  background: var(--tint);
  border-radius: var(--r-m);
  padding: clamp(20px, 3vw, 32px);
  /* A tinted block needs air under it, not just inside it. Without this the
     line that follows sat directly on the panel's bottom edge and read as part
     of it. The space above it is the paragraph's own bottom margin, so only the
     underside is set here. Round 145. */
  margin: 0 0 var(--sp-6);
}
.brief p { margin: 0 0 var(--sp-3); color: var(--ink); }
.brief p:last-child { margin-bottom: 0; }
.brief q { quotes: "\201C" "\201D"; }

/* A supporting quote inside a story — quieter than the bigquote. */
.pull {
  margin: var(--sp-5) 0;
  padding-left: var(--sp-4);
  border-left: 3px solid var(--tint-ink);
}
.pull p {
  margin: 0 0 var(--sp-1);
  color: var(--ink);
}
.pull cite {
  font-style: normal;
  font-size: var(--t-small);
  color: var(--ink);
}

/* Recommendations on the index. Three root classes were not affordable at the
   old budget, so everything below the card hangs off element selectors: the
   card is a <figure>, the fold a <details>, the face an <img>, the source link
   the only <a> in the caption. Only .praise and .rec are new names.

   align-items is start, not the grid default of stretch. Stretched, opening one
   card grew the row and every other card grew with it, which reads as all of
   them expanding at once. */
.praise {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: clamp(28px, 3.4vw, 40px);
  align-items: start;
  max-width: 980px;
  margin-inline: auto;
}

/* The shadow alone left the top edge undefined, because it is cast downward:
   the card appeared to dissolve into the page along its upper corners. The
   stroke draws the whole outline at one weight and the shadow only lifts it. */
.rec {
  margin: 0;
  padding: clamp(28px, 3.2vw, 38px);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-l);
  box-shadow: 0 1px 2px rgba(16, 22, 31, .04),
              0 18px 40px -30px rgba(16, 22, 31, .3);
  transition: translate var(--dur-2) var(--ease),
              box-shadow var(--dur-2) var(--ease);
}
/* `translate`, not `transform`: see the note on .principle:hover. The card
   carries .reveal, and `.js .reveal.is-in { transform: none }` wins on
   specificity, so the lift written as a transform never drew once the card had
   scrolled into view. */
.rec:hover {
  translate: 0 -4px;
  box-shadow: 0 2px 4px rgba(16, 22, 31, .05),
              0 28px 56px -30px rgba(16, 22, 31, .42);
}

/* The quote mark is set type, not a graphic, so it stays on the scale. */
.rec blockquote { margin: 0 0 clamp(24px, 3vw, 32px); }
.rec blockquote::before {
  content: "\201C";
  display: block;
  margin-bottom: var(--sp-2);
  font-size: var(--t-title);
  line-height: .7;
}
.rec blockquote p { margin: 0; }

/* The fold animates in two ways, and only one of them is guaranteed. The
   revealed paragraph always rises and fades, which is a plain keyframe. The
   card's own height also interpolates where ::details-content and
   interpolate-size are supported, and simply snaps where they are not, so the
   enhancement can never be the thing that breaks the control. */
.rec { interpolate-size: allow-keywords; }
.rec details::details-content {
  block-size: 0;
  overflow: hidden;
  transition: block-size var(--dur-2) var(--ease),
              content-visibility var(--dur-2) allow-discrete;
}
.rec details[open]::details-content { block-size: auto; }

@keyframes rec-unfold {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}
.rec details[open] > p { animation: rec-unfold var(--dur-2) var(--ease) both; }

.rec summary { list-style: none; cursor: pointer; }
.rec summary::-webkit-details-marker { display: none; }

/* The one span in the summary is the control: its label is swapped on [open]
   and the chevron it holds turns over. */
/* flex, not inline-flex: the control has to break to its own line under the
   quotation rather than trail the last word of it. fit-content keeps the hover
   and click target the width of the label instead of the whole card. */
.rec summary > span {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: var(--sp-2);
  margin-top: var(--sp-4);
  font-size: var(--t-small);
  white-space: nowrap;
}
.rec summary > span::before { content: "Read more"; }
.rec details[open] summary > span::before { content: "Show less"; }
.rec summary svg {
  flex: none;
  width: 11px;
  height: 8px;
  transition: transform var(--dur-2) var(--ease);
}
.rec details[open] summary svg { transform: rotate(180deg); }
/* Open, the control sits between two paragraphs and needs air on both sides,
   or it reads as a heading for the text it just revealed. */
.rec details[open] summary { margin-bottom: var(--sp-4); }
.rec summary:hover > span { opacity: .65; }
.rec summary:active > span { opacity: .5; }

.rec figcaption {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  padding-top: var(--sp-5);
  border-top: 1px solid var(--rule);
}
.rec figcaption img {
  flex: none;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  object-fit: cover;
  background: var(--rule);
}
/* Size carries the hierarchy here, as everywhere: the name sits at body size
   and everything under it at caption size. Type is --ink throughout. */
.rec figcaption > span { display: grid; gap: 1px; min-width: 0; }
.rec figcaption > span span { font-size: var(--t-small); }

/* Pushed to the far edge of the row. It points at the recommendation itself
   rather than at the person, because that is the thing this card is a copy of. */
.rec figcaption a {
  flex: none;
  margin-left: auto;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border: 1px solid var(--rule);
  border-radius: var(--r-s);
  opacity: .55;
  transition: opacity var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease);
}
.rec figcaption a svg { width: 18px; height: 18px; }
.rec figcaption a:hover { opacity: 1; border-color: var(--ink); }
.rec figcaption a:active { opacity: .7; }

@media (max-width: 400px) {
  .rec figcaption { gap: var(--sp-3); }
  .rec figcaption a { display: none; }
}

/* Short points inside a story. */
/* The lead is a label, not the first half of the sentence: it sits on its own
   line the way a snapshot label used to sit above its answer, so a list of
   points scans as headings before anybody reads a word of the explanation.
   Round 134 widened this from the header Impact block to every points list on
   a case study, because the alternative was two label voices on one page. */
.project .points b { display: block; margin-bottom: var(--sp-1); }
.points {
  list-style: none;
  margin: var(--sp-5) 0 0;
  padding: 0;
  display: grid;
  gap: var(--sp-3);
}
.points li {
  position: relative;
  padding-left: var(--sp-5);
  color: var(--ink);
}
.points li::before {
  content: "";
  position: absolute;
  left: 0; top: 0.68em;
  width: 11px; height: 2px;
  border-radius: 1px;
  background: var(--tint-ink);
}

/* Numbered variant, for the solution walk. */
.points--num { counter-reset: pt; }
.points--num > li { padding-left: var(--sp-6); }
.points--num > li::before {
  counter-increment: pt;
  content: counter(pt, decimal-leading-zero);
  top: 0;
  width: auto; height: auto;
  background: none;
  border-radius: 0;
  font-size: var(--t-small);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* Four scannable solution modules: the copy keeps the case study's narrow
   measure while real product evidence carries the other half of each card. */
/* One ground for the whole section instead of four module panels. The frame
   sits on the section, taking the heading inside it, and the modules sit on
   that single ground with the grid gap doing the separating. */
.project .solution-section {
  /* The same inset as the hero panel, so the one tinted section in the body
     starts its copy on the page's text edge rather than 12px inside it.
     Round 141. */
  padding: var(--pad-card);
  border-radius: var(--r-l);
  /* Mixed from the project tint rather than set as its own hex, so the section
     ground can never be stranded on an old palette. The hero panel is the tint
     at full strength; this is the same colour at a little over half, which keeps
     the section quieter than the header it sits under. */
  background: color-mix(in srgb, var(--tint) 55%, #fff);
}
.project .solution-section > h2 { margin-bottom: var(--sp-7); }
.project .solution-grid {
  display: grid;
  gap: clamp(56px, 7vw, 88px);
}
/* Equal columns, so the ratio follows the copy rather than the column index.
   With both columns the same there is nothing for the even-module flip to
   correct, which is what used to render every second screenshot smaller. The
   module itself is unframed: the screenshot's own rounded boundary is the only
   framed object in the section, and the caption sits on the page. */
.project .solution-card {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(24px, 4vw, 48px);
  align-items: center;
  padding: 0;
  border-radius: 0;
  background: none;
}
.project .solution-card:nth-child(even) .solution-copy { order: 2; }
/* A module with no screenshot is one column, not two with a hole in it. Some
   case studies have decisions whose evidence is a document or a prototype
   rather than a captured screen; the measure is held so the copy does not run
   the full width of the section. */
.project .solution-card:not(:has(.solution-media)) {
  grid-template-columns: minmax(0, 1fr);
}
.project .solution-card:not(:has(.solution-media)) .solution-copy {
  max-width: 66ch;
  order: 0;
}
.project .solution-copy > .p-kicker {
  margin: 0 0 var(--sp-2);
  font-size: var(--t-small);
}
.project .solution-copy h3 {
  margin: 0 0 var(--sp-5);
  font-size: var(--t-h2);
  line-height: 1.2;
}
.project .solution-details {
  display: grid;
  gap: var(--sp-5);
  margin: 0;
}
/* Label above answer, not beside it. One column, and the label is set at the
   size of the answer it introduces; weight alone marks it, so the pair reads
   as a single block rather than a two-column table. */
.project .solution-details div {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-2);
  align-items: start;
}
.project .solution-details dt {
  margin: 0;
  font-size: var(--t-base);
  line-height: 1.55;
}
.project .solution-details dd {
  margin: 0;
  line-height: 1.55;
}
.project .solution-media { margin: 0; }
/* The screenshot's own boundary is the frame, at the tighter product-evidence
   radius, and it carries the shadow rather than sitting on a tinted matte. The
   wrapper matches that radius or the hover zoom exposes a mismatched corner.
   No forced ratio: a product screenshot is whatever shape it was captured at,
   and a 16:9 box around it shows a matte the page never asked for. */
.project .solution-image {
  position: relative;
  overflow: hidden;
  cursor: zoom-in;
  background: transparent;
  border-radius: var(--r-s);
  box-shadow: 0 18px 40px -26px rgba(16, 22, 31, .38);
  /* The frame shrinks to the image, not to the column. A phone screen is twice
     as tall as it is wide, so at the column's full width it makes a module
     three times the height of the copy beside it. Capping the height and
     letting the frame follow keeps a portrait screenshot and a landscape one
     reading as the same kind of object. */
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
}
.project .solution-media img {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  max-height: 560px;
  aspect-ratio: auto;
  object-fit: initial;
  background: transparent;
  border-radius: var(--r-s);
  box-shadow: none;
  transition: transform var(--dur-2) var(--ease);
}
.project .solution-image:hover > img,
.project .solution-image:focus-within > img { transform: scale(1.03); }

/* The magnifier. A screenshot answers "where is this control" faster than a
   sentence does, but only if the reader is told where to look. `.lens` is a
   circle sitting over the shot, showing one part of the same image enlarged,
   so the full screen keeps its context and the detail is still readable at
   card width.

   Four numbers per instance, set inline because they are content, not style:
   `--lens-cx` / `--lens-cy` place the circle, `--lens-x` / `--lens-y` say which
   point of the image it magnifies, `--lens-z` is the enlargement, and
   `--lens-ar` is the image's own height over width. The circle is 34% of the
   frame, so the copy inside it is drawn at 100/34 = 2.941 times the frame per
   step of zoom, and the offsets put the focus point at the circle's centre. */
/* The circle is squared by arithmetic rather than by `aspect-ratio`, which the
   audit forbids anywhere near a screenshot: a percentage height resolves
   against the frame's height, so 34% of the width is 34/ar percent of it. */
.project .lens {
  --lens-size: 46;
  position: absolute;
  left: var(--lens-cx);
  top: var(--lens-cy);
  width: calc(var(--lens-size) * 1%);
  height: calc(var(--lens-size) * 1% / var(--lens-ar));
  transform: translate(-50%, -50%);
  border-radius: 50%;
  overflow: hidden;
  /* White rim, then a hairline. Without the hairline the circle dissolves
     wherever the screenshot behind it is also white, and a dissolved edge reads
     as a crop rather than as a lens sitting on top. */
  border: 2px solid var(--paper);
  box-shadow: 0 0 0 1px var(--rule-strong), 0 16px 34px -12px rgba(16, 22, 31, .5);
  pointer-events: none;
}
.project .lens img {
  position: absolute;
  width: calc(var(--lens-z) * 10000% / var(--lens-size));
  max-width: none;
  max-height: none;
  height: auto;
  left: calc(50% - var(--lens-x) * var(--lens-z) * 100 / var(--lens-size));
  top: calc(50% - var(--lens-y) * var(--lens-z) * 100 / var(--lens-size) * var(--lens-ar));
  border-radius: 0;
  transform: none;
  transition: none;
}
.project .solution-image::after {
  content: "";
  position: absolute;
  inset: 45% 0 0;
  background: linear-gradient(transparent, rgba(0, 0, 0, .42));
  opacity: 0;
  transition: opacity var(--dur-1) var(--ease);
  pointer-events: none;
}
/* Round 5 — back to the Round 3 control: a plain white pill with black type, no
   border, no shadow, and no state rules at all. The gradient that reveals it is
   what makes it readable, so the control itself never has to shout. Keyboard
   focus is not lost — the global :focus-visible ring still draws on it. */
.project .image-expand {
  position: absolute;
  right: var(--sp-3);
  bottom: var(--sp-3);
  z-index: 1;
  padding: var(--sp-3);
  border: 0;
  border-radius: var(--r-pill);
  background: var(--paper-raised);
  color: var(--ink);
  font: inherit;
  font-size: var(--t-small);
  cursor: pointer;
  opacity: 0;
  transform: translateY(var(--sp-2));
  transition: opacity var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease);
}
/* Round 6 — the control is an icon, not a label. The button keeps its
   accessible name in aria-label, so nothing is lost by removing the words, and
   a 40px circle is a better target over a screenshot than a wide pill. */
.project .image-expand,
.project .image-viewer-close {
  display: grid;
  place-items: center;
  line-height: 0;
}
.project .image-expand svg,
.project .image-viewer-close svg {
  display: block;
  width: 16px;
  height: 16px;
}

.project .solution-image:hover::after,
.project .solution-image:focus-within::after,
.project .solution-image:hover .image-expand,
.project .solution-image:focus-within .image-expand {
  opacity: 1;
  transform: none;
}

.project .image-viewer {
  width: min(94vw, 1320px);
  max-width: none;
  max-height: 94vh;
  margin: auto;
  padding: 0;
  border: 0;
  overflow: visible;
  border-radius: 0;
  background: transparent;
  color: var(--ink);
  box-shadow: 0 32px 80px -24px rgba(0, 0, 0, .55);
}
.project .image-viewer::backdrop { background: rgba(16, 22, 31, .72); }
.project .image-viewer figure {
  margin: 0;
}
.project .image-viewer img {
  display: block;
  width: 100%;
  height: auto;
  max-height: calc(94vh - 64px);
  object-fit: scale-down;
  background: transparent;
  border-radius: var(--r-m);
}
.project .image-viewer figcaption {
  margin-top: var(--sp-3);
  padding-right: var(--sp-6);
  font-size: var(--t-small);
  line-height: 1.5;
  color: #fff;
}
.project .image-viewer-close {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);
  z-index: 1;
  padding: var(--sp-3);
  border: 0;
  border-radius: var(--r-pill);
  background: var(--paper-raised);
  color: var(--ink);
  font: inherit;
  font-size: var(--t-small);
  cursor: pointer;
}
.project .solution-media figcaption {
  margin-top: var(--sp-3);
  font-size: var(--t-small);
  line-height: 1.5;
}

/* Saved Triggers treats sourced statements as one evidence component. The
   surrounding story can change, but its measure and typography do not. */
.project .research-statement {
  width: 100%;
  max-width: var(--prose);
  /* Left aligned, not centred. Centred inside the old 620px story column the
     difference was invisible; inside the one wide column of Round 141 it would
     be the only block on the page off the text edge. */
  margin: var(--sp-5) 0;
}
/* A quote that is a section in its own right carries the column's inset as a
   margin rather than as padding, so its bar lands on the page's text edge and
   the quotation hangs inside it. Every other block pads. */
@media (min-width: 900px) {
  /* width auto, not 100%: with a left margin, a 100% width would push the box
     past the column and land its text 60px beyond the page's right edge. */
  .p-body > .research-statement { margin-inline: var(--pad-card) 0; width: auto; }
  /* Round 142: it fills the column like every other block, so its right edge
     agrees with the screenshots above and below it. */
  .p-body .research-statement { max-width: none; }
}

/* The larger quote variation remains available to the other case studies. */
.bigquote {
  padding-left: clamp(20px, 3vw, 32px);
  border-left: 3px solid var(--tint-ink);
  margin: 0;
}
/* Same rule as the research statement: a quote that is a section of its own
   takes the column's inset as a margin, so its bar lands on the text edge
   instead of 25px inside it. This is the treatment the four case studies still
   to be rebuilt use, so it is aligned rather than left behind. Round 141. */
@media (min-width: 900px) {
  .p-body > .bigquote { margin-inline: var(--pad-card) 0; width: auto; }
}
.bigquote p {
  margin: 0 0 var(--sp-3);
  font-size: var(--t-h2);
  line-height: 1.4;
  color: var(--ink);
}
.bigquote cite {
  font-style: normal;
  font-size: var(--t-small);
  color: var(--ink);
}

/* Who it was for — a light editorial list, not cards. The person and stat
   rows share the tint rule that marks the page's colour. */
.people {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: var(--sp-6) var(--sp-6);
}
.person,
.stat {
  border-top: 2px solid var(--tint);
  padding-top: var(--sp-4);
}
.person p,
.stat p,
.principle p {
  margin: 0;
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink);
}
.person h3 {
  margin: 0 0 2px;
  font-size: var(--t-base);
}
.person .role {
  display: block;
  font-size: var(--t-small);
  color: var(--ink);
  margin-bottom: var(--sp-2);
}
.person p { margin-bottom: var(--sp-2); }
.person p:last-child { margin-bottom: 0; }

/* Saved Triggers elevates the audience into four research cards. The role and
   evidence provide the identity, so the cards need no decorative numbering. */
.project .profile-intro {
  max-width: 56ch;
  margin: 0 0 var(--sp-5);
}
/* One right edge as well as one left edge, so the line introducing the personas
   ends where the cards under it end. Round 142. */
@media (min-width: 900px) {
  .p-body > * > .profile-intro { max-width: none; }
}
.project .people {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-4);
}
.project .person {
  position: relative;
  min-width: 0;
  padding: var(--sp-5);
  border-top: 0;
  border-radius: var(--r-m);
  background: var(--tint);
}
.project .person h3 {
  margin: 0 0 var(--sp-1);
  font-size: var(--t-h2);
  line-height: 1.2;
}
.project .person .role { margin-bottom: 0; }
.project .profile-details {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-5);
  margin: var(--sp-6) 0 0;
}
.project .profile-details div { min-width: 0; }
/* Round 5 — one label treatment across the case study. The persona labels match
   the solution labels: body size, semi-bold, stacked above their answer. */
.project .profile-details dt {
  margin: 0;
  font-size: var(--t-base);
  line-height: 1.55;
}
.project .profile-details dd {
  margin: var(--sp-2) 0 0;
  font-size: var(--t-base);
  line-height: 1.55;
}

@media (hover: none) {
  .project .solution-image::after,
  .project .image-expand { opacity: 1; transform: none; }
}

/* Data table — evidence rows, hairline-ruled. */
.data-scroll { overflow-x: auto; }
table.data {
  width: 100%;
  min-width: 520px;
  border-collapse: collapse;
  font-size: var(--t-small);
}
table.data caption {
  text-align: left;
  font-size: var(--t-small);
  color: var(--ink);
  padding-bottom: var(--sp-3);
}
.data th, .data td {
  text-align: left;
  padding: var(--sp-3) var(--sp-4) var(--sp-3) 0;
  border-bottom: 1px solid var(--rule);
  vertical-align: top;
  line-height: 1.5;
  color: var(--ink);
}
.data th {
  color: var(--ink);
  border-bottom-color: var(--rule-strong);
}
.data td b { color: var(--ink); }
.data tr:last-child td { border-bottom-color: var(--rule-strong); }
.data .flag { color: var(--ink); }

/* User flows — bare rows of steps, the chrome stripped away. */
.flows { display: grid; gap: var(--sp-5); }
.flows > div {
  border-top: 1px solid var(--rule);
  padding-top: var(--sp-4);
}
.flow-name {
  font-size: var(--t-small);
  color: var(--ink);
  margin-bottom: var(--sp-3);
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
}
.flow-name .who { font-weight: 400; color: var(--ink); }

.flow-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
}
.flow-steps li {
  display: inline-flex;
  align-items: center;
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink);
  background: var(--paper-sunk);
  border-radius: var(--r-pill);
  padding: var(--sp-1) var(--sp-3);
}
.flow-steps li.terminal {
  background: var(--tint);
  color: var(--ink);
}
.flow-steps li.gap {
  background: var(--accent-soft);
  color: var(--ink);
}

/* Saved Triggers is the workflow-card pilot. Other case studies keep the
   compact flow rows above until this page receives final approval. */
.project .flows { gap: var(--sp-5); }
/* Round 6 — the workflow canvas is mixed from the project tint at the same
   strength as the solution section, so the two grounds on the page are one
   colour. The dot grid stays neutral ink and the connectors are already
   --tint-ink, so both carry over to any palette. */
.project .flow-chart {
  padding: var(--sp-5);
  border-radius: var(--r-l);
  background-color: color-mix(in srgb, var(--tint) 55%, #fff);
  background-image: radial-gradient(var(--rule-strong) 1px, transparent 1px);
  background-size: var(--sp-5) var(--sp-5);
}
.project .flow-name {
  justify-content: space-between;
  margin: 0 0 var(--sp-5);
  font-size: var(--t-base);
}
.project .flow-name .who { font-size: var(--t-small); }
.project .flow-steps {
  display: flex;
  flex-wrap: nowrap;
  align-items: stretch;
  gap: var(--sp-6);
  counter-reset: saved-flow-step;
}
.project .flow-steps li {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  display: block;
  padding: var(--sp-4);
  border-radius: var(--r-m);
  background: var(--paper-raised);
  box-shadow: 0 12px 24px -20px rgba(16, 22, 31, .55);
}
.project .flow-steps li::before {
  counter-increment: saved-flow-step;
  content: "Step " counter(saved-flow-step, decimal-leading-zero);
  display: block;
  margin-bottom: var(--sp-5);
  font-size: var(--t-small);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.project .flow-steps li:not(:last-child)::after {
  content: "";
  position: absolute;
  left: calc(100% + var(--sp-2));
  top: 50%;
  width: var(--sp-4);
  height: var(--sp-2);
  background: var(--tint-ink);
  clip-path: polygon(0 42%, 72% 42%, 72% 0, 100% 50%, 72% 100%, 72% 58%, 0 58%);
}
.project .flow-steps li.terminal { background: var(--tint); }

/* The numbers — serif figures over the page's tint. */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--sp-6);
}
.stat b {
  display: block;
  font-size: var(--t-title);
  line-height: 1;
  color: var(--ink);
  margin-bottom: var(--sp-2);
}

/* --------------------------------------------------------------------------
   Figures — images and grids of images
   -------------------------------------------------------------------------- */

.figure { margin: 0; }
.figure img,
.figure video {
  width: 100%;
  height: auto;
  border-radius: var(--r-m);
  background: var(--tint);
}
.figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: var(--sp-4);
}

/* --------------------------------------------------------------------------
   Video — local poster, YouTube only loaded on click
   -------------------------------------------------------------------------- */

.video-embed { margin: 0; }

/* Round 5 — the poster sits off the page ground like the case-study
   screenshots do, so the video reads as evidence rather than as a panel the
   section is built from. Round 8 carries the same shadow onto the players: the
   poster is replaced on click, and without this the frame flattened onto the
   page the moment anyone pressed play. */
.project .vid-poster,
.project .video-embed video,
.project .video-embed iframe {
  box-shadow: 0 18px 40px -26px rgba(16, 22, 31, .38);
}

.vid-poster {
  display: block;
  position: relative;
  width: 100%;
  padding: 0;
  border: 0;
  border-radius: var(--r-m);
  overflow: hidden;
  background: var(--tint);
  cursor: pointer;
  line-height: 0;
}
.vid-poster img {
  width: 100%;
  height: auto;
  transition: transform var(--dur-2) var(--ease), filter var(--dur-2) var(--ease);
}
.vid-poster:hover img { transform: scale(1.02); filter: brightness(.92); }

.vid-play {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 68px;
  height: 68px;
  border-radius: 50%;
  background: var(--tint-ink);
  box-shadow: 0 10px 30px -8px rgba(16, 22, 31, .55);
  transition: transform var(--dur-1) var(--ease);
}
.vid-play::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0; height: 0;
  border-style: solid;
  border-width: 12px 0 12px 19px;
  border-color: transparent transparent transparent #fff;
  transform: translateX(2px);
}
.vid-poster:hover .vid-play { transform: scale(1.08); }

.vid-dur {
  position: absolute;
  right: 14px;
  bottom: 14px;
  padding: var(--sp-1) var(--sp-2);
  border-radius: var(--r-pill);
  background: rgba(0, 0, 0, .74);
  color: #fff;
  font-size: var(--t-small);
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}

.video-embed iframe {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  height: auto;
  border: 0;
  border-radius: var(--r-m);
  background: #000;
}

/* Round 8 — a recording we host ourselves takes its ratio from the file's own
   width and height attributes rather than a declared 16/9, which a screen
   capture is not. Same rule the screenshots follow: the image decides, the page
   only frames it. */
.video-embed video {
  display: block;
  width: 100%;
  height: auto;
  border: 0;
  border-radius: var(--r-m);
  background: #000;
}

/* Two takes, one frame. A segmented control, not tabs: the two takes are two
   states of one player rather than two pages, and an underlined tab row implies
   the content below it changes wholesale.

   Two colours and no more: a soft blue-grey track and a white open segment. Both
   are mixed from the project tint rather than written as hex, so the control
   follows a palette change the way every other ground on this page does, and
   the sand --paper-sunk cannot strand it on an old one. The open segment is
   white because that is the page's own ground: the choice reads as the track
   opening onto the page, not as a badge painted over it. Nothing here fills with
   the accent, so every character in the control stays --ink. */
.vid-switch {
  display: inline-flex;
  /* Two takes never needed to wrap. Three demo labels do, on a phone, and
     without this they squeeze to three lines of text inside three pills. */
  flex-wrap: wrap;
  gap: var(--sp-1);
  margin-bottom: var(--sp-3);
  padding: var(--sp-1);
  border: 1px solid color-mix(in srgb, var(--tint) 70%, var(--tint-ink) 30%);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--tint) 88%, var(--tint-ink) 12%);
}
.vid-switch button {
  padding: var(--sp-1) var(--sp-4);
  border: 0;
  border-radius: var(--r-pill);
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: var(--t-small);
  line-height: 1.5;
  cursor: pointer;
  transition: background-color var(--dur-1) var(--ease);
}
.vid-switch button:hover { background: color-mix(in srgb, var(--tint) 40%, #fff); }
.vid-switch button[aria-pressed="true"],
.vid-switch button[aria-pressed="true"]:hover { background: #fff; }

/* `hidden` has to win over the display these two carry, or the take that is
   not showing renders underneath the one that is. */
.vid-poster[hidden],
.vid-chapters[hidden] { display: none; }

.vid-chapters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
}
.vid-chapter {
  display: inline-flex;
  align-items: baseline;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  background: var(--paper-raised);
  color: var(--ink);
  font: inherit;
  font-size: var(--t-small);
  line-height: 1.5;
  cursor: pointer;
  transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}
.vid-chapter:hover { border-color: var(--tint-ink); }
.vid-chapter .vid-at {
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.figure figcaption,
.video-embed figcaption,
.live-demo figcaption,
.wt-text {
  margin-top: var(--sp-3);
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--ink);
}
.video-embed figcaption a { color: var(--ink); text-decoration: none; }
.video-embed figcaption a:hover { text-decoration: underline; }
.video-embed figcaption span { color: var(--ink); }

/* --------------------------------------------------------------------------
   Plates — screenshots set into the page, not into a frame
   --------------------------------------------------------------------------
   An exhibit that is a pair of real captures wants no browser chrome and no
   inner scrollbar: both make the section read as a widget dropped into the
   case study rather than a part of it. A plate is the solution module's
   screenshot with a label above it, so the boundary, the shadow, the expand
   control and the shared viewer are all ones the page has already taught.

   Side by side, not stacked. Two of these at the full measure read as a second
   set of shipped screenshots, which is the wrong claim: an alternative that was
   explored and not chosen should carry less weight on the page than the design
   that shipped, not the same. Halving them says "supporting material" before a
   word of the caption is read, and puts the comparison in one glance rather
   than across a scroll. Legibility is not the cost it looks like: these are
   1400px captures, so the plate is a thumbnail either way, and the expand
   control opens the same full-screen viewer the solution screenshots use.
   They drop to one column when a column would be under 300px. */

.project .plates {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  align-items: start;
  gap: var(--sp-6);
}
/* Detail crops run one per row. The default two-up is right for a pair being
   compared, where the point is that they differ; a crop cut to be legible has
   nothing to gain from half the measure. */
.project .plates.is-stacked { grid-template-columns: minmax(0, 1fr); }
.project .plate { margin: 0; }
.project .plate > .p-kicker {
  margin-bottom: var(--sp-4);
}
/* The solution module centres its frame inside a half-width column. Here the
   plate has the whole measure, so a centred frame would leave the label above
   it hanging off the left edge of nothing. */
.project .plate .solution-image { margin-inline: 0; }
.project .plate figcaption { max-width: var(--prose); }

/* --------------------------------------------------------------------------
   Skill file — the working file quoted as text
   --------------------------------------------------------------------------
   Some projects leave one file behind and nothing else. It is set as marked-up
   text rather than as a screenshot of an editor: a picture of a file is
   unreadable on a phone and cannot be selected, and this way the excerpt is
   able to drop the internal names a capture would publish. One face, one small
   size, the project tint for the ground — so it reads as a document the case
   study is quoting, not as a terminal pasted into the page. */

.project .skill-file {
  max-width: var(--wide);
  margin: 0;
}
/* The column is the measure now, and 940px inside a 964px column left the file
   24px short of the figures around it. Round 142. */
@media (min-width: 900px) {
  .p-body > .skill-file,
  .p-body > * > .skill-file { max-width: none; }
}
.project .file-body {
  display: grid;
  gap: var(--sp-6);
  padding: clamp(24px, 4vw, 48px);
  border-radius: var(--r-l);
  background: color-mix(in srgb, var(--tint) 55%, #fff);
}
/* Frontmatter is the file's own header, so it keeps the label treatment the
   rest of the case study uses for a label and answer pair. */
.project .file-front {
  display: grid;
  gap: var(--sp-3);
  margin: 0;
}
.project .file-front div {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-1);
}
.project .file-front dt {
  font-size: var(--t-base);
}
.project .file-front dd {
  margin: 0;
  line-height: 1.55;
}
.project .file-block { margin: 0; }
.project .file-block h3 {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  margin: 0 0 var(--sp-3);
  font-size: var(--t-base);
}
/* The step number is the file's ordering, not a heading of its own, so it sits
   in the tinted ink at caption size and lets the words hold the line. */
.project .file-step {
  font-size: var(--t-small);
  font-variant-numeric: tabular-nums;
  opacity: .55;
}
.project .file-block p {
  max-width: var(--prose);
  margin: 0 0 var(--sp-2);
  line-height: 1.55;
}
.project .file-block p:last-child { margin-bottom: 0; }
/* One literal line out of the file. Boxed rather than set in a second face,
   because the site has one face and a border can say "this is verbatim" just
   as clearly as a monospace can. */
.project .file-code {
  display: inline-block;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-s);
  background: var(--paper);
  font-size: var(--t-small);
}
/* The steps fold away. A file worth quoting in full is long enough to push the
   rest of the case study off the screen, so only its head stays open: what the
   file is, and the one rule that governs it. The control is the recommendation
   fold again, at caption size on the panel's own ground, so the site teaches
   one disclosure rather than two. Height interpolates where
   ::details-content and interpolate-size are supported and snaps where they
   are not, which keeps the enhancement from being the thing that breaks. */
.project .file-body { interpolate-size: allow-keywords; }
.project .file-body details::details-content {
  block-size: 0;
  overflow: hidden;
  transition: block-size var(--dur-2) var(--ease),
              content-visibility var(--dur-2) allow-discrete;
}
.project .file-body details[open]::details-content { block-size: auto; }
/* The panel spaces its parts with a grid gap, which stops at the fold: inside
   it the blocks carry their own. */
.project .file-body details > .file-block { margin-top: var(--sp-6); }

/* Every fold's <details> carries the shared `fold` class (build.py emits it
   with the summary), so the treatment is written once, whatever the details
   holds — the quoted file's tail, the cut frame's grow control, the next. */
details.fold summary {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: var(--sp-2);
  list-style: none;
  cursor: pointer;
  font-size: var(--t-small);
}
details.fold summary::-webkit-details-marker { display: none; }
/* Both labels are in the markup and one is shown at a time. Swapping them with
   generated content would put the control's name in the stylesheet, where a
   screen reader is not promised to look for it. */
details.fold[open] summary > span:first-child,
details.fold summary > span + span { display: none; }
details.fold[open] summary > span + span { display: block; }
details.fold summary svg {
  flex: none;
  width: 11px;
  height: 8px;
  transition: transform var(--dur-2) var(--ease);
}
details.fold[open] summary svg { transform: rotate(180deg); }
details.fold summary:hover { opacity: .65; }
details.fold summary:active { opacity: .5; }

.project .skill-file figcaption {
  max-width: var(--prose);
  margin-top: var(--sp-3);
  font-size: var(--t-small);
  line-height: 1.5;
}

/* --------------------------------------------------------------------------
   Live demo — a real prototype in a browser-ish frame
   -------------------------------------------------------------------------- */

.live-demo { margin: 0; }
.live-demo + .live-demo { margin-top: var(--sp-7); }

/* Inside a switched deck only one figure shows, so the stacking gap that
   separates two visible frames would otherwise indent the one on screen. */
.live-demo[data-demo] + .live-demo[data-demo] { margin-top: 0; }

.demo-bar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4);
  border: 1px solid var(--rule);
  border-bottom: 0;
  border-radius: var(--r-m) var(--r-m) 0 0;
  background: var(--paper-sunk);
  font-size: var(--t-small);
}
.demo-bar i {
  flex: none;
  width: 38px;
  height: 8px;
  background-image: radial-gradient(circle, var(--ink-3) 3.6px, transparent 3.7px);
  background-size: 13px 8px;
  background-repeat: repeat-x;
  opacity: .45;
}
.demo-bar span {
  flex: 1;
  min-width: 0;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.demo-bar a {
  flex: none;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
}
.demo-bar a:hover { text-decoration: underline; }

/* A deck's bar is a browser's tab strip, so the tabs are drawn as tabs: they
   sit on its bottom edge, and the open one takes the frame's own ground and
   punches through the strip's rule, so the tab and the page it shows read as
   one surface. The traffic lights to their left are then doing exactly the job
   they were drawn for. */
.demo-bar:has(.vid-switch) {
  align-items: flex-end;
  gap: var(--sp-2);
  padding-bottom: 0;
  border-bottom: 1px solid var(--rule);
}
/* The tabs set the strip's height and reach its bottom edge. The link sits on
   the tabs' own text baseline, so it takes back the bottom padding the strip
   gave up. The lights are 8px of dots against a 21px line, so matching that
   offset would centre their top edge, not the dots: they centre in the row
   instead. */
.demo-bar:has(.vid-switch) > a { margin-bottom: var(--sp-2); }
.demo-bar:has(.vid-switch) > i { align-self: center; }
.demo-bar .vid-switch {
  flex: 1;
  min-width: 0;
  gap: var(--sp-1);
  margin-bottom: 0;
  padding: 0;
  border: 0;
  background: none;
}
.demo-bar .vid-switch button {
  margin-bottom: -1px;
  padding: var(--sp-2) var(--sp-4);
  border: 1px solid transparent;
  border-radius: var(--r-s) var(--r-s) 0 0;
}
.demo-bar .vid-switch button:hover {
  background: color-mix(in srgb, var(--demo-ground, var(--paper-raised)) 55%, transparent);
}
.demo-bar .vid-switch button[aria-pressed="true"],
.demo-bar .vid-switch button[aria-pressed="true"]:hover {
  border-color: var(--rule);
  border-bottom-color: var(--demo-ground, var(--paper-raised));
  background: var(--demo-ground, var(--paper-raised));
}

/* The strip carries the rule now, so the frame must not draw a second one
   under the open tab. */
.live-demo[data-demo] iframe {
  border-top: 0;
  border-radius: 0 0 var(--r-m) var(--r-m);
}

.live-demo iframe {
  display: block;
  width: 100%;
  height: var(--demo-h, 620px);
  border: 1px solid var(--rule);
  border-radius: 0 0 var(--r-m) var(--r-m);
  /* A prototype paints its own ground. The frame takes it so there is no white
     flash before the document arrives, and so the open tab has something to
     match. */
  background: var(--demo-ground, var(--paper-raised));
}

/* Round 128 — the frame opens cut, and the reader grows it back.

   Three frames on Diagram View ran to 3,215px, three and a half screens of
   prototype inside a page that is already long. Cut to 380px they cost 1,675px
   and the reader still reaches the whole of any of them: the disclosure under
   the frame grows it to its full height in place, which is what the fixed crop
   Round 31 rejected could not do. `data-cut` is an attribute rather than a
   class because the root-class budget is full, and because whether a frame is
   cut is a fact about that frame, not a component.

   The control is a <details> holding nothing. What it opens is the frame above
   it, and a frame inside ::details-content would not be visible when closed,
   so the element is empty and the state it carries does the work. The site
   keeps one fold pattern, and this one needs no script. */
.live-demo[data-cut] iframe {
  /* --cut-h is set inline by build.py whenever data-cut is: one owner, no
     fallback copy of the number to drift from DEFAULT_CUT. */
  height: var(--cut-h);
  /* The cut is drawn rather than merely made: the frame fades out at the
     boundary instead of stopping mid-sentence. A mask on the frame itself, not
     an overlay, so nothing has to be positioned against a bar whose height
     changes when the switch gains a second row. */
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent);
  transition: height var(--dur-2) var(--ease);
}
.live-demo[data-cut]:has(details[open]) iframe {
  height: var(--demo-h);
  -webkit-mask-image: none;
  mask-image: none;
}
.live-demo[data-cut] details { margin-top: var(--sp-4); }

@media (max-width: 720px) {
  /* The narrow frame was already 380px, with no way to see the rest of it.
     A cut frame keeps its inline --cut-h here too: the phone gains the
     control rather than losing any of the frame. */
  .live-demo iframe { height: 380px; }
}

/* --------------------------------------------------------------------------
   Prototype walkthrough
   -------------------------------------------------------------------------- */

.walkthrough { margin: 0; }

.wt-stage {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--r-m);
  overflow: hidden;
  background: var(--tint);
  box-shadow: 0 1px 2px rgba(16, 22, 31, .05),
              0 18px 40px -26px rgba(16, 22, 31, .38);
}

.wt-frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-2) var(--ease);
}
.wt-frame.is-current { opacity: 1; visibility: visible; }

.wt-bar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  margin-top: var(--sp-3);
}

.wt-btn {
  flex: none;
  width: 36px;
  height: 36px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: 50%;
  background: var(--paper-raised);
  color: var(--ink-2);
  cursor: pointer;
  transition: color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease),
              background var(--dur-1) var(--ease);
}
.wt-btn:hover { color: var(--tint-ink); border-color: var(--tint-ink); }
.wt-btn svg { width: 16px; height: 16px; }

.wt-toggle .i-pause { display: none; }
.wt-toggle.is-playing .i-play { display: none; }
.wt-toggle.is-playing .i-pause { display: block; }

.wt-ticks {
  flex: 1;
  display: flex;
  gap: var(--sp-1);
  min-width: 0;
}
.wt-tick {
  flex: 1;
  min-width: 0;
  height: 14px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  position: relative;
}
.wt-tick::before {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 6px;
  height: 2px;
  border-radius: 2px;
  background: var(--rule-strong);
  transition: background var(--dur-1) var(--ease),
              height var(--dur-1) var(--ease),
              top var(--dur-1) var(--ease);
}
.wt-tick:hover::before { background: var(--ink-3); }
.wt-tick.is-on::before {
  background: var(--tint-ink);
  height: 4px;
  top: 5px;
}

.wt-count {
  flex: none;
  font-size: var(--t-small);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.wt-count i { font-style: normal; }

.wt-cap {
  margin-top: var(--sp-3);
  min-height: 3.4em;
}
.wt-chapter {
  display: block;
  font-size: var(--t-small);
  color: var(--ink);
  margin-bottom: var(--sp-1);
}
.wt-text { display: block; margin-top: 0; }

@media (max-width: 560px) {
  .wt-ticks { display: none; }
  .wt-cap { min-height: 5em; }
}

/* --------------------------------------------------------------------------
   About, Approach, Contact
   -------------------------------------------------------------------------- */

.band {
  padding: var(--sp-band) 0;
  scroll-margin-top: 78px;
}
/* Alternating grounds are the divider now. About and Testimonials carry it, so
   the page reads white, ground, white, ground, white from the work index down.
   The work index itself is deliberately left white: its tiles each carry their
   own tint, and a tinted ground behind them muddies every one. */
.band.is-ground { background: var(--paper-cool); }

/* Awards. One award, so it is a row rather than a grid: the citation on the
   left, the winner card and the prize that came with it on the right. The whole
   block hangs off an id rather than a class, because the root-class budget is
   full at 77 and an award is one block on one page. Round 151. */
#award-nnid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(32px, 5vw, 64px);
  /* Stretch, not centre: the two columns are read against each other, so the
     winner card starts on the title's line and the prize photograph finishes on
     the card's. Centring left every one of those four edges off by itself.
     Round 154. */
  align-items: stretch;
  max-width: 980px;
  margin-inline: auto;
}
#award-nnid h3 {
  /* The card follows the heading directly now that the team line has gone, so
     the heading carries the gap the paragraph used to. Round 153. */
  margin: 0 0 var(--sp-5);
  font-size: var(--t-h2);
  line-height: 1.2;
}
#award-nnid > div > p {
  margin: 0 0 var(--sp-5);
  color: var(--ink);
  line-height: 1.55;
}
/* The citation is a recommendation card, the same object the testimonials use,
   because it is the same kind of thing: someone else's words about the work,
   lifted off the page. It carries no fold: the quotation is four lines, and a
   Read more control that reveals nothing is a control that lies. Round 152. */
#award-nnid .rec blockquote { margin-bottom: var(--sp-5); }
/* The direct child only. An id beats a class, so an unscoped `#award-nnid img`
   also caught the portrait inside the recommendation card and stretched a 48px
   avatar to the width of the column. Round 152. */
#award-nnid > figure {
  margin: 0;
  display: grid;
  gap: var(--sp-4);
  /* The images are shorter than the column, so the slack goes between them:
     first image to the top edge, last image to the bottom edge. */
  align-content: space-between;
}
#award-nnid > figure > img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--r-m);
}

@media (max-width: 820px) {
  #award-nnid { grid-template-columns: minmax(0, 1fr); }
}

.section-head {
  text-align: center;
  max-width: 54ch;
  margin: 0 auto clamp(40px, 5vw, 64px);
}
.section-head h2 {
  margin: 0 0 var(--sp-3);
  font-size: var(--t-title);
  line-height: 1.1;
}
.section-head p { margin: 0; }

/* The six process steps, as flat cards. Not the recommendation card: that one
   is raised, because a recommendation is an object lifted off the page and set
   down here from somewhere else. A process step is part of the page's own
   argument, so it gets ground rather than elevation. The card tint is the same
   --paper-cool the About and testimonials bands are set in, which puts the six
   panels in the family of the sections either side of them instead of
   introducing a colour to this band alone.

   No stroke, no shadow, no hover and no fold. There is nothing here to press.
   The card is a container for reading, and the whole method is on the screen at
   once, in order, which is what the section is for. */
.principles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(252px, 1fr));
  gap: clamp(16px, 2.2vw, 24px);
  max-width: 980px;
  margin-inline: auto;
}

/* What she works in, at the foot of How I work. Grouped rather than flat: the
   grouping is the argument, since most designers can only fill the first
   column. It sat between the hero and the case studies from Round 78 until
   Round 83, and it belongs here instead: beside the six process steps the row
   answers "what do you work in", where in front of the tiles it only delayed
   the work.

   Element selectors hung off `#approach`, not a class of its own. The
   root-class budget is at 77 of 77, and the id exists on the index and nowhere
   else, so it names this row and nothing outside it. Rename that id and every
   rule below goes quiet. */
#approach > .wrap > .p-kicker {
  margin: var(--sp-8) 0 var(--sp-6);
  font-size: var(--t-small);
  text-align: center;
}
#approach dl {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-6) var(--sp-8);
  margin: var(--sp-8) 0 0;
}
#approach dl > div {
  display: grid;
  justify-items: center;
  gap: var(--sp-4);
}
#approach dl dt { font-size: var(--t-small); }
#approach dl dd { margin: 0; }
#approach dl ul {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-5);
  margin: 0;
  padding: 0;
  list-style: none;
}
#approach dl li {
  position: relative;
  display: grid;
  justify-items: center;
  gap: var(--sp-2);
}
/* One 42px chip box for both icon rows in How I work: the tool marks here and
   the principle drawings further down. They were sized to match on purpose, so
   the box is one rule and a future resize is a one-rule change. */
#approach dl li svg,
.principle svg {
  width: 42px;
  height: 42px;
  padding: var(--sp-2);
  border-radius: var(--r-s);
}

/* Colour at rest, Round 80. The hero above is white, cool grey and near-black
   by design, and fourteen brand marks are the one place on the opening screen
   where colour can arrive without being taken from the tiles. --brand is
   inline on each <li>, the way --tint is inline on a tile; Notion, Cursor and
   Terminal have no brand colour to reach and fall back to the page ink. The
   padding is always there, so the hover chip appears without moving anything. */
#approach dl li svg {
  color: var(--brand, var(--ink));
  background: color-mix(in srgb, var(--brand, var(--ink)) 12%, #fff);
  transition: background-color var(--dur-1) var(--ease);
}

/* The chip is there at rest, Round 82: the mark's own colour mixed a tenth of
   the way into white, so each tool sits on a wash of itself rather than on the
   band. The hover deepens the same mix rather than introducing a second idea. */
@keyframes tool-hop {
  0%   { translate: 0 0; }
  35%  { translate: 0 -6px; }
  62%  { translate: 0 0; }
  80%  { translate: 0 -2px; }
  100% { translate: 0 0; }
}
#approach dl li span { font-size: var(--t-small); }

/* Where there is a real pointer the name waits to be asked for, so the row
   reads as a set of marks at a glance rather than as a second list of words.
   Opacity, not display, so nothing reflows when a name arrives. Everywhere
   else the name is simply there: a tooltip nobody can summon is a label that
   does not exist, and that is most of the traffic. */
@media (hover: hover) and (pointer: fine) {
  #approach dl { padding-bottom: var(--sp-6); }
  #approach dl li span {
    position: absolute;
    top: 100%;
    left: 50%;
    translate: -50% var(--sp-2);
    white-space: nowrap;
    opacity: 0;
    transition: opacity var(--dur-1) var(--ease);
  }
  #approach dl li:hover span { opacity: 1; }

  /* The mark is already in its colour and already on its chip, so the hover has
     the name, the hop, and the same mix taken from a tenth to a quarter. One
     step darker reads as a press without a new colour arriving. */
  #approach dl li:hover svg {
    background: color-mix(in srgb, var(--brand, var(--ink)) 24%, #fff);
    animation: tool-hop var(--dur-2) var(--ease);
  }
}
/* align-content is start, not the grid default. The cards stretch to level
   their row, and a stretched grid hands the slack to its own rows: the shortest
   card pushed its heading a visible distance further from its chip than the
   tallest one did, so six cards that should share a rhythm did not. The slack
   belongs at the foot of the card, where nothing is reading it. */
.principle {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--sp-3);
  align-items: center;
  align-content: start;
  padding: clamp(18px, 2vw, 24px);
  background: var(--paper-cool);
  border-radius: var(--r-l);
}

/* The icon chip. Each card carries its own --tint and --tint-ink inline, the
   same pair of custom properties build.py writes onto an <article class="project">,
   and drawn from the same set: the four palettes of the four case studies above,
   plus Drunk Hank's teal and Motion's sand. Nothing new was mixed, and the six
   are ordered so that no two neighbours in the 3 + 3 grid sit in the same part
   of the wheel, which is the rule the index tiles are already held to.

   The <svg> is the chip. Giving it the fill, the corner and the padding itself
   saves a wrapper element, and a wrapper would have cost a root class the
   stylesheet does not have. `stroke`, not `color`: it is the property that
   actually paints this drawing, and it cannot land on a character, so the
   one-ink rule has nothing to say about it. The markup still carries
   stroke="currentColor", which is what draws if the stylesheet never arrives. */
.principle svg {
  /* The box itself (size, padding, corner) is the shared chip rule grouped
     with the tools row above. */
  background: var(--tint);
  stroke: var(--tint-ink);
}

/* The heading sits beside the chip rather than under it. The ordinal went with
   the change: once the icon and the title share a line, a number on that line
   is a third thing competing for it, and the order the steps run in is already
   carried by the reading order of the grid. Losing it lets the card come down a
   size, chip and padding together, which is what a row of six wanted. */
.principle h3 {
  margin: 0;
  font-size: var(--t-base);
}
.principle p {
  grid-column: 1 / -1;
  margin-top: var(--sp-4);
}

.about-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: clamp(32px, 5vw, 68px);
  max-width: 980px;
  margin-inline: auto;
  align-items: start;
}

.about-grid > div p {
  margin: 0 0 var(--sp-4);
  color: var(--ink);
}
.about-grid > div p:last-child { margin-bottom: 0; }

.about-portrait {
  display: block;
  width: 100%;
  max-width: 300px;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: var(--r-m);
  margin: 0;
  background: var(--rule);
}
@media (max-width: 820px) {
  .about-portrait { max-width: 200px; }
}

.about-grid dl {
  margin: 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0 var(--sp-4);
  align-items: baseline;
  font-size: var(--t-small);
}
.about-grid dt {
  color: var(--ink);
  padding-top: var(--sp-3);
  white-space: nowrap;
  border-top: 1px solid var(--rule);
}
.about-grid dd {
  margin: 0;
  padding-top: var(--sp-3);
  color: var(--ink);
  line-height: 1.5;
  border-top: 1px solid var(--rule);
}
.about-grid dt:first-of-type,
.about-grid dd:first-of-type { border-top: 0; padding-top: 0; }

@media (max-width: 820px) {
  .about-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 480px) {
  .about-grid dl { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .about-grid dt { padding-top: var(--sp-4); }
  .about-grid dd { border-top: 0; padding-top: 2px; }
}

.contact {
  padding-bottom: clamp(64px, 8vw, 96px);
  text-align: center;
}
.contact h1,
.contact h2 {
  margin: 0 0 var(--sp-4);
  font-size: var(--t-hero);
  line-height: 1.04;
  letter-spacing: -0.01em;
}
.contact p {
  margin: 0 auto var(--sp-6);
  max-width: 46ch;
}

/* The same close as the index, one step quieter. On a case study it follows a
   section that already carries a heading, so a hero-sized line here would read
   as the page starting again rather than ending. Only a project page puts a
   contact block directly after </main>, which is what scopes this. */
main + .contact h2 { font-size: var(--t-title); }

.social {
  display: flex;
  justify-content: center;
  gap: var(--sp-3);
  list-style: none;
  margin: 0;
  padding: 0;
}
.social a {
  display: grid;
  place-items: center;
  width: 44px; height: 44px;
  border: 1px solid var(--rule);
  border-radius: 50%;
  color: var(--ink-2);
  background: var(--paper-raised);
  transition: color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease);
}
.social a:hover {
  color: var(--ink);
  border-color: var(--ink);
  transform: translateY(-2px);
}
.social svg { width: 18px; height: 18px; }

/* --------------------------------------------------------------------------
   Prev / next pager
   -------------------------------------------------------------------------- */

.pager {
  border-top: 1px solid var(--rule);
  padding: var(--sp-7) 0;
}
.pager .wrap {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--sp-5);
}
.pager a {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  max-width: 46%;
  text-decoration: none;
  color: var(--ink);
}
.pager a.next { text-align: right; align-items: flex-end; }
.pager small { color: var(--ink); }
.pager b {
  font-size: var(--t-h2);
  line-height: 1.15;
  transition: color var(--dur-1) var(--ease);
}
.pager a:hover b { text-decoration: underline; text-underline-offset: 4px; }

@media (max-width: 560px) {
  .pager .wrap { flex-direction: column; gap: var(--sp-5); }
  .pager a, .pager a.next {
    max-width: none;
    text-align: left;
    align-items: flex-start;
  }
}

.colophon {
  border-top: 1px solid var(--rule);
  padding: var(--sp-5) 0 var(--sp-6);
  font-size: var(--t-small);
  color: var(--ink);
}
.colophon .wrap {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-5);
  justify-content: space-between;
  align-items: center;
}
.colophon a {
  color: var(--ink);
  text-decoration: none;
  transition: color var(--dur-1) var(--ease);
}
.colophon a:hover { opacity: .65; }

/* --------------------------------------------------------------------------
   Pressed states — every interactive element answers the click itself.
   -------------------------------------------------------------------------- */

.masthead nav a:active,
/* The CV pill is named again rather than left to `.masthead nav a:active`
   above: its hover rule carries the [data-cv] attribute too, so the shorter
   selector would lose to it and a press under the cursor would do nothing. */
.masthead nav a[data-cv]:active,
.hero-cta:active,
.inline-link:active,
.vid-switch button:active,
.pager a:active,
.colophon a:active,
.demo-bar a:active,
.video-embed figcaption a:active { opacity: .7; }

.tile:active .tile-art {
  transform: translateY(-2px);
  transition-duration: var(--dur-1);
}
.vid-poster:active .vid-play { transform: scale(.94); }
.vid-chapter:active,
.wt-btn:active { transform: scale(.96); }
.social a:active { transform: translateY(-1px) scale(.95); }

/* --------------------------------------------------------------------------
   Motion
   -------------------------------------------------------------------------- */

@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* The hero illustrations and their motion are both gone (Round 114).

   They used to share one gesture: a signal leaving a source, travelling a wire
   and being acknowledged. It was built for a drawing that held half the hero
   panel, and a case study no longer shows a drawing there at all, so there is
   nothing left for it to animate.

   The seven files are still in site/art, still carry their `.art-*` class
   names, and still serve the index tiles of the projects that use them. Tiles
   never animated, deliberately, so nothing on the site is waiting for these
   rules. The travelling signals inside each file are written with
   `opacity="0"` on the element, so they draw nothing without an animation to
   raise them and the static wire beneath each one is what a tile shows.
   Bringing the gesture back would mean putting these rules back, not editing
   seven SVGs. */

.hero > .wrap > * {
  animation: rise var(--dur-2) var(--ease) both;
}
.hero > .wrap > *:nth-child(1) { animation-delay: .04s; }
.hero > .wrap > *:nth-child(2) { animation-delay: .13s; }
.hero > .wrap > *:nth-child(3) { animation-delay: .20s; }
.hero > .wrap > *:nth-child(4) { animation-delay: .27s; }
.hero > .wrap > *:nth-child(5) { animation-delay: .34s; }

/* Scoped to .js (set by an inline head script, and withdrawn again if
   portfolio.js never arrives) so the page is never blank without JS. */
.js .reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--dur-2) var(--ease),
              transform var(--dur-2) var(--ease);
}
.js .reveal.is-in { opacity: 1; transform: none; }
.js .index-grid .tile.reveal:nth-child(2n) { transition-delay: .08s; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  .js .reveal { opacity: 1; transform: none; }
}

/* --------------------------------------------------------------------------
   Responsive
   -------------------------------------------------------------------------- */

@media (max-width: 900px) {
  .masthead nav { gap: var(--sp-4); }
}

@media (max-width: 860px) {
  .p-hero .panel { grid-template-columns: minmax(0, 1fr); }
  .p-hero h1 { max-width: none; }
}

@media (max-width: 720px) {
  :root { --gutter: 20px; }

  /* Bigger marks (Round 81) need a tighter row here, or five of them wrap. */
  #approach dl ul { gap: var(--sp-3); }
  #approach dl { gap: var(--sp-5) var(--sp-6); }

  .index-grid { grid-template-columns: minmax(0, 1fr); gap: var(--sp-6); }

  .project .solution-card { grid-template-columns: minmax(0, 1fr); }
  /* the scoped ratio outranks the line above, so it has to collapse here too or
     the pilot keeps two columns on a phone */
  .project .solution-card { grid-template-columns: minmax(0, 1fr); }
  .project .solution-card:nth-child(even) .solution-copy { order: 0; }

  .project .people { grid-template-columns: minmax(0, 1fr); }
  .project .profile-details,
  .project .solution-details div { grid-template-columns: minmax(0, 1fr); }
  .project .solution-image::after,
  .project .image-expand { opacity: 1; transform: none; }
  .project .flow-chart { padding: var(--sp-4); }
  .project .flow-name { display: grid; gap: var(--sp-1); }
  .project .flow-steps { flex-direction: column; gap: var(--sp-6); }
  .project .flow-steps li { width: 100%; }
  .project .flow-steps li:not(:last-child)::after {
    left: 50%;
    top: calc(100% + var(--sp-2));
    transform: translateX(-50%) rotate(90deg);
  }

  .masthead nav a[data-optional] { display: none; }
}

@media (max-width: 560px) {
  .flow-steps { flex-direction: column; align-items: flex-start; }
}

@media print {
  .masthead, .social, .reveal { position: static; }
  .reveal { opacity: 1; transform: none; }
  .project { break-inside: avoid; }

  /* A folded card prints as a heading with nothing under it, and on paper there
     is no control to open it with. Both folds on the site, the recommendations
     and the quoted file, come out flat, and the control that opened them goes
     because it no longer opens anything. */
  details::details-content { block-size: auto; content-visibility: visible; }
  .principle, .rec { break-inside: avoid; }
  details.fold summary,
  .rec summary > span { display: none; }
  /* Same reasoning for the cut frame: on paper there is nothing to press, so
     it prints at its full height with no fade at the boundary. */
  .live-demo[data-cut] iframe {
    height: var(--demo-h);
    -webkit-mask-image: none;
    mask-image: none;
  }
}


/* ---------------------------------------------------------------------------
   The round trip. A looping recreation of one UI fix crossing four tools, and
   then the same fix made in the repository. Everything is scoped under
   `.project` because the root-class budget is full, and every string lives in
   the markup rather than in the script so it can be read and checked. Text
   inside it is set in spans and divs, not <p>, so the page's word budget still
   measures prose rather than interface copy.
   -------------------------------------------------------------------------- */
/* The round trip sits inside its own panel. Five things move in that section
   and the page around it is white, so without a ground the animation reads as
   the page itself misbehaving. A pale band holds it, and the cards inside turn
   their headers white so they still read as objects sitting on something. */
.project .is-panel {
  background: var(--paper-cool);
  border-radius: var(--r-m);
  /* The hero panel's inset, like every other panel in the body, so the copy
     inside a panelled section starts on the page's one text edge. Round 141. */
  padding: var(--pad-card);
}
.project .is-panel .trip-card .head { background: var(--paper); }
.project .is-panel .hop .lag { background: var(--paper-cool); }

.project .rail {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex-wrap: wrap;
  margin: 0 0 var(--sp-4);
}
.project .rail .tool {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  border: 1px solid var(--rule);
  border-radius: var(--r-pill);
  padding: 4px 12px;
  font-size: var(--t-small);
  background: var(--paper);
  opacity: .45;
  transition: opacity var(--dur-2) var(--ease), border-color var(--dur-2) var(--ease);
}
.project .rail .tool.is-live { opacity: 1; border-color: var(--rule-strong); }
.project .rail .tool svg { width: 16px; height: 16px; display: block; }
.project .rail .sep { opacity: .35; }
.project .rail .tally { margin-left: auto; font-size: var(--t-small); opacity: .7; }

.project .trip-card {
  background: var(--paper);
  border-radius: var(--r-s);
  box-shadow: 0 18px 40px -30px rgba(16, 22, 31, .5), 0 0 0 1px var(--rule);
  overflow: hidden;
  opacity: .42;
  transition: opacity var(--dur-2) var(--ease), box-shadow var(--dur-2) var(--ease);
}
.project .trip-card.is-live { opacity: 1; box-shadow: 0 22px 46px -26px rgba(16, 22, 31, .5), 0 0 0 1px var(--rule-strong); }
.project .trip-card .head {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--rule);
  background: var(--paper-cool);
  font-size: var(--t-small);
}
.project .trip-card .head svg { width: 14px; height: 14px; display: block; }
.project .trip-card .inner { padding: var(--sp-3); font-size: var(--t-small); }

.project .trip-queue .inner { padding: 0; }
.project .qwrap { height: 132px; overflow: hidden; position: relative; }
.project .qroll { transition: transform var(--dur-3) var(--ease); }
.project .qrow {
  display: grid;
  grid-template-columns: 96px 1fr 104px;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4);
  border-bottom: 1px solid var(--rule);
  align-items: center;
  transition: background-color var(--dur-2) var(--ease);
}
.project .qrow .grp { opacity: .65; font-size: .9em; }
.project .qrow.is-hit { background: var(--tint); }
.project .tally-pill {
  position: absolute;
  right: var(--sp-3);
  bottom: var(--sp-2);
  background: var(--ink);
  color: #fff;
  font-size: var(--t-small);
  padding: 4px 12px;
  border-radius: var(--r-pill);
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}
.project .tally-pill.is-on { opacity: 1; }

.project .drop { position: relative; height: 32px; width: 2px; margin: 0 0 0 12%; }
.project .drop::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(180deg, var(--rule-strong) 0 6px, transparent 6px 12px);
  background-size: 2px 12px;
}
.project .drop .bead,
.project .hop .bead {
  position: absolute;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--tint-ink);
  opacity: 0;
}
.project .drop .bead { left: -3px; top: 0; }
.project .drop.is-run .bead { animation: bead-fall var(--dur-2) var(--ease) forwards; }
/* translateY, not top: the bead composites instead of running layout on every
   frame. The static top/left above stay the origin it moves from. */
@keyframes bead-fall { from { transform: translateY(0); opacity: 1; } to { transform: translateY(24px); opacity: 1; } }

.project .relay { display: grid; grid-template-columns: 1fr 76px 1fr 76px 1fr; align-items: stretch; }
.project .relay .inner { min-height: 200px; }
.project .hop { position: relative; height: 2px; align-self: center; }
.project .hop::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(90deg, var(--rule-strong) 0 6px, transparent 6px 12px);
  background-size: 12px 2px;
}
.project .hop .bead { top: -3px; left: 0; }
.project .hop.is-run .bead { animation: bead-run var(--dur-2) var(--ease) forwards; }
@keyframes bead-run { from { transform: translateX(0); opacity: 1; } to { transform: translateX(64px); opacity: 1; } }
.project .hop .lag {
  position: absolute;
  top: -28px;
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--t-small);
  white-space: nowrap;
  background: var(--paper);
  padding: 0 8px;
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}
.project .hop.is-run .lag, .project .hop.is-done .lag { opacity: .75; }

.project .trip-field {
  border: 1px solid var(--rule-strong);
  border-left: 3px solid var(--tint-ink);
  border-radius: 8px;
  background: var(--tint);
  padding: var(--sp-2);
  min-height: 44px;
}
.project .trip-post {
  float: right;
  margin: var(--sp-2) 0 0;
  font-size: .88em;
  border: 1px solid var(--rule-strong);
  border-radius: 8px;
  padding: 2px 12px;
  transition: background-color var(--dur-1) var(--ease);
}
.project .trip-post.is-press { background: var(--tint); }
.project .trip-entry {
  display: grid;
  grid-template-columns: 20px 1fr;
  gap: var(--sp-2);
  padding: var(--sp-2) 0 0;
  border-top: 1px solid var(--rule);
  margin-top: var(--sp-2);
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}
.project .trip-entry.is-on { opacity: 1; }
.project .trip-entry.is-bare { border-top: none; padding-top: 0; }
.project .trip-av {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--tint);
  display: grid;
  place-items: center;
  font-size: .78em;
}
.project .trip-av.is-ai { background: var(--ink); color: #fff; }
.project .trip-who { font-size: .82em; opacity: .7; }
.project .trip-bub { background: var(--paper-cool); border-radius: 10px; padding: var(--sp-2); }
.project .trip-dots { display: inline-flex; gap: 4px; padding: var(--sp-2); background: var(--paper-cool); border-radius: 10px; }
.project .trip-dots i { width: 4px; height: 4px; border-radius: 50%; background: var(--ink); opacity: .4; display: block; }

.project .trip-canvas {
  position: relative;
  height: 172px;
  overflow: hidden;
  border-radius: 8px;
  background:
    linear-gradient(var(--paper-cool) 1px, transparent 1px) 0 0 / 100% 22px,
    linear-gradient(90deg, var(--paper-cool) 1px, transparent 1px) 0 0 / 22px 100%,
    var(--paper);
}
.project .trip-mock { position: absolute; background: var(--paper-cool); border-radius: 4px; }
.project .trip-pin {
  position: absolute;
  width: 20px;
  height: 20px;
  border-radius: 50% 50% 50% 2px;
  background: var(--tint-ink);
  color: #fff;
  display: grid;
  place-items: center;
  font-size: .78em;
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}
.project .trip-pin.is-on { opacity: 1; }
.project .trip-bubble {
  position: absolute;
  right: 4px;
  top: 8px;
  width: 142px;
  background: var(--paper);
  border-radius: 8px;
  box-shadow: 0 12px 26px -16px rgba(16, 22, 31, .5), 0 0 0 1px var(--rule);
  padding: var(--sp-2);
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}
.project .trip-bubble.is-on { opacity: 1; }
.project .trip-composer {
  margin-top: var(--sp-2);
  border: 1px solid var(--rule-strong);
  border-radius: 8px;
  padding: var(--sp-2);
}

.project .trip-after { margin-top: var(--sp-6); }
.project .trip-after .inner { display: grid; grid-template-columns: 1.5fr 1fr; gap: var(--sp-5); align-items: start; }
.project .trip-prompt { border: 1px solid var(--rule-strong); border-radius: 8px; padding: var(--sp-2); }
.project .trip-diff {
  display: block;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .88em;
  background: var(--ink);
  color: #fff;
  border-radius: 8px;
  padding: var(--sp-2);
  margin-top: var(--sp-2);
}
.project .trip-diff .was { opacity: .6; }
.project .trip-pips { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
.project .trip-pip {
  font-size: var(--t-small);
  border-radius: var(--r-pill);
  padding: 4px 12px;
  border: 1px solid var(--rule);
  opacity: .5;
  transition: opacity var(--dur-2) var(--ease), background-color var(--dur-2) var(--ease);
}
.project .trip-pip.is-on { opacity: 1; background: var(--tint); border-color: transparent; }
.project .trip-say { margin-top: var(--sp-4); font-size: var(--t-small); opacity: .75; }
.project .trip-band { font-size: var(--t-small); opacity: .7; margin: 0 0 var(--sp-3); }

@media (max-width: 880px) {
  .project .relay { grid-template-columns: 1fr; gap: var(--sp-4); }
  .project .hop { height: 32px; width: 2px; justify-self: center; }
  .project .hop::before { background-image: linear-gradient(180deg, var(--rule-strong) 0 6px, transparent 6px 12px); background-size: 2px 12px; }
  .project .hop .bead { display: none; }
  .project .hop .lag { top: 50%; transform: translate(-50%, -50%); }
  .project .trip-after .inner { grid-template-columns: 1fr; }
  .project .trip-pips { flex-direction: row; flex-wrap: wrap; }
  /* Narrow: the story number and its one-line description carry the backlog.
     The assignment group is the first thing to go, and the rows keep a single
     line each so the list still reads as a list rather than as wrapped prose. */
  .project .qrow { grid-template-columns: 84px 1fr; }
  .project .qrow .grp { display: none; }
  .project .tally-pill { position: static; display: block; margin: var(--sp-2) var(--sp-4); width: fit-content; }
  .project .qwrap { height: 116px; }
}
@media (prefers-reduced-motion: reduce) {
  .project .qroll, .project .drop.is-run .bead, .project .hop.is-run .bead { animation: none; transition: none; }
}
