:root {
  --paper: #ffffff;
  /* Reading text is black on paper, at every level. The greys these three
     used to hold (#424242, and #8d8c83 for the faintest) were doing the work
     of a hierarchy, but they were doing it by taking contrast away — so the
     hierarchy is carried by size and weight now, and nothing on paper is set
     in a colour that has to be looked for.

     The dark pages — About, Playground, Writing — redefine these locally
     against their own ground, so black here never reaches them. */
  --ink: #000000;
  --ink-soft: #000000;
  --ink-faint: #000000;

  /* One inset for every page. Everything that sets a page edge, a sticky
     offset or a scroll target reads this rather than its own number. It grows
     with the viewport so the margin stays proportional rather than looking
     pinched on a laptop and lost on a large display. */
  --pad: 30px;

  /* One reading size for the whole site. It was the case studies' own — the
     share of a 1512px artboard the file gives running copy — and every other
     page now reads the same token, so the home page, About, Playground and
     the menus are set at the size a case study sets its body in rather than
     each carrying its own 12px. Below 900px it stops scaling and takes a
     floor instead; see the media query under this block. */
  --fs-body: clamp(12px, 0.794vw, 16px);

  /* How much paper sits between the flowers and the copy. 0 lets the field
     through untouched; 1 hides it behind the column entirely. */
  --scrim: 0.72;

  /* Selection, taken from the field's own pink (240, 20, 140). A translucent
     wash so whatever is behind the text still reads through it, and the same
     hue darkened for the type — one colour, two values, rather than a tint
     against unrelated black. */
  --select-bg: rgba(240, 20, 140, 0.18);
  --select-ink: #840b4d;

  /* The lime the Hack the North field is built from, taken down until it holds
     its own as type on paper, and again for the far end of a hover. */
  --accent-green: #5f7d13;
  --accent-green-deep: #3b480e;
  /* The panel the live sites sit in, and the type that labels it. */
  --accent-green-wash: rgb(221, 250, 185);
  --accent-green-label: rgb(65, 115, 4);

  /* The panel a case study puts its one outward link in, named for the job
     rather than the colour so a work built from something other than the lime
     can restate it — see [data-accent] below. The lime is the default because
     it is what the panel was built for. */
  --box-wash: var(--accent-green-wash);
  --box-label: var(--accent-green-label);
  --box-link: var(--accent-green);
  --box-link-deep: var(--accent-green-deep);

  /* What the page — and the canvas, which paints its own ground — sits on. */
  --ground: var(--paper);
  --field-ground: #ffffff;
}

/* The type stops scaling with the window and takes a floor instead. The
   artboard's 12px was set for a column a third of a 1512px window wide, read
   at desk distance; the same copy on a handset is the whole screen wide and a
   foot from the face. This is the case studies' own floor, now the site's. */
@media (max-width: 900px) {
  :root { --fs-body: 13.5px; }
}

html[data-theme="dark"] {
  --ground: #171717;
  --field-ground: #171717;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

a { color: inherit; text-decoration: none; }

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

/* A work can be selected in its own colour. The rule above already reads these
   two tokens, so an accent only has to set them: anything carrying the
   attribute — the work's row on the home page, or the whole article on its own
   page — hands them down to the selection inside it.

   Each is built the way the default pink is: one hue at two values, a
   translucent wash so what is behind the text still reads through, and the
   same hue darkened for the type. The paler the hue, the more alpha it needs
   before it registers on paper. */
[data-accent="green"] {
  --select-bg: rgba(140, 171, 33, 0.3);
  --select-ink: #3b480e;
}

[data-accent="cyan"] {
  --select-bg: rgba(55, 190, 228, 0.26);
  --select-ink: #0b4d63;
}

[data-accent="coral"] {
  --select-bg: rgba(242, 112, 74, 0.24);
  --select-ink: #7a2c10;
}

[data-accent="yellow"] {
  --select-bg: rgba(245, 230, 58, 0.4);
  --select-ink: #5c5406;
}

/* A deeper blue than cyan, for a banner whose sky reaches that far. The wash
   is the paper the BuBeGooMoo manual was photographed on, so the panel at the
   foot of that page is the same colour as the picture above it. */
[data-accent="sky"] {
  --select-bg: rgba(31, 122, 178, 0.24);
  --select-ink: #10466b;
  --box-wash: #dce1f7;
  --box-label: #4f5776;
  --box-link: #3a4573;
  --box-link-deep: #1d2547;
}

html, body {
  min-height: 100%;
  background: var(--ground);
}

body {
  font-family: "General Sans", Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;

  /* The face is variable, so every weight between 200 and 700 is a real
     drawing. Synthetic bolding would smear a fake one over the top of it and
     break the ramp halfway through. */
  font-synthesis-weight: none;
}

/* How far ink thickens when something is touched. One number for the whole
   site, so the gesture reads as one material rather than per-element taste.
   Kept small on running copy, where a wider glyph pushes the rest of the
   line: see .col a. */
:root {
  --ink-gain: 100;
  --ink-settle: 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}

#garden {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
  pointer-events: none;
}

.page {
  position: relative;
  z-index: 1;
  min-height: 100svh;
  padding: var(--pad);
  pointer-events: none;
}

.col {
  position: relative;
  width: min(270px, 38vw);
  /* The old 18rem floor sat above the column width, so it would have clamped
     it straight back up. Nothing needs a floor here: below 900px the column
     is released to fill the page anyway. */
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
}

/* A wash of paper behind the copy so the type survives a dense field. It is
   blurred rather than clipped: a solid rectangle reads as a panel sitting on
   the page, while a blurred one has no edge to find and just looks like the
   flowers thinning out. Static, so the blur is composited once and not
   recomputed as the field animates. */
.col::before {
  content: "";
  position: absolute;
  inset: -1.75rem -2.25rem;
  z-index: -1;
  border-radius: 2.5rem;
  background: var(--paper);
  opacity: var(--scrim);
  filter: blur(22px);
  pointer-events: none;
}

.mark {
  display: block;
  color: var(--ink);
  pointer-events: auto;
}

.name {
  font-size: 1.375rem;
  font-weight: 500;
  letter-spacing: -0.014em;
}

.col p {
  font-size: var(--fs-body);
  line-height: 1.45;
  color: var(--ink-soft);
  text-wrap: pretty;
}

.col > p + p { margin-top: 0.5rem; }

.col a {
  pointer-events: auto;
  font-weight: 400;
  transition: font-weight var(--ink-settle);
}

/* Fading a link on hover makes it retreat, which is the one thing nothing in
   the field ever does — a bloom only ever gains density. So the link inks in
   instead. The gain is deliberately half the site's: these sit mid-sentence,
   and a wider word pushes the rest of the line. At 60 units over a name this
   short the advance moves well under a pixel, so the line cannot re-wrap. */
.col a:hover { font-weight: 460; }

/* List rows already answer with an indent and a rule; fading them as well is
   one signal more than the row needs. */
.panel li a:hover { opacity: 1; }

/* ── collapsible groups ─────────────────────────────────────────────────── */

.group {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.group h2 { font-size: inherit; font-weight: inherit; }

.toggle {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.16rem 0;
  background: none;
  border: 0;
  font: inherit;
  font-size: var(--fs-body);
  font-weight: 500;
  color: var(--ink);
  cursor: pointer;
  pointer-events: auto;
  text-align: left;
  transition: font-weight var(--ink-settle);
}

/* space-between pins the sign to the right edge of a full-width row, so the
   label thickens into the gap between them. */
.toggle:hover { font-weight: calc(500 + var(--ink-gain)); }

.toggle:focus-visible {
  outline: 1.5px solid var(--ink);
  outline-offset: 3px;
}

.pm {
  width: 0.625rem;
  height: 0.625rem;
  flex: none;
  stroke: var(--ink-soft);
  stroke-width: 1.4;
  stroke-linecap: round;
  transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1),
              stroke-width 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* A plus that turns through 45° into a close mark.
   Rotation keeps the arm tips the same distance from centre, so the ink's
   width and height shrink by √2 and the ✕ renders visibly smaller than the ＋.
   scale(√2) puts that back; stroke-width divided by √2 stops the same scale
   from thickening the strokes along with it. */
.toggle[aria-expanded="true"] .pm {
  transform: rotate(45deg) scale(1.41421);
  stroke-width: 0.98995;
}

/* One wrapper row collapsing 1fr -> 0fr; the list inside must be able to
   shrink, so it carries min-height:0 and overflow:hidden. */
.panel {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 0.3s cubic-bezier(0.22, 0.61, 0.36, 1),
              opacity 0.24s ease;
}

.panel > ul {
  list-style: none;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.panel li {
  font-size: var(--fs-body);
  line-height: 1.5;
  color: var(--ink-soft);
}

.panel[hidden] {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  pointer-events: none;
}



/* ── controls ───────────────────────────────────────────────────────────── */

/* Fixed to the bottom-left of the viewport: out of the reading column, on
   the paper it is actually describing, and on the opposite diagonal from
   where the field opens so it stays legible longest. */
.controls {
  position: fixed;
  left: var(--pad);
  bottom: var(--pad);
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.6875rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink);
}

.reset {
  display: grid;
  place-items: center;
  width: 0.875rem;
  height: 0.875rem;
  flex: none;
  padding: 0;
  background: none;
  border: 0;
  color: var(--ink);
  cursor: pointer;
  pointer-events: auto;
  transition: opacity 0.2s ease;
}

.reset-icon { display: block; width: 100%; height: 100%; }

.reset:hover { opacity: 0.75; }

/* The turn belongs to the action, so it is driven by a class the click adds
   rather than by :hover. */
.reset-icon.spinning { animation: spin 0.65s cubic-bezier(0.22, 0.61, 0.36, 1); }

@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(-360deg); }
}

/* The palette button is its own swatch: three dots in the colours the field
   is currently growing in, sitting at the reset icon's weight so the two read
   as one small toolbar rather than as a button and a decoration. */
.palette {
  display: flex;
  align-items: center;
  gap: 2px;
  height: 0.875rem;
  flex: none;
  padding: 0 1px;
  background: none;
  border: 0;
  cursor: pointer;
  pointer-events: auto;
  transition: opacity 0.2s ease;
}

.palette-dot {
  display: block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  /* The colours are set inline by flowers.js; this only matters for the
     instant before it runs, and if it never does. */
  background: currentColor;
  /* Where the dot sits when nothing is happening to it. Hover moves the outer
     two apart by setting this, and the bounce reads it, so the two can share
     one transform without either one snapping the other back. */
  --rest: 0px;
  transform: translateY(var(--rest));
  transition: background 0.35s ease, transform 0.2s ease;
}

.palette:hover { opacity: 0.75; }
.palette:hover .palette-dot:nth-child(1) { --rest: -1px; }
.palette:hover .palette-dot:nth-child(3) { --rest: 1px; }

/* The click belongs to the dots: all three hop, one after the other, so the
   palette change arrives as a little wave across the swatch rather than as a
   colour that simply became a different colour. Driven by a class ui.js adds,
   for the same reason the reset icon's turn is. */
.palette-dot.bouncing {
  animation: dot-bounce 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.palette-dot.bouncing:nth-child(2) { animation-delay: 0.05s; }
.palette-dot.bouncing:nth-child(3) { animation-delay: 0.1s; }

/* Up, past the rest position on the way down, and a small second hop to
   settle — the overshoot is what makes it read as a bounce and not a lift. */
@keyframes dot-bounce {
  0%   { transform: translateY(var(--rest)) scale(1); }
  28%  { transform: translateY(calc(var(--rest) - 4px)) scale(1.2); }
  55%  { transform: translateY(var(--rest)) scale(0.88); }
  75%  { transform: translateY(calc(var(--rest) - 1.5px)) scale(1.06); }
  100% { transform: translateY(var(--rest)) scale(1); }
}

.palette:focus-visible { outline: 1.5px solid var(--ink); outline-offset: 3px; }

/* ── clock ──────────────────────────────────────────────────────────────── */

/* The controls sit bottom-left; this is their counterweight on the opposite
   corner, at the same size and colour. Not uppercased like them, though: the
   tracking that opens up a line of caps is what makes a running clock read as
   a label rather than as a reading, and the time is the point of this one. No
   pointer-events either — the corner it occupies is still field, and clicking
   it should still plant. */
.clock {
  position: fixed;
  right: var(--pad);
  bottom: var(--pad);
  z-index: 3;
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  font-size: 0.6875rem;
  letter-spacing: 0.02em;
  color: var(--ink);
  pointer-events: none;
  white-space: nowrap;
}

/* The frame is sized by a hidden 12:00:00 PM and the live time is laid over
   it, so the box never changes width — not at the 9→10 rollover, and not
   between 1 and 12. Held to the right edge inside it, which is the margin
   everything else in this corner is aligned to, so the only thing an hour
   rollover moves is where the leading digit starts. */
.clock-frame {
  position: relative;
  display: inline-block;
}

.clock-sizer { visibility: hidden; }

.clock-time {
  position: absolute;
  right: 0;
  top: 0;
  /* Every digit the same width, so a ticking second cannot shuffle the ones
     to the left of it. */
  font-variant-numeric: tabular-nums;
}

/* Nothing to say until the first tick lands, and a place with no time beside
   it is worse than no line at all. */
.clock:not([data-ready]) { visibility: hidden; }

/* ── the page's foot ────────────────────────────────────────────────────── */
/* On a desktop there is no footer here: the wrapper lays out as though it were
   not in the document at all, the clock keeps the fixed corner it has always
   held, and the copyright — which the short pages have never carried, because
   the whole page is in view at once and a notice at the bottom of it would be
   one more thing in the field — is simply not drawn. Both come back below. */
.page-foot { display: contents; }
.foot-note { display: none; }

/* 12px is the floor on a phone. This is an 11px cap on the desktop, which is
   legible at arm's length on a large screen and is not on a handset held at
   reading distance — so it comes up to the same size as the rest of the small
   type rather than being the one thing nobody can read. The clock had the same
   floor and is hidden at this width; see the small-screen block below. */
@media (max-width: 900px) {
  .controls { font-size: 0.75rem; }
}

/* ── work rows ──────────────────────────────────────────────────────────── */
/* The row carries a cover, a year and a blurb for the phone layout. On a
   desktop none of that is drawn: the row is the title on its own, and the
   cover arrives on hover in the floating preview instead. */

.work-cover,
.work-year,
.work-blurb { display: none; }

.work-line,
.work-name { display: inline; }

/* ── list items nudge aside on hover ────────────────────────────────────── */

.panel li a,
.work-toc a {
  position: relative;
  display: inline-block;
  font-weight: 400;
  /* Weight lands a beat after the slide, so the row moves and then settles,
     the way ink keeps darkening after the stroke has stopped. */
  transition: transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1),
              font-weight var(--ink-settle),
              opacity 0.2s ease;
}

/* Drawn from the text outwards into the space the indent just opened: the
   rule ends 6px short of the text and the row shifts 14px, so it lands in the
   gap rather than in the margin. It rides the same transform as the text, so
   the two stay locked together. */
.panel li a::before,
.work-toc a::before {
  content: "";
  position: absolute;
  top: 50%;
  right: 100%;
  margin-right: 6px;
  width: 0;
  height: 1px;
  background: currentColor;
  opacity: 0;
  transition: width 0.24s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s ease;
}

/* Both are inline-block in a left-aligned column, so a wider glyph run grows
   into empty space and moves nothing else on the page. That is the whole
   reason the full gain is affordable here and not in running copy. */
.panel li a:hover,
.work-toc a:hover {
  transform: translateX(14px);
  font-weight: calc(400 + var(--ink-gain));
}

.panel li a:hover::before,
.work-toc a:hover::before { width: 8px; opacity: 0.45; }
.reset:focus-visible { outline: 1.5px solid var(--ink); outline-offset: 3px; }

/* ── entrance ───────────────────────────────────────────────────────────── */

.mark, .name, .col p, .group, .controls, .clock, .back, .meta {
  opacity: 0;
  animation: rise 1.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
.mark        { animation-delay: 0.1s; }
.name        { animation-delay: 0.3s; }
.col p:nth-of-type(1) { animation-delay: 0.5s; }
.col p:nth-of-type(2) { animation-delay: 0.65s; }
.col p:nth-of-type(3) { animation-delay: 0.8s; }
.controls    { animation-delay: 0.95s; }
.clock       { animation-delay: 1.05s; }
.group:nth-of-type(1) { animation-delay: 1.1s; }
.group:nth-of-type(2) { animation-delay: 1.2s; }
.group:nth-of-type(3) { animation-delay: 1.3s; }

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

/* The name is the one piece of type that arrives the way a bloom does: it is
   laid down thin and keeps gaining ink after it has stopped moving, which is
   what ink() does in flowers.js — k passes at partial alpha converging on the
   target rather than one cross-fade. The easing is already decelerating, so
   the weight approaches 500 and slows into it.

   Only the name. It is a single short line in a fixed-width column with
   nothing beside it, so the changing advance width has nothing to push. Body
   copy is wrapped text: the same ramp there would re-flow lines mid-entrance
   and jump a word to the next row. */
.name { animation-name: rise-ink; }

@keyframes rise-ink {
  from { opacity: 0; transform: translateY(6px); font-weight: 300; }
  to   { opacity: 1; transform: translateY(0);   font-weight: 500; }
}

/* One switch for the whole small-screen layout, set where the hover preview
   stops fitting beside the copy. Two different breakpoints left a band from
   721 to 900 running the desktop column with no preview to justify it — a
   24rem column stranded in the middle of an 800px window. */
@media (max-width: 900px) {
  /* Fluid: the column is the page's content box, whatever that is. A fixed
     cap here is what produced the dead gutter — the layout stopped growing at
     26rem while the screen kept going. */
  .col {
    width: auto;
    max-width: none;
  }

  /* Running text is the one thing that does not want the full width: past
     roughly 70 characters the eye loses the start of the next line. Measured
     in ch, so the type sets it rather than a pixel guess, and it applies only
     to the paragraphs — every box below fills the column. */
  .col > p { max-width: 68ch; }

  /* The wash covers a third of a desktop field but nearly all of a phone one,
     where the copy is most of the screen. Thinner here, and pulled in at the
     sides, so the blooms behind the column read through it instead of being
     washed out. */
  .col::before {
    inset: -1.25rem -1.25rem;
    opacity: 0.5;
    filter: blur(18px);
  }

  /* ── the phone running order ──────────────────────────────────────────── */
  /* Copy, then the two short lists, then the controls, then the work cards.
     Visual order only: the DOM keeps Works first, so tab order and reading
     order are untouched, and the desktop column never sees any of this. */
  .col > * { order: 0; }
  .col > .group:nth-of-type(2),          /* Extras  */
  .col > .group:nth-of-type(3) { order: 1; }  /* Contact */
  .col > .controls { order: 2; }
  .col > .group:nth-of-type(1) { order: 3; }  /* Works, as cards */

  /* Off the bottom corner and into the column, above the first card. They are
     the only flower interaction left here, so they sit with the content
     rather than floating over it while the feed scrolls past. */
  .controls {
    position: static;
    align-self: flex-end;
    margin: 0.35rem 0 1.5rem;
  }

  /* Nothing to tap for, so nothing to say — see the touch branch in
     flowers.js, which stops planting on tap. */
  .hint { display: none; }

  /* No room for a fixed corner once the page scrolls — it would ride over the
     cards the whole way down — so the clock comes out of the corner and lands
     at the foot of the feed, with the notice beside it. The page now has an
     end, which on a phone it needs: the card feed otherwise simply stops. */
  .page-foot {
    /* Last in the running order, under the feed — it is the end of the page,
       and the DOM has it inside the column so the paper wash behind the copy
       reaches it. A line of small print laid straight on the field is the one
       thing here that cannot be read against a bloom. */
    order: 4;
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    width: 100%;
    margin-top: 1.5rem;
    padding-top: 0.9rem;
    border-top: 1px solid rgba(66, 66, 66, 0.14);
  }

  .foot-note {
    display: block;
    font-size: 0.6875rem;
    letter-spacing: 0.02em;
    color: var(--ink);
  }

  .clock {
    position: static;
    display: flex;
  }

  /* Works stops being a collapsible group and becomes the feed: the heading
     and its +/- are the affordance for a list you can put away, and a feed is
     not that. */
  .col > .group:nth-of-type(1) > h2 { display: none; }

  /* ...which means the panel must be open regardless of what the toggle was
     last left set to on a desktop. */
  .col > .group:nth-of-type(1) > .panel,
  .col > .group:nth-of-type(1) > .panel[hidden] {
    display: grid;
    grid-template-rows: 1fr;
    opacity: 1;
  }

  /* ── work cards ───────────────────────────────────────────────────────── */

  /* Fills the column at any width: one card on a phone, two or three once
     there is room, with the tracks sharing whatever is left over. min(100%)
     keeps the minimum from exceeding the column on the narrowest screens,
     which is what would otherwise force a horizontal scrollbar. */
  #works ul {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
    gap: 1.75rem 1.25rem;
  }

  #works li { font-size: inherit; line-height: inherit; }

  /* The row was an inline-block that slid sideways on hover. As a card it is
     a block that fills the column, and the slide and the rule that came with
     it have nothing to point at. */
  #works li a {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    transform: none;
    font-weight: 400;
  }
  #works li a::before { content: none; }

  .work-cover {
    display: block;
    aspect-ratio: 16 / 10;
    overflow: hidden;
    background: rgba(66, 66, 66, 0.05);
  }

  .work-cover img,
  .work-cover video {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* Title left, year right, sharing a baseline so the two read as one line
     even when the title wraps to two. */
  .work-line {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
  }

  .work-name {
    display: block;
    font-size: 0.875rem;
    font-weight: 500;
    color: var(--ink);
  }

  .work-year {
    display: block;
    flex: none;
    font-size: 0.875rem;
    font-weight: 400;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
  }

  .work-blurb {
    display: block;
    margin-top: -0.2rem;
    font-size: 0.8125rem;
    line-height: 1.45;
    color: var(--ink-faint);
  }
}

@media (prefers-reduced-motion: reduce) {
  .mark, .name, .col p, .group, .controls, .clock, .back, .meta {
    animation-duration: 0.01ms;
    animation-delay: 0ms;
    opacity: 1;
  }
  .pm, .panel, .panel li a, .work-toc a { transition-duration: 0.01ms; }
  .panel li a:hover, .work-toc a:hover { transform: none; }
  /* The gain still happens — it is the affordance — it just stops being a
     ramp, matching the reduced branch in flowers.js, which lays every bloom
     down in a single pass instead of fading it in. */
  .col a, .toggle, .back { transition-duration: 0.01ms; }
  .about-note-cta,
  .open-note-cta { transition-duration: 0.01ms; transform: none; }
  .panel li a::before, .work-toc a::before { transition-duration: 0.01ms; }
  .reset-icon.spinning { animation: none; }
  .palette-dot { transition-duration: 0.01ms; --rest: 0px; }
  .palette:hover .palette-dot:nth-child(1),
  .palette:hover .palette-dot:nth-child(3) { --rest: 0px; }
  .palette-dot.bouncing { animation: none; }
}

/* ── sub-pages ──────────────────────────────────────────────────────────── */

.back {
  font-size: 0.62rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-bottom: 1.4rem;
}

.back { transition: font-weight var(--ink-settle); }
.back:hover { font-weight: calc(400 + var(--ink-gain)); }

.meta {
  font-size: 0.62rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.prose { width: 100%; }

.prose p + p { margin-top: 1rem; }

.prose h2 {
  font-size: 0.92rem;
  font-weight: 500;
  margin: 1.5rem 0 0.4rem;
}

.prose ul, .prose ol { margin: 0 0 1.35rem 1.1rem; }

.prose li {
  font-size: 0.92rem;
  line-height: 1.5;
  color: var(--ink-soft);
}

.prose code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.84em;
  color: var(--ink);
}

.prose img {
  display: block;
  width: 100%;
  height: auto;
  margin: 0 0 1.35rem;
}

/* ── after the first page ───────────────────────────────────────────────── */
/* The staggered entrance is a first-impression moment. Once the router is
   swapping pages, the same 1.5s cascade just reads as lag, so collapse it. */

html[data-nav] .mark,
html[data-nav] .name,
html[data-nav] .col p,
html[data-nav] .group,
html[data-nav] .controls,
html[data-nav] .back,
html[data-nav] .meta {
  animation-duration: 0.45s;
  animation-delay: 0s;
}

/* A landscape phone is short enough that the copy runs past the bottom of the
   viewport and scrolls — straight underneath the clock and the controls, which
   are fixed to that corner. The page has to end above them, not behind them.
   Height-based rather than width-based: it is the short viewport that causes
   this, and a tall narrow phone does not need the padding. */
@media (max-height: 520px) {
  .page { padding-bottom: calc(var(--pad) + 3.5rem); }
}

/* ── work preview overlay ───────────────────────────────────────────────── */
/* Sits in the open space right of the copy, over the flower field. Fixed, so
   its start position is the same for every work and the morph into the case
   study hero is predictable. */

.preview {
  position: fixed;
  z-index: 2;
  top: 50%;
  /* Centred in the open space rather than hung off one side of it. left and
     right bound that space — everything past the copy column, inset from the
     page edge by the same padding the page uses — and the auto margins split
     what the frame does not fill evenly between the two, so it sits in the
     middle of the field at every width instead of drifting to an edge. */
  left: 22rem;
  right: var(--pad);
  margin-inline: auto;
  /* One frame for every work. It used to take the shape of whatever it was
     holding, which meant a 2.4:1 screen recording came in as a thin strip and
     a 5:3 one as a tall block — the same list reading as a different size on
     every row. A fixed 16:9 is the shape the covers are closest to, and the
     media fills it (see object-fit below), so the previews are one size and
     the frame is as large as the space allows. The 26rem held back is the copy
     column plus clearance beside it, kept a little wider than the left bound
     above so a narrow desktop shrinks the frame rather than crowding the type.
     The last term keeps the ratio exact on a short viewport: the box is capped
     by width, not by max-height, which would squash it out of 16:9. */
  width: min(52rem, calc(100vw - 26rem - var(--pad)), calc(76vh * 16 / 9));
  aspect-ratio: 16 / 9;
  transform: translateY(-50%);
  border-radius: 20px;
  /* Already here for the frame; it is also what rounds the media, since the
     img/video fills the box edge to edge and is clipped by it. */
  overflow: hidden;
  background: var(--paper);
  box-shadow: 0 1px 2px rgba(66, 66, 66, 0.06), 0 12px 40px rgba(66, 66, 66, 0.1);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.28s ease, transform 0.36s cubic-bezier(0.22, 0.61, 0.36, 1),
              visibility 0s linear 0.3s;
}

.preview.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(-50%) scale(1);
  transition: opacity 0.28s ease, transform 0.36s cubic-bezier(0.22, 0.61, 0.36, 1),
              visibility 0s;
}

.preview:not(.is-open) { transform: translateY(-50%) scale(0.97); }

.preview img,
.preview video {
  display: block;
  width: 100%;
  height: 100%;
  /* cover, not contain: the frame is a fixed 16:9 now, so contain would matte
     every cover with bands of paper at a different height per work — the same
     inconsistency the fixed frame was meant to remove, moved inside it. This
     is also what the case study banner the preview morphs into does, so the
     crop does not change across the transition. */
  object-fit: cover;
}

/* No pointer, no hover preview. On a phone the covers are on the cards
   themselves, so there is nothing left to peek at and the row is simply a
   link — see the card layout in the mobile block above. */
@media (hover: none), (max-width: 900px) {
  .preview { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .preview,
  .preview.is-open { transition-duration: 0.01ms; transform: translateY(-50%); }
}

/* ── case study page ────────────────────────────────────────────────────── */
/* Its own layout: full width, banner across the top, sticky contents rail
   beside the body. The flower field is hidden here — see [data-garden]. */

.work {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  min-height: 100svh;
  background: var(--paper);
}

/* The case study's type comes straight from the Figma source. Two families:
   General Sans carries the reading copy, Hedvig Letters Serif marks the
   structure — section labels, metadata labels, pull quotes and the live links.
   Five steps, nothing heavier than medium.

   Geometry is the Figma's too. At its 1512px artboard the rule below gives
   30 / 270 / 30 / 882 / 300, exactly what the file specifies.

   Away from that width nothing is pinned. Every value is written as the share
   of 1512 the file gives it, so the whole page is one scale rather than a
   fixed drawing with breakpoints cut into it: at the artboard's width the page
   IS the file, wider it grows with the window instead of sitting in a puddle
   in the middle of it, and narrower each value eases down to a floor that is
   still legible in the hand. The two media queries further down only decide
   what stands beside what — they set no sizes the scale has not already. */
/* The page inset is not set here: a case study starts at the same edge the
   home page, About and Playground do, so it takes the site's --pad. Everything
   inside — banner, body, footer, the back-to-top corner — already reads that
   token, and there is nothing on this page to override it with. */
.work {
  --rail: clamp(150px, 17.86vw, 300px);   /* 270 at 1512 */
  /* Where the page stops stretching and centres instead. On an ultrawide the
     column would otherwise run past 100 characters a line, which is a measure
     the eye loses its place in on the way back. Set above the artboard's own
     width, so at 1512 and below this decides nothing. */
  --work-max: 1680px;
  --serif: "Hedvig Letters Serif", Georgia, "Times New Roman", serif;
  /* The serif section labels and the captions under media. Black, as above. */
  --muted: #000000;

  /* --fs-body — the General Sans reading copy — is the site's now, and is set
     at :root so every other page is read at the same size. The four below are
     this page's structure and stay with it. */
  --fs-eyebrow: clamp(12px, 0.794vw, 15px);  /* serif section labels, captions */
  --fs-serif:   clamp(14px, 0.926vw, 18px);  /* metadata labels, lead-ins */
  --fs-quote:   clamp(14px, 0.926vw, 19px);  /* serif pull quotes, the tagline */
  --fs-head:    clamp(16px, 1.058vw, 21px);  /* section headings */
  --fs-link:    clamp(20px, 1.455vw, 30px);  /* live site links */
  --fs-title:   clamp(16px, 1.19vw, 26px);   /* the banner — 18 at 1512 */
}

.work-banner {
  position: relative;
  display: flex;
  align-items: flex-start;
  /* 510 at the artboard, and a share of the window either side of it. The
     heroes are sized from this rather than from their own copy of the number —
     the MIDI panel reads it straight, the record takes its width from it — so
     the frame and whatever stands in it cannot disagree about how tall it is. */
  --banner-h: clamp(300px, 33.73vw, 620px);
  height: var(--banner-h);
  padding: var(--pad);
  overflow: hidden;
  background: #ecebe3;
}

.work-banner-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

.work-banner-copy {
  position: relative;
  /* Above the veil below, which is itself above the picture. */
  z-index: 2;
  display: flex;
  flex-direction: column;
  width: 100%;
}

/* Set the way the section labels are — serif, and one size, whether the banner
   behind it is a hero the page draws or a picture it loads. The colour is the
   one thing that cannot be shared: it belongs to the ground each banner puts
   under it, so a hero names its own below and a cover banner says which it
   wants with `bannerInk` in frontmatter. */
.work-title {
  font-family: var(--serif);
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: 1.35;
  letter-spacing: -0.014em;
  color: var(--banner-ink, #000);
  /* Held to a measure so a sentence wraps in the corner it lives in rather
     than running under the record. */
  max-width: 26rem;
}

.work-banner[data-ink="light"] { --banner-ink: #fff; }

/* White over a photograph needs ground under it, the way the Descartes globe
   does. A cover banner is somebody's picture rather than a scene this page
   drew, so the veil is the page's to add: darkest in the corner the title sits
   in and gone by the time it would touch the middle of the picture. Only where
   the ink is light — a black title has the picture's own paper behind it. */
.work-banner[data-ink="light"]:not(.has-hero)::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 62%;
  background: linear-gradient(180deg, rgba(16, 20, 40, 0.46) 0%, rgba(16, 20, 40, 0.16) 55%, rgba(16, 20, 40, 0) 100%);
  pointer-events: none;
  /* The picture is a positioned sibling that comes later in the document, so
     without this the veil would be painted underneath the thing it veils. */
  z-index: 1;
}

/* The same again at the foot of a banner, on a phone only.

   Two of the heroes owe a credit for code that is not mine, set white in the
   bottom corner — and both of them stand in green fields that are at their
   palest exactly there. On a desktop the scene fills the frame and the line
   lands on the artwork; at 420px the record and the MIDI panel have shrunk
   away from that corner and the credit is left on bare pale green, where white
   is barely a colour at all.

   Keyed to the credit rather than to the two hero names: it is the thing being
   made readable, so a hero that grows one later is covered by this, and the
   ones with none are not darkened for nothing — the globe is already night and
   would only get muddier. */
@media (max-width: 900px) {
  .work-banner:has(.hero-thanks)::after {
    content: "";
    position: absolute;
    inset: auto 0 0 0;
    /* Tall, and mostly ramp. The credit sits between 30 and 58px off the floor
       of a 420px banner, so the dark has to be full strength there — but a
       short veil that gets there ends up a slab with an edge, and the edge
       lands on bare pale green where there is nothing to hide it. Made twice
       as tall it reaches its top over the artwork instead, which is dark and
       busy and takes a fade without showing the join. The scene loses a little
       of its foot to it; a green field going to shadow at the bottom of the
       frame is what the ground does anyway. */
    height: 46%;
    background: linear-gradient(0deg,
      rgba(16, 20, 40, 0.7) 0%,
      rgba(16, 20, 40, 0.66) 18%,
      rgba(16, 20, 40, 0.6) 32%,
      rgba(16, 20, 40, 0.34) 52%,
      rgba(16, 20, 40, 0.12) 76%,
      rgba(16, 20, 40, 0) 100%);
    pointer-events: none;
    /* Over the scene and the leaves that blow across it, under the title (5)
       and under the credit itself (6): this is ground for that line, not a
       wash laid over it. */
    z-index: 4;
  }
}

.work-body {
  display: flex;
  flex: 1;
  align-items: flex-start;
  gap: var(--pad);
  width: 100%;
  /* Past --work-max the page stops widening and centres. The banner and the
     footer's rule still run the full width — they are the page's edges — but
     what is read sits in the middle of the window rather than at the left of
     it. */
  max-width: var(--work-max);
  margin-inline: auto;
  padding: var(--pad);
  /* The rail and the page edge, mirrored on the right — the Figma's 300px at
     its own width, and proportional to it everywhere else. */
  padding-right: calc(var(--pad) + var(--rail));
  padding-bottom: 120px;
}

/* The rail rides along as the body scrolls. */
.work-rail {
  position: sticky;
  top: var(--pad);
  display: flex;
  flex-direction: column;
  gap: 16px;
  width: var(--rail);
  flex: none;
}

.work-rail .back {
  font-size: var(--fs-eyebrow);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 0;
}

.work-toc {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.work-toc a {
  font-size: var(--fs-eyebrow);
  line-height: 1.35;
  color: var(--ink);
}

/* The section being read. Set by the scroll spy in ui.js, so it moves with the
   reader rather than being pinned to the first row. */
.work-toc li a.is-current {
  font-weight: 500;
  color: var(--ink);
}

/* Third-level headings sit in from the section they belong to. */
.work-toc .is-sub { padding-left: 16px; }
.work-toc .is-sub a { color: #000; }

.work-content {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--pad);
}

/* ── the metadata row ───────────────────────────────────────────────────── */

.work-meta {
  display: flex;
  gap: 48px;
  width: 100%;
}

.work-meta > div { flex: 1; min-width: 0; }

.work-meta dt {
  font-family: var(--serif);
  font-size: var(--fs-serif);
  font-weight: 400;
  color: var(--ink);
  margin-bottom: 4px;
}

.work-meta dd {
  font-size: var(--fs-body);
  line-height: 1.35;
  color: var(--ink);
}

/* The serif label that opens a section. In the markdown this is an h2, which
   is also what fills the contents rail. */
.overline,
.work-content .prose h2 {
  font-family: var(--serif);
  font-size: var(--fs-eyebrow);
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  line-height: 1.35;
  color: var(--muted);
  margin: 0;
}

.work-lede { display: flex; flex-direction: column; gap: 4px; }

.work-tagline {
  font-family: var(--serif);
  font-size: var(--fs-quote);
  font-weight: 400;
  line-height: 1.35;
  color: var(--ink);
}

/* ── the body ───────────────────────────────────────────────────────────── */

.work-content .prose { max-width: none; }

/* Spacing follows the Figma: 30px between blocks, 4px inside one, and a blank
   line between consecutive paragraphs. A block starts at a section label, a
   heading, or a piece of media — unless it sits directly under a label, in
   which case it is part of that label's block rather than a new one. */
.work-content .prose > * { margin: 0; }
.work-content .prose > * + * { margin-top: 4px; }
.work-content .prose > p + p { margin-top: 1em; }
.work-content .prose > :is(h2, h3, .h, figure, .figs, .cols, .stack, .sites-box) { margin-top: 30px; }
/* Media closes a block as well as opening one, so whatever comes after it
   starts a new one — otherwise a line like "Components I worked on:" sits
   tight under the image above it. */
.work-content .prose > :is(figure, .figs, .cols, .stack) + * { margin-top: 30px; }
/* ...except directly under a label or heading, which owns what follows it. */
.work-content .prose > :is(h2, h3, .h, .overline, .lead-in) + * { margin-top: 4px; }
.work-content .prose > :first-child { margin-top: 0; }

/* The 16px medium heading. Some belong in the rail and some do not, so the
   ones that do are h3 and the rest carry .h — identical to a reader, and the
   difference only decides what the rail lists. */
.work-content .prose h3,
.work-content .prose .h {
  font-family: inherit;
  font-size: var(--fs-head);
  font-weight: 500;
  line-height: 1.35;
  color: var(--ink);
}

.work-content .prose :is(p, li) {
  font-size: var(--fs-body);
  line-height: 1.45;
  color: var(--ink);
}

.work-content .prose :is(ul, ol) {
  margin: 0;
  padding-left: 21px;
}

.work-content .prose :is(strong, b) { font-weight: 500; color: var(--ink); }

/* A serif line that introduces a list — "This included:", "Components I worked
   on:" — set at reading size rather than at label size. */
.work-content .prose .lead-in {
  font-family: var(--serif);
  font-size: var(--fs-serif);
  color: var(--ink);
  margin-bottom: 4px;
}

/* Some of these introductions are set in the body sans rather than the serif.
   Same job, so the same name with the typeface turned off — the spacing rule
   that keeps a list tight under its lead already reads .lead-in. */
.work-content .prose .lead-in.plain {
  font-family: inherit;
  font-size: var(--fs-body);
}

/* The pull quote: a 28px rule, then the line in serif. */
.work-content .prose .quote {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--serif);
  font-size: var(--fs-quote);
  line-height: 1.35;
  color: var(--ink);
}

.work-content .prose .quote::before {
  content: "";
  flex: none;
  width: 28px;
  height: 1px;
  background: var(--ink);
}

/* The two live sites, pushed to either end of the column. */
/* The live sites are the one thing on this page a reader might genuinely want
   to leave for, so they sit in a panel rather than in the run of the text: a
   wash of the field's own lime, and the links already green inside it instead
   of waiting to be hovered to say they are links. */
.work-content .prose .sites-box {
  padding: clamp(1.25rem, 2.4vw, 1.9rem) clamp(1.25rem, 2.6vw, 2.1rem);
  border-radius: 14px;
  background: var(--box-wash);
}

/* The label, the section heading and the lead line all belong to the panel
   rather than to the page around it, so they take its green instead of the
   grey and near-black the rest of the body uses. */
.work-content .prose .sites-box :is(h2, .overline, .h) {
  color: var(--box-label);
}

/* Inside the panel the `.prose > *` rules do not reach, so the same rhythm is
   restated: tight between a label and its heading, a clear step before the
   links themselves. */
.work-content .prose .sites-box > * { margin: 0; }
.work-content .prose .sites-box > * + * { margin-top: 4px; }
.work-content .prose .sites-box .sites { margin-top: 0.85rem; }

.work-content .prose .sites {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  font-family: var(--serif);
  font-size: var(--fs-link);
  line-height: 1.2;
}

/* These are the two things on the page a reader is most likely to want, so
   they are underlined at rest rather than waiting to be discovered. On hover
   the rule goes to full strength and the mark that says "this leaves the
   site" fades in beside it. */
/* Green floods the type from the left on hover, in the direction the line is
   read, rather than switching colour all at once.

   The flood is a two-stop gradient twice the width of the link, clipped to the
   glyphs and slid from one half to the other: at rest the ink half shows, on
   hover the green half has arrived. Nothing animates but background-position,
   which is cheap, and the type never blurs the way a cross-fade of two colours
   would. */
.work-content .prose .sites a {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  color: var(--box-link);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.16em;
  /* Drawn but colourless until hover: the rule is already laid out, so it
     arrives without shifting the line. */
  text-decoration-color: transparent;
  transition: text-decoration-color 0.3s ease, color 0.3s ease;
}

/* Only where the glyphs can actually be painted by a background. Everywhere
   else the link simply turns green, which is the same idea without the sweep,
   and the type is never left transparent with nothing behind it. */
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .work-content .prose .sites a {
    background-image:
      linear-gradient(90deg, var(--box-link-deep) 50%, var(--box-link) 50%);
    background-size: 200% 100%;
    background-position: 100% 0;
    background-repeat: no-repeat;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    transition:
      background-position 0.55s cubic-bezier(0.22, 0.61, 0.36, 1),
      text-decoration-color 0.3s ease;
  }

  .work-content .prose .sites a:is(:hover, :focus-visible) {
    background-position: 0 0;
  }
}

.work-content .prose .sites a::after {
  content: "";
  flex: none;
  /* Sized in em so it tracks whatever it sits beside — at the 22px live links
     that is a mark the same size as the type, not a footnote next to it. */
  width: 1em;
  height: 1em;
  /* Not currentColor: where the glyphs are painted by a clipped background the
     colour is transparent, and the mark would vanish with it. */
  background: var(--box-link);
  opacity: 0;
  transform: translate(-0.15em, 0.15em);
  transition: opacity 0.2s ease, transform 0.2s ease;
  --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M9.5 3H3.5A1 1 0 0 0 2.5 4v8.5a1 1 0 0 0 1 1H12a1 1 0 0 0 1-1v-6' stroke='%23000' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M10 2.5h3.5V6M13.2 2.8 7.5 8.5' stroke='%23000' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  -webkit-mask: var(--glyph) center / contain no-repeat;
  mask: var(--glyph) center / contain no-repeat;
}

.work-content .prose .sites a:is(:hover, :focus-visible) {
  color: var(--box-link-deep);
  text-decoration-color: var(--box-link-deep);
}

/* The mark arrives green, so it lands with the end of the flood. */
.work-content .prose .sites a:is(:hover, :focus-visible)::after {
  background: var(--box-link-deep);
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .work-content .prose .sites a::after { transition: opacity 0.12s ease; transform: none; }
}

/* Text laid out in columns — the TL;DR lists and the takeaways. */
.work-content .prose .cols {
  display: grid;
  gap: var(--pad);
  grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
}

.work-content .prose .cols > * { margin: 0; }
.work-content .prose .cols p + p { margin-top: 1em; }
.work-content .prose .cols .h + p { margin-top: 4px; }

/* Inside a column the medium heading is a list item's title, not a section
   heading — the Figma sets it at reading size. */
.work-content .prose .cols .h { font-size: var(--fs-body); }

/* Lines that are one thought rather than consecutive thoughts — the questions
   a hacker profile has to answer. Consecutive paragraphs sit a full line
   apart; these sit at the 4px the rest of a block uses. */
.work-content .prose .stack > * + * { margin-top: 4px; }

/* ── media ──────────────────────────────────────────────────────────────── */

.work-content .prose figure { max-width: 100%; }

.work-content .prose figure :is(img, video) {
  display: block;
  width: 100%;
  height: auto;
  margin: 0;
  background: #ecebe3;
}

/* Two pieces of media under one caption — a still and the recording it is a
   frame of. They are one figure, so they sit at a block's own 4px rather than
   the 30px that separates two figures. */
.work-content .prose figure :is(img, video) + :is(img, video) { margin-top: 4px; }

/* The screen recordings carry their own letterboxing. The Figma crops it away
   by sitting each one in a shorter window, so --ar names that window and the
   video fills it. Images keep their natural shape. */
.work-content .prose figure video {
  aspect-ratio: var(--ar, auto);
  object-fit: cover;
}

/* Somebody else's player standing in a figure — the one case study whose
   record of the thing is a video that lives on YouTube. Sized like the rest of
   the media rather than by the embed's own width attribute, so it holds the
   column and reserves its height before the frame arrives. */
.work-content .prose figure iframe {
  display: block;
  width: 100%;
  aspect-ratio: var(--ar, 16 / 9);
  border: 0;
  background: #ecebe3;
}

/* A serif label above a piece of media, matching the section labels. */
.work-content .prose figcaption {
  font-family: var(--serif);
  font-size: var(--fs-eyebrow);
  line-height: 1.35;
  color: var(--muted);
  margin-bottom: 4px;
}

/* A caption that sits under its image and is centred — the lo-fi strip. */
.work-content .prose .cap {
  font-size: var(--fs-eyebrow);
  line-height: 1.35;
  text-align: center;
  color: #000;
  margin: 10px 0;
}

/* Media belonging to one moment sits in a row. --cols sets how many, and they
   are even. Where the Figma gives them different widths — a landscape render
   beside a portrait clip — --tpl names the track list instead and --cols is
   ignored. Both are variables rather than an inline grid-template-columns so
   the phone rule below, which is a plain declaration, still collapses the row
   to one column; an inline property would outrank it. */
.work-content .prose .figs {
  display: grid;
  gap: var(--pad);
  align-items: start;
  grid-template-columns: var(--tpl, repeat(var(--cols, 2), minmax(0, 1fr)));
}

.work-content .prose .figs > * { margin: 0; }

/* The three Hack Pals. Each is the published Lottie rather than a still of it,
   so the row is three iframes sitting over the name graphics they belong to.
   The animations are authored on a square canvas, which is what --pal-ar names:
   the box is reserved before the frame loads, so the names underneath do not
   jump once three players arrive. */
.work-content .prose .pals {
  display: grid;
  gap: var(--pad);
  align-items: end;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.work-content .prose .pal {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  margin: 0;
}

.work-content .prose .pal iframe {
  display: block;
  width: 100%;
  aspect-ratio: var(--pal-ar, 1);
  border: 0;
  /* The Lottie pages render on transparent, so the paper shows through. */
  background: transparent;
  /* They are a picture, not a control: nothing inside them is clickable. */
  pointer-events: none;
}

.work-content .prose .pal img {
  display: block;
  width: 100%;
  height: auto;
  margin: 0;
}

/* ideation → direction → graphic design + dev handoff */
.work-content .prose .flow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  font-size: var(--fs-body);
  color: var(--ink);
  margin-bottom: 4px;
}

.work-content .prose .flow span { flex: none; }

.work-content .prose .flow i {
  position: relative;
  flex: 1 1 auto;
  height: 1px;
  background: var(--ink);
}

.work-content .prose .flow i::after {
  content: "";
  position: absolute;
  right: 0;
  top: -2.5px;
  border-left: 5px solid var(--ink);
  border-top: 3px solid transparent;
  border-bottom: 3px solid transparent;
}

/* Where a section comes to rest when its contents link is clicked. Set well
   down the window rather than at the top edge: the end of the section before
   it stays in view, so the jump reads as a move within one page instead of a
   page replacement. This single value drives both the smooth scroll and the
   browser's native jump under reduced motion. */
.work-content .prose :is(h2, h3),
.work-lede { scroll-margin-top: 40vh; }

/* Below the artboard the mirrored right inset is the first thing to go. It is
   there to hold the measure at desktop width, where there is width to spare;
   on a tablet there is none, and 200px of empty paper beside a 600px column is
   the column's. The rail stays — it still has a margin to stand in. */
@media (max-width: 1200px) {
  .work-body { padding-right: var(--pad); }
}

@media (max-width: 900px) {
  /* The structural type stops scaling with the window and takes a floor
     instead, the same way --fs-body does at :root — the artboard's sizes were
     set for a column a third of a 1512px window wide, read at desk distance,
     and the same copy on a handset is the whole screen wide and a foot from
     the face. Everything on the page is sized from these and the body token,
     so it all comes up together. */
  .work {
    --fs-eyebrow: 12px;
    --fs-serif: 15px;
    --fs-quote: 15px;
    --fs-head: 17px;
  }

  .work-body {
    flex-direction: column;
    gap: 2rem;
    padding-bottom: 4rem;
  }

  /* The rail goes, and the contents list with it. Stacked above the body it
     was a screenful of links before the first word, and the sections it points
     at are a flick apart at this width anyway. What replaces it is the corner
     menu — the whole site rather than the one piece, which is what a reader
     who wants out of a case study is actually after. See .wnav. */
  .work-rail { display: none; }

  /* The banner's title stops where the menu button starts. The button is fixed
     in the same corner the title runs into, and a headline passing under it is
     a headline with a coin of paper set in the middle of it. 44px of button
     and a space. */
  .work-banner-copy { padding-right: 52px; }

  .work-meta { flex-wrap: wrap; gap: 1.25rem 2rem; }
  .work-meta > div { flex: 1 1 40%; }
  .work-content .prose :is(.cols, .figs) { grid-template-columns: 1fr; }

  /* A collapsed column pair is one run of points, not two. Side by side the
     gap between the halves is what separates them; stacked, the same gap lands
     in the middle of the run and reads as a break the desktop layout never
     had. So the halves rejoin at whatever rhythm already runs inside them.

     Loose paragraphs — the runs of questions — rejoin at the 1em that already
     sits between two of them. Spelled as the body size rather than 1em because
     the gap resolves against the block, which is at the page's root size, and
     the margin it is matching resolves against the paragraph. */
  .work-content .prose .cols:not(:has(.h)) { row-gap: var(--fs-body); }

  /* A split list rejoins at nothing: two <ul>s stacked flush are one list,
     bullets straight through. The :not(:has(.h)) is carried over from the rule
     above only for weight — a :has of bare element names is lighter than one
     of a class, so without it this loses to the 1em. */
  .work-content .prose .cols:not(:has(.h)):has(> :is(ul, ol)) { row-gap: 0; }

  /* The exception is a block whose halves are titled items — the numbered
     takeaways. There the halves really are two things and the gap belongs
     between them, which is what the :not(:has(.h)) above spares. */

  /* Three squares at phone width would each be a thumbnail; two rows of pals
     reads better than one row of nothing. */
  .work-content .prose .pals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .work-content .prose .sites { font-size: var(--fs-head); }
  .work-content .prose .flow { flex-wrap: wrap; }

  /* A section comes to rest 40vh down on a desktop, where that leaves the end
     of the section above it in view. On a phone 40vh is nearly half the screen
     given to what has already been read. */
  .work-content .prose :is(h2, h3),
  .work-lede { scroll-margin-top: 18vh; }
}

/* A handset, held in one hand. */
@media (max-width: 560px) {
  .work-meta { gap: 1rem; }
  .work-meta > div { flex: 1 1 100%; }

  /* Two long hostnames pushed to opposite edges of a 350px panel leave a hole
     between them; stacked at the left they read as a list of two places. */
  .work-content .prose .sites { justify-content: flex-start; gap: 0.5rem 1rem; }
}

/* ── the footer ─────────────────────────────────────────────────────────── */
/* The single-column pages carry the clock and the controls in fixed corners.
   A case study scrolls too far for a fixed corner to be anything but in the
   way, so the same information sits at the end of the page instead. */

.work-foot {
  display: flex;
  flex-wrap: wrap;
  /* Centred, not baselined: the sign-off bloom is the tallest thing in the row
     and a baseline would hang the two text blocks off the bottom of it. */
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem var(--pad);
  margin-top: auto;
  /* Held to the same column the body is, so on a wide window the sign-off and
     its rule finish under what was being read rather than out at the glass. */
  width: 100%;
  max-width: var(--work-max);
  margin-inline: auto;
  padding: var(--pad);
  border-top: 1px solid rgba(66, 66, 66, 0.14);
  /* --fs-eyebrow is the case study's own scale and is declared on .work, so on
     any other page carrying this footer the variable does not resolve and the
     row would inherit the document's 16px next to copy set in 12. The fallback
     is the site's reading size — the same 0.75rem the wall's captions, About,
     and the home page's copy are set in. */
  font-size: var(--fs-eyebrow, 0.75rem);
  color: var(--ink-faint);
}

.work-foot a { color: var(--ink); }
.work-foot a:hover { text-decoration: underline; }

/* ── the strip of field under the footer ───────────────────────────────────
   A case study switches the flower field off for its whole length, and gives
   the reader one seeded bloom at the end of it. This is the rest of that
   ending: the band the footer sits in is a strip of the same field, grown by
   flowers.js through the same runners and recipes, and it can be planted into
   by clicking it.

   It is a pane over the footer's own ground rather than a block in the row:
   absolutely positioned so it adds no height and moves nothing, and inset by
   the top border so the growth stops at the hairline the footer is divided by
   rather than crossing it. Everything else in the row is lifted a layer above
   it — see .work-foot > :not(.foot-field) — because the type is the thing that
   has to stay readable, and because a link has to stay clickable through what
   is drawn over its ground.

   The canvas is the clip: a bloom that opens near the edge is cut by it, and a
   runner that leaves is gone. That is what makes this read as a strip cut from
   a field rather than as a footer ornament. */
.foot-field {
  position: absolute;
  /* Below the rule, not over it. */
  inset: 1px 0 0 0;
  z-index: 0;
  display: block;
  /* Both of these are stated rather than left to `bottom` to resolve, because
     a canvas is a replaced element: with height:auto it ignores `bottom` and
     takes its own width/height attributes as the intrinsic size instead. Those
     attributes are the backing store, which flowers.js sizes FROM this box —
     so leaving it auto is a feedback loop, and the strip grows to fill the
     window. The row's height is the one that decides, in both directions. */
  width: 100%;
  height: calc(100% - 1px);
  /* Clicks land on the footer and flowers.js works out where in the band they
     were — one handler, and no pane in front of the links. */
  pointer-events: none;
}

/* The strip is behind the row, so the row has to be in front of it. A z-index
   on the children rather than a stacking context on the footer, which would
   put the fixed back-to-top button and the reading rail behind it. */
.work-foot { position: relative; }
.work-foot > :not(.foot-field) { position: relative; z-index: 1; }

/* The same wash the copy block gets on the short pages, at the size of a line
   rather than a column: paper behind the type so it survives a dense field,
   blurred rather than clipped so there is no edge to find and it reads as the
   flowers thinning out around the words instead of as a plate under them.
   Without it the strip would have to be kept off the type entirely, and a band
   this shallow has nowhere to keep it — the whole row would end up as two
   corners of flowers with a dead middle.

   --foot-wash rather than --paper directly, because two of the three pages
   carrying this footer are on the dark ground and the wash has to be whatever
   is actually behind the row. Set just below. */
.work-foot [data-foot-avoid] { position: relative; }

.work-foot [data-foot-avoid]::before {
  content: "";
  position: absolute;
  inset: -0.55rem -1.35rem;
  z-index: -1;
  border-radius: 1.5rem;
  background: var(--foot-wash, var(--paper));
  /* Heavier than the copy block's --scrim, which is 0.72. That one is a whole
     column of reading size held above a field seen across a screen; this is a
     line of 12px type with blooms opening at the width of the words, and the
     end of the band the growth comes in at is the densest part of it. At 0.72
     the dwell count on the dark pages was legible but working for it. */
  opacity: 0.88;
  filter: blur(12px);
  pointer-events: none;
}

/* The short pages say the field is plantable in copy — "(click the space to
   grow)" — because there the field is the whole page and a cursor announcing
   itself over all of it would be a page that flinches. A band at the end of a
   case study is the opposite case: small, bounded, and arrived at without any
   of that page having been plantable, so there is nothing to carry the idea
   over. A cursor is the right size of announcement for a region this size, and
   it is how the wall's draggable scenes and the vinyl already say the same
   thing. Only where planting is actually on — see `plantable` in flowers.js,
   which turns it off for touch, and the width where the strip goes away. */
@media (hover: hover) and (min-width: 901px) {
  .work-foot { cursor: crosshair; }
  /* ...except over the things that are already doing something. */
  .work-foot a,
  .work-foot button { cursor: pointer; }
}

/* Below this the footer is one tight line of 11px type with the links already
   dropped, and the band is too short to hold a flower that is not just noise
   behind the small print. The same width planting is turned off at, for the
   same reason the hint is: there is nothing here to offer a phone. flowers.js
   checks the box it is given and declines to grow into a hidden one. */
@media (max-width: 900px) {
  .foot-field { display: none; }
}

.foot-links {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
}

/* ── back to top ───────────────────────────────────────────────────────── */
/* The counterweight to the reading rail: the rail is how you move around inside
   the piece, this is how you leave the bottom of it. A fixed corner, in the one
   corner a case study leaves free — the clock and the controls that would have
   been here on a short page are down in the footer instead.

   Set at 12px rather than inheriting: --fs-eyebrow happens to be 12 today, and
   this size was asked for as a size. */
.to-top {
  position: fixed;
  right: var(--pad);
  /* Normally the same inset as every other page edge. The scroll handler raises
     it over the footer — see --to-top-bottom in ui.js — so it comes to rest
     exactly where a line in the footer would have sat. */
  bottom: var(--to-top-bottom, var(--pad));
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 12px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);

  /* Nothing to go back to until the reader has gone somewhere: it starts out of
     the way, and ui.js adds .is-shown once the banner has gone past. Visibility
     is in the transition so a hidden link is out of the tab order too — it
     flips on at the start of the fade in and off at the end of the fade out. */
  opacity: 0;
  visibility: hidden;
  transform: translateY(4px);
  transition:
    opacity 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
    transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
    visibility 0.32s,
    bottom 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
    color var(--ink-settle),
    font-weight var(--ink-settle);
}

.to-top.is-shown {
  opacity: 1;
  visibility: visible;
  transform: none;
}

/* A fixed corner crosses whatever happens to be under it, and on a phone the
   figures run the full width — grey caps over a screenshot are unreadable. So
   the link carries a little paper with it. Soft-edged rather than a plate: over
   the paper it is the same colour as the page and cannot be seen at all, over a
   figure it wicks out the way ink does everywhere else here, and there is no
   rectangle either way. */
.to-top::before {
  content: "";
  position: absolute;
  inset: -12px -18px;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--paper) 60%, transparent);
}

/* Ink thickens on touch, like everything else here. No underline: the mark
   lifting off the baseline is the affordance, and a rule under an uppercase
   eyebrow reads as a mistake. */
.to-top:hover {
  color: var(--ink);
  font-weight: calc(400 + var(--ink-gain));
}

/* Matched to the type rather than set from the old 0.6rem: at 12px the arrow
   fills its box to about the cap height of the label beside it. */
.to-top-mark {
  width: 12px;
  height: 12px;
  flex: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  transition: transform var(--ink-settle);
}

/* It goes where it is pointing. */
.to-top:hover .to-top-mark { transform: translateY(-2px); }

/* The gain and the colour still happen — they are the affordance — they just
   stop being a ramp. Here rather than in the block at the top of the file,
   which sits above these rules and would lose on source order. The lift and the
   ride over the footer are motion, so those go. */
@media (prefers-reduced-motion: reduce) {
  .to-top {
    transition-duration: 0.01ms, 0.01ms, 0.01ms, 0.01ms, 0.01ms, 0.01ms;
  }
  .to-top-mark { transition-duration: 0.01ms; }
  .to-top:hover .to-top-mark { transform: none; }
}

/* ── the phone's menu ───────────────────────────────────────────────────── */
/* What the contents rail becomes below 900px. The rail answers "where am I in
   this piece", which a phone can answer by scrolling; this answers "how do I
   get out of it", which it cannot. So the corner carries the site instead of
   the section list: home, the other works, the extras, and the ways to reach
   me — the home page's own three groups, in its order.

   Nothing here exists on a desktop. The rail is standing in its margin there,
   and two navigations for one page is one too many. */
.wnav-btn { display: none; }

.wnav-sheet[hidden] { display: none; }

@media (max-width: 900px) {
  .wnav-btn {
    position: fixed;
    top: var(--pad);
    right: var(--pad);
    z-index: 41;
    display: flex;
    align-items: center;
    justify-content: center;
    /* 44px square: a thumb's worth of target, whatever the marks inside
       measure. */
    width: 44px;
    height: 44px;
    padding: 0;
    background: none;
    border: 0;
    color: var(--ink);
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: color 0.3s ease;
  }

  /* Standing on a dark banner, the button is white and carries no paper: a
     coin of paper on a picture is a hole punched in it. Once the banner has
     gone past, the ground is paper and a figure as often as not, so it goes
     back to ink with its own wash under it. .is-past is set in ui.js off the
     same measurement that raises the back-to-top link, so the two corners
     change at the same moment. Open, it is on the sheet's own paper and takes
     ink again whatever is behind it.

     Two ways a banner can be dark, and the button owes the same answer to
     both. A hero draws its own scene and every one of them is dark, which is
     why every hero's title is white. A cover banner is a photograph, and says
     which way it is inked with `bannerInk` in frontmatter — so [data-ink] is
     the same fact stated by the other kind of banner. Keyed to both rather
     than to .has-hero alone, which left BuBeGooMoo — a white title over a dark
     photograph — as the one work whose menu button was black on the picture
     with a paper coin behind it. The condition is the title's colour, not how
     the banner was built. */
  .work-banner:is(.has-hero, [data-ink="light"]) ~ .wnav-btn:not(.is-past):not([aria-expanded="true"]) {
    color: #fff;
    /* Three hairlines of white have no weight of their own to hold them up.
       The MIDI maker's path is a pale ground and the record's field is a mid
       green; a shadow the width of the rule itself is what keeps the mark on
       both without putting a plate behind it. */
    filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.45));
  }

  .work-banner:is(.has-hero, [data-ink="light"]) ~ .wnav-btn:not(.is-past):not([aria-expanded="true"])::before {
    opacity: 0;
  }

  /* Three of the four banners are dark and one is cream, and the button is
     over all of them. It carries a little paper, soft-edged the way the
     back-to-top link's is: over the paper below the fold it is the colour of
     the page and cannot be seen at all, over a hero it wicks out like ink
     rather than sitting there as a plate. */
  .wnav-btn::before {
    content: "";
    position: absolute;
    inset: 2px;
    z-index: -1;
    border-radius: 50%;
    background: radial-gradient(closest-side, var(--paper) 34%, transparent);
    transition: opacity 0.3s ease;
  }

  .wnav-lines {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 5px;
    width: 20px;
  }

  .wnav-lines i {
    display: block;
    height: 1.5px;
    width: 100%;
    background: currentColor;
    transition: transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1),
                opacity 0.16s ease;
  }

  /* The three lines fold into the close mark, rather than being swapped for
     one: the middle rule goes and the outer two meet in the space it leaves.
     6.5px is the gap plus a rule — how far each has to travel to the centre. */
  .wnav-btn[aria-expanded="true"] .wnav-lines i:nth-child(1) {
    transform: translateY(6.5px) rotate(45deg);
  }
  .wnav-btn[aria-expanded="true"] .wnav-lines i:nth-child(2) { opacity: 0; }
  .wnav-btn[aria-expanded="true"] .wnav-lines i:nth-child(3) {
    transform: translateY(-6.5px) rotate(-45deg);
  }

  /* Paper over the whole screen rather than a panel at one edge: the case
     study underneath is a long document, and half of one showing past a drawer
     is a page that looks like it is still being read. */
  .wnav-sheet {
    position: fixed;
    inset: 0;
    z-index: 40;
    display: flex;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--paper);
    animation: wnav-in 0.26s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }

  .wnav-inner {
    display: flex;
    flex-direction: column;
    gap: 1.1rem;
    width: 100%;
    /* The mascot stands in the corner opposite the button, on the same line as
       it, so the sheet opens at the page's own inset rather than below the
       height of a control. */
    padding: var(--pad) var(--pad) calc(var(--pad) + 1.5rem);
  }

  /* Sized to sit level with the close mark across the row: the button is 44px
     of target around a 20px mark, so the mascot is inset by the difference
     rather than being hung off the top of the sheet. */
  .wnav-home {
    align-self: flex-start;
    margin: 7px 0;
    color: var(--ink);
  }

  .wnav-home svg { display: block; }

  .wnav-group {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
  }

  /* The name of a group, not a label on the page: this is the home page's own
     navigation, so it is set the way the home page sets it — General Sans at
     medium, at the same --fs-body the .toggle rows use. The serif belongs to
     the case study's structure, and the menu is not part of the case study. */
  .wnav-label {
    font-size: var(--fs-body);
    font-weight: 500;
    line-height: 1.35;
    color: var(--ink);
  }

  .wnav-group ul {
    list-style: none;
    display: flex;
    flex-direction: column;
    margin: 0;
  }

  /* The rows are set at the label's size, not above it: this is the home
     page's list, and there the group name and the rows under it are the same
     size. A row larger than the name it sits under reads as a heading with a
     caption over it rather than as one list. */
  .wnav-group li {
    font-size: var(--fs-body);
    line-height: 1.35;
  }

  /* Enough room around a row to hit it and no more: at 0.45rem either side of
     a 12px line the rows are 30px apart, which reads as a list rather than as
     a stack of buttons with air between them. */
  .wnav-group :is(a, .is-here) {
    display: block;
    padding: 0.45rem 0;
    color: var(--ink);
  }

  /* The piece being read keeps its row and loses its link. Marked in the
     margin the row would indent into if it were one. */
  .wnav-group .is-here {
    position: relative;
    padding-left: 14px;
    font-weight: 500;
  }

  .wnav-group .is-here::before {
    content: "";
    position: absolute;
    left: 0;
    top: 50%;
    width: 8px;
    height: 1px;
    background: currentColor;
    opacity: 0.45;
  }
}

/* It arrives the way everything else on this page does — a short rise out of
   nothing, not a panel sliding in from an edge. */
@keyframes wnav-in {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .wnav-sheet { animation-duration: 0.01ms; }
  .wnav-lines i,
  .wnav-btn,
  .wnav-btn::before { transition-duration: 0.01ms; }
}

/* The sheet has the screen while it is open, so the document under it stops
   being scrollable — Lenis is told to stand down separately, in ui.js, since
   the wheel on this page is its rather than the browser's. */
html[data-wnav-open], html[data-wnav-open] body { overflow: hidden; }

/* The one flower a case study gets, beside the notice it signs. Sized here
   and nowhere else: flowers.js reads this box to set its backing store and the
   bloom's radius, so the number below is the only one to change. */
.foot-sign {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.foot-bloom {
  display: block;
  width: 62px;
  height: 62px;
  /* It is drawn as it is scrolled to, and there is nothing to see before then.
     Fixed dimensions rather than intrinsic ones, so reserving the space costs
     no layout shift when the ink lands. */
  flex: none;
}

/* The dwell count, in the corner the clock holds on the short pages. Same size,
   same colour, same frame around the digits — it is the same line, counting a
   different thing, and should not announce itself as a new one. */
.dwell {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.dwell-label { color: var(--ink-faint); }
.dwell .clock-time { color: var(--ink); }

/* Nothing to say until the first tick lands. */
.dwell:not([data-ready]) { visibility: hidden; }

/* The footer stays one line on a phone. Stacked, it turned the end of a long
   read into three rows of small print — and the third of them, the ways to
   reach me, is already in the menu at the top of the same screen. So the
   socials go and what is left is what the line is for: the notice, and how
   long you have been here. The bloom comes down with them; at 62px it is
   nearly half the height of the row it signs. */
@media (max-width: 700px) {
  .work-foot { flex-wrap: nowrap; gap: 1rem; }
  .work-foot .foot-links { display: none; }
  .foot-bloom { width: 40px; height: 40px; }
}

/* On the narrowest handsets the row still has to be a row: a notice that puts
   "Zhao" on a line of its own is a stack in all but name. The line comes down
   a point and the bloom with it, which is enough to hold both ends of it at
   320px — the narrowest screen anyone is still reading on. */
@media (max-width: 400px) {
  .work-foot { font-size: 11px; gap: 0.75rem; }
  .foot-bloom { width: 32px; height: 32px; }
}

/* ── content reveal ─────────────────────────────────────────────────────── */
/* A case study is far too long to arrive all at once the way the home page
   does, so each block resolves as it reaches the viewport instead: up, and out
   of a blur. Same curve and the same short distance as the entrance above, so
   the two read as one piece of motion rather than two.

   Grid wrappers are skipped in favour of their children — revealing the cells
   of a 2x2 of screen recordings one after another is the point of doing this
   at all, and blurring a parent would blur them a second time.

   The whole thing hangs off [data-reveal], which only exists once the inline
   script in the head has run. */
html[data-reveal] :where(
  .work-banner-copy,
  .work-lede,
  .work-tagline,
  .work-foot,
  .work-content .prose > :not(.figs, .cols),
  .work-content .prose :is(.figs, .cols) > *
) {
  opacity: 0;
  transform: translateY(10px);
  filter: blur(6px);
  transition:
    opacity 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s),
    transform 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s),
    filter 0.6s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s);
}

html[data-reveal] .is-in {
  opacity: 1;
  transform: none;
  filter: blur(0);
}

/* A filter leaves a compositing layer behind it, and a page carrying six
   playing videos does not want fifty of them. Once a block has finished
   arriving it stops being filtered at all. */
html[data-reveal] .is-revealed { filter: none; }

@media (prefers-reduced-motion: reduce) {
  /* Something still marks the arrival, but nothing travels or defocuses. */
  html[data-reveal] :where(
    .work-banner-copy,
    .work-lede,
    .work-tagline,
    .work-foot,
    .work-content .prose > :not(.figs, .cols),
    .work-content .prose :is(.figs, .cols) > *
  ) {
    transform: none;
    filter: none;
    transition: opacity 0.2s ease var(--reveal-delay, 0s);
  }
}

/* ── the Hack the North banner ──────────────────────────────────────────── */
/* A banner that names hero: vinyl gets HTN's field instead of a cover image:
   the ground as a gradient, bushes and flowers bottom-anchored into it, the
   record player standing in it, and leaf shadows swaying over the lot.

   Positions inside the record are percentages of the artwork's own 682x591
   box — taken from the constants in HTN's component, where the layout was
   tuned at 550px wide — so the parts stay registered at any size. */

/* What a hero's banner sits on before its own artwork has arrived, and behind
   it wherever the artwork does not reach. Per hero, because each one is built
   on different ground: the record stands in a green field, the MIDI maker on a
   sandy path. */
.work-banner[data-hero="vinyl"] {
  background: linear-gradient(180deg, #8cab21 0%, #cfe67f 43%, #8cab21 81%);
}

.work-banner[data-hero="midi"] {
  background: linear-gradient(180deg, #5d7d1f 0%, #86ab2d 55%, #9cbe3a 100%);
}

/* Sky, deepest at the top the way it actually is, so the banner is already
   the right colour behind the photograph while it loads. */
.work-banner[data-hero="directory"] {
  background: linear-gradient(180deg, #12669e 0%, #4c9bc9 55%, #a8cfe5 100%);
}

/* The title sits over the scene rather than in it. How it is set is shared
   with every other banner and lives with .work-title; all a hero adds is
   lifting the copy clear of the scene it drew underneath. */
.work-banner.has-hero .work-banner-copy {
  position: relative;
  z-index: 5;
}

/* The title takes its colour from the ground under it, which is the hero's to
   decide: white on the record's green field, ink on the MIDI maker's path. */
.work-banner[data-hero="vinyl"] .work-title { color: #fff; }

/* The field runs under the title here as well, so this one is white too. */
.work-banner[data-hero="midi"] .work-title { color: #fff; }

/* The title sits in the top corner, which is the darkest part of the sky.
   Nothing stands in that corner here — the window starts a quarter of the way
   down — so the line is let out to run across the sky rather than being held
   to the measure that keeps HTN's sentence off the record. */
.work-banner[data-hero="directory"] .work-title {
  color: #fff;
  max-width: 40rem;
}

/* The darkest banner of the four, and the globe is centred, so the corner the
   title sits in is very nearly black. White, and let out like the directory's:
   there is nothing in the top-left for it to run into. */
.work-banner[data-hero="descartes"] .work-title {
  color: #fff;
  max-width: 40rem;
}

/* ── stars from home ────────────────────────────────────────────────────── */
/* Sky and marks, in that order. The banner's own background carries the colour
   before the photograph arrives, so a slow connection gets night rather than
   the cream every other banner sits on. */
.work-banner[data-hero="stars"] { background: #05060f; }

.stars-scene {
  position: absolute;
  inset: 0;
}

.stars-sky {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Held to a share of the frame rather than a pixel size, so the emblems keep
   their place in the picture as the banner's clamp changes its height. The
   plate carries its own glow as transparent margin, which is why this is wider
   than the artwork inside it looks. */
.stars-marks {
  position: absolute;
  left: 50%;
  top: 13%;
  /* The Figma stands the marks in a 745px box at 1512 (node 171:7419). The
     plate is wider than that box because its glow is baked in as transparent
     margin — 745 ÷ (2578/2796) is the 53.4% below, and the same margin is why
     the top is 13% rather than the box's own 15.7%. */
  width: 53.4%;
  height: auto;
  transform: translateX(-50%);
}

/* The sky is night, so the title is the one thing on it that is not. */
.work-banner[data-hero="stars"] .work-title {
  color: #fff;
  max-width: 40rem;
}

@media (max-width: 760px) {
  /* At phone width the copy runs most of the frame, so the emblems drop below
     it rather than behind it. */
  .stars-marks { top: 24%; width: 72%; }
}

/* ── designing for systems @ descartes ──────────────────────────────────── */
/* Descartes' own earth loop, filling the frame, with a veil over it so the
   title in the corner has ground under it wherever the globe's glow drifts. */

/* The one banner of the four you put a whole hand on rather than one finger,
   so the whole frame is the handle and the cursor says so. pan-y keeps a
   vertical swipe on a phone as a page scroll — only the sideways one is ours. */
.desc-scene {
  position: absolute;
  inset: 0;
  background: #000209;
  cursor: grab;
  touch-action: pan-y;
}

.desc-scene.is-dragging { cursor: grabbing; }

/* Cut a little larger than the frame, for the same reason the directory's sky
   is: it leans, and without the margin the lean would drag the video's own
   edge into view. 3% is 45px of slack sideways at the width the page is
   designed at, against a lean that can spend 9. */
.desc-globe {
  position: absolute;
  top: -3%;
  left: -3%;
  display: block;
  width: 106%;
  height: 106%;
  object-fit: cover;
  translate: calc(var(--mx, 0) * -9px) calc(var(--my, 0) * -6px);
  will-change: translate;
  /* The frame is the handle, so the two layers inside it stay out of the way
     of the pointer and let every event land on the scene itself. */
  pointer-events: none;
  user-select: none;
  -webkit-user-drag: none;
}

/* Darkest in the top-left corner where the title stands, gone by the middle of
   the frame so the globe is never dimmed — it is the picture, and a veil laid
   evenly over the whole banner would only make it duller. */
.desc-veil {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    108deg,
    rgba(0, 2, 9, 0.82) 0%,
    rgba(0, 2, 9, 0.5) 22%,
    rgba(0, 2, 9, 0) 46%
  );
}

/* A globe has no handle on it, and nothing else in this frame says it can be
   touched — the vinyl at least has an arm to make glow. So the banner says so
   in words, in the corner the credit sits in on HTN's, and stops saying it the
   moment the reader has taken hold. */
.desc-hint {
  position: absolute;
  z-index: 6;
  right: var(--pad);
  bottom: var(--pad);
  margin: 0;
  font-size: var(--fs-eyebrow);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.62);
  pointer-events: none;
  transition: opacity 0.5s ease;
}

.work-banner.has-touched .desc-hint { opacity: 0; }

/* The globe comes up out of a blur and settles, the same arrival the body's
   blocks make further down the page and a beat behind the title — the title is
   what the banner is saying and the earth is what it is saying it over. One
   animation rather than three transitions, so the blur cannot resolve while the
   frame is still at opacity 0. Behind [data-reveal], so without scripting the
   banner is simply there. */
html[data-reveal] .desc-globe {
  animation: desc-globe-in 1.1s cubic-bezier(0.22, 0.61, 0.36, 1) 0.25s both;
}

@keyframes desc-globe-in {
  from { opacity: 0; scale: 1.06; filter: blur(14px); }
  to   { opacity: 1; scale: 1;    filter: blur(0); }
}

/* Arrived: it stops carrying a filter layer. blur(0) is not free — an element
   composited through a filter is rasterised at layer size and then scaled to
   the frame, which on a video being drawn larger than its own box is a real
   softening, not a theoretical one. The directory's window drops its filter
   for the same reason. The end state has to be written out, because dropping
   the animation would otherwise drop its fill along with it. */
html[data-reveal] .desc-globe.is-shown {
  animation: none;
  opacity: 1;
  scale: none;
  filter: none;
}

/* ── the hacker directory ───────────────────────────────────────────────── */
/* Two layers and no behaviour: the sky filling the frame, and the product
   standing on it. */

.dir-scene {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Cut larger than the frame on every side, because it is the one layer that
   moves: without the margin, a lean of a few pixels would drag the photograph's
   own edge into view. 4% leaves 60px of slack sideways and 20px vertically at
   the width the page is designed at — more than the two movements below can
   ever spend between them.

   The lean is `translate` and the drift is `transform`, which are separate
   properties that compose, so the two can be authored independently rather than
   one having to carry the other. */
.dir-sky {
  position: absolute;
  top: -4%;
  left: -4%;
  display: block;
  width: 108%;
  height: 108%;
  object-fit: cover;
  translate: calc(var(--mx, 0) * -10px) calc(var(--my, 0) * -7px);
  animation: sky-drift 80s ease-in-out infinite alternate;
  will-change: transform, translate;
}

/* Weather, at the speed weather actually moves. Eighty seconds end to end and
   barely a percent of travel: not something to catch happening, just enough
   that the sky is never twice in the same place. This one is not a pointer
   effect, so it is also what keeps the banner alive on a touch screen, where
   there is no hover to lean on. */
@keyframes sky-drift {
  from { transform: translate3d(-1.2%, 0, 0); }
  to   { transform: translate3d(1.2%, 0, 0); }
}

/* The window is larger than the banner on purpose. It starts below the title
   and runs off the bottom edge, so what you get is a crop of a screen rather
   than a screenshot set inside a box — the same trick the record's field
   plays by running off three sides.

   Placed as a share of the frame so it keeps the mockup's proportions at any
   width, and capped so a very wide viewport gets more sky instead of a window
   so large only its title bar is left. Centred from the middle rather than
   pinned to a left offset, so it stays centred once that cap is what decides
   the width. The width is set here rather than inferred from a second offset:
   the image inside is a replaced element, and `width: auto` on one takes the
   file's own pixels and quietly ignores `right`. */
.dir-window {
  position: absolute;
  top: 27.5%;
  left: 50%;
  transform: translateX(-50%);
  width: 91.9%;
  max-width: 1800px;
  /* The frame owns the crop, the corners and the shadow, so the picture inside
     is free to travel without its rounded top sliding off or its edges painting
     over the sky above. */
  overflow: hidden;
  border-radius: 18px 18px 0 0;
  box-shadow: 0 24px 60px rgba(8, 46, 76, 0.28);
  /* The scene is transparent to the pointer; this one square is not, because it
     is the thing you scroll. Events still bubble to the banner, so the sky goes
     on leaning with the cursor over it. */
  pointer-events: auto;
}

/* --dir-pan is how far ui.js has wound the screenshot up inside its frame.
   It is `translate` rather than `transform` so it composes with the arrival
   below instead of overwriting it. */
.dir-screen {
  display: block;
  width: 100%;
  height: auto;
  /* --dir-x is the standing crop the narrow layout sets and never changes;
     --dir-pan is what the wheel winds. Both live on `translate` so the two
     compose, and so neither collides with the arrival's `transform`. */
  translate: var(--dir-x, 0) var(--dir-pan, 0px);
  -webkit-user-drag: none;
}

/* The picture elements are two-source wrappers, not boxes: the layout is the
   image's, exactly as it was before they were introduced. */
.dir-scene picture { display: contents; }

/* The sky is painted first and the window set down on it after, a beat behind
   the title — the sentence is what the banner is saying and the screenshot is
   the evidence for it, so they should not land at once. It travels a little and
   comes out of a blur, the same arrival the body's blocks make further down the
   page.

   One animation rather than three transitions: as separate transitions the blur
   is a shorter property than the fade, so it resolved while the window was still
   at opacity 0 and what you actually saw was a sharp thing fading in. Keyframes
   hold all three to one clock. Behind [data-reveal] so that without scripting it
   is simply there. */
html[data-reveal] .dir-window { opacity: 0; }

html[data-reveal] .dir-window.is-ready {
  animation: dir-window-in 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) 0.35s both;
}

@keyframes dir-window-in {
  from { opacity: 0; translate: 0 12px; filter: blur(10px); }
  to   { opacity: 1; translate: 0 0;    filter: blur(0); }
}

/* Arrived: it stops carrying a filter layer, which otherwise sits under a sky
   that never stops drifting. The end state has to be written out, because
   dropping the animation would otherwise drop its fill with it. */
html[data-reveal] .dir-window.is-shown {
  animation: none;
  opacity: 1;
  translate: none;
  filter: none;
}

.htn-scene,
.htn-leaves {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.htn-scene { z-index: 0; }

/* Above the record, not just the field. A shadow that fell on the grass but
   stopped at the player would read as a sticker on top of a photograph. */
.htn-leaves { z-index: 4; }

.htn-scene img {
  position: absolute;
  display: block;
  -webkit-user-drag: none;
}

/* Anchored to the floor and running off three edges, so the field reads as a
   crop of somewhere larger rather than a picture set inside a box. */
.htn-bush.is-left  { left: -6%;  bottom: -38%; width: 39%; }
.htn-bush.is-right { right: -5%; bottom: -44%; width: 35%; }

/* Loose sprigs filling the bare stretches of field. Small, and inside the
   scene layer, so anything the record covers stays covered. */
.htn-sprig.is-a { right: 21%; top: 34%;    width: 4.6%; }
.htn-sprig.is-b { right: 29%; bottom: 20%; width: 3.8%; }
.htn-sprig.is-c { left: 29%;  top: 46%;    width: 4%; }
.htn-sprig.is-d { right: 13%; top: 62%;    width: 4.9%; }

/* ── parallax ───────────────────────────────────────────────────────────── */
/* ui.js puts the pointer's position in the frame on the banner as --mx/--my,
   each running -1 to 1 from edge to edge. Every layer reads the same pair and
   scales it by its own distance: the shadows overhead shift most, the bushes
   on the ground least, and the ground itself not at all. The record is left
   out on purpose — it is the one thing here you are meant to grab, and a
   target that drifts away from the cursor is a worse target. */

.htn-bush,
.htn-sprig,
.htn-leaves { will-change: transform; }

.htn-bush {
  transform: translate3d(calc(var(--mx, 0) * -7px), calc(var(--my, 0) * -5px), 0);
}

.htn-sprig {
  transform: translate3d(calc(var(--mx, 0) * -10px), calc(var(--my, 0) * -7px), 0);
}

.htn-leaves {
  transform: translate3d(calc(var(--mx, 0) * -19px), calc(var(--my, 0) * -13px), 0);
}

/* Hung from the top edge, not the floor: the artwork is cropped along its own
   top, so that edge has to sit outside the frame to not read as a cut. It is
   the one layer that does not drift with the pointer — being the furthest
   thing back, it is the frame everything else moves against. */
.htn-flowers {
  left: 50%;
  top: -4%;
  width: 60%;
  transform: translateX(-50%);
}

/* The credit for the code the record is a port of. General Sans rather than
   the serif: it is a note in the corner, not part of the scene's own voice.
   Above every layer, so no leaf shadow ever crosses it. */
/* The credit a hero owes for code that is not mine, in the corner of the
   banner it belongs to. Shared by both heroes — the record and the MIDI maker
   were each ported from something someone else wrote. */
.hero-thanks {
  position: absolute;
  z-index: 6;
  right: var(--pad);
  bottom: var(--pad);
  max-width: 24rem;
  font-size: 12px;
  line-height: 1.4;
  text-align: right;
  color: #fff;
}

/* The name in it is a link, and says so on approach rather than at rest: a
   credit should read as a sentence first. */
.hero-thanks a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: rgba(255, 255, 255, 0.4);
  text-underline-offset: 2px;
  transition: text-decoration-color 0.15s ease;
  pointer-events: auto;
}

.hero-thanks a:is(:hover, :focus-visible) { text-decoration-color: #fff; }

/* ── leaf shadows ───────────────────────────────────────────────────────── */
/* The sway is HTN's, angles and timings unchanged: a long slow arc that leans
   further one way than the other, so it reads as wind rather than a metronome.
   Hanging from the top edge is what makes it look like branches out of frame. */

.htn-leaf {
  position: absolute;
  transform-origin: top center;
  animation: leaf-sway 16s ease-in-out infinite;
  will-change: transform;
}

.htn-leaf img {
  display: block;
  width: 100%;
  height: auto;
  opacity: 0.3;
  mix-blend-mode: multiply;
  -webkit-user-drag: none;
}

/* All four hug the edges. Nothing hangs over the middle of the frame, which
   is where the title and the record have to stay legible. */
.htn-leaf:nth-child(1) { left: -9%;  top: -32%; width: 29%; }
.htn-leaf:nth-child(2) { left: 5%;   top: -42%; width: 23%; }
.htn-leaf:nth-child(3) { right: 5%;  top: -38%; width: 25%; }
.htn-leaf:nth-child(4) { right: -9%; top: -30%; width: 30%; }

/* Same three-way offset the original uses, so no two neighbours agree. */
.htn-leaf:nth-child(2n) { animation-delay: -5s; animation-duration: 23s; }
.htn-leaf:nth-child(3n) { animation-delay: -10s; animation-direction: reverse; }

@keyframes leaf-sway {
  0%, 100% { transform: rotate(0deg); }
  30%      { transform: rotate(-10deg); }
  52%      { transform: rotate(2deg); }
  70%      { transform: rotate(10deg); }
}

/* Narrower frames get the shallower arc, or the same rotation throws the leaf
   clean out of the picture. */
@keyframes leaf-sway-narrow {
  0%, 100% { transform: rotate(0deg); }
  30%      { transform: rotate(-7deg); }
  52%      { transform: rotate(1.4deg); }
  70%      { transform: rotate(7deg); }
}

/* ── the record ─────────────────────────────────────────────────────────── */

.vinyl {
  position: absolute;
  z-index: 2;
  left: 50%;
  bottom: 7%;
  /* 400px at the banner's own 510, and a share of it on either side: on a wide
     window the frame grows and the record grows with it rather than shrinking
     into a corner of its own field. 31vw still holds it off the sides. */
  width: min(31vw, calc(var(--banner-h) * 0.784));
  aspect-ratio: 682 / 591;
  transform: translateX(-50%);
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
}

/* The field is painted first and the record is set down on it after: it waits
   for the scenery to finish loading, then fades up out of a blur. Arriving
   together, the record lands on bare gradient and the scene assembles in the
   wrong order. Behind [data-reveal] so that without scripting it is simply
   there, like everything else that hides itself to arrive. */
html[data-reveal] .vinyl {
  opacity: 0;
  filter: blur(8px);
  transition:
    opacity 0.7s cubic-bezier(0.22, 0.61, 0.36, 1),
    filter 0.7s cubic-bezier(0.22, 0.61, 0.36, 1);
}

html[data-reveal] .vinyl.is-ready { opacity: 1; filter: blur(0); }

/* Arrived: it stops carrying a filter layer, which otherwise sits under a
   disk that never stops turning. */
html[data-reveal] .vinyl.is-shown { filter: none; }

.vinyl img {
  display: block;
  width: 100%;
  height: auto;
  -webkit-user-drag: none;
  pointer-events: none;
}

.vinyl-body {
  position: relative;
  z-index: 1;
}

/* 380 across, placed at 50,30 — and always turning, with the animation itself
   paused until the arm comes down. */
.vinyl-disk {
  position: absolute;
  z-index: 2;
  left: 9.0909%;
  top: 6.2944%;
  width: 69.0909%;
  transform-origin: 50% 50%;
  pointer-events: none;
  animation: vinyl-spin 6000ms linear infinite;
  animation-play-state: paused;
}

.vinyl.is-playing .vinyl-disk { animation-play-state: running; }

@keyframes vinyl-spin {
  to { transform: rotate(360deg); }
}

/* The arm pivots at its mount, not its centre. */
.vinyl-stick {
  position: absolute;
  z-index: 6;
  right: 14.9091%;
  top: 14.687%;
  width: 29.5%;
  padding: 0;
  border: 0;
  background: none;
  transform: rotate(var(--angle, -20deg));
  transform-origin: 93% 4.5%;
  cursor: grab;
  touch-action: none;
}

.vinyl-stick:active { cursor: grabbing; }
.vinyl-stick:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }

/* Only while it is settling. During a drag the arm tracks the pointer. */
.vinyl:not(.is-dragging) .vinyl-stick {
  transition: transform 0.38s cubic-bezier(0.33, 1, 0.68, 1);
}

/* Until it has been touched once, the arm says quietly that it can be. */
.vinyl:not(.is-touched) .vinyl-stick img {
  animation: vinyl-hint 2.6s ease-in-out infinite;
}

@keyframes vinyl-hint {
  0%, 100% { filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); }
  50%      { filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.9)); }
}

/* Travels between 75 and 155 up from the base. */
.vinyl-volume {
  position: absolute;
  z-index: 7;
  right: 10.5455%;
  width: 7.2727%;
  bottom: calc(15.7361% + var(--volume, 0.75) * 16.7852%);
  cursor: ns-resize;
  touch-action: none;
}

.vinyl-volume:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }

/* The track is not this site's to license, so the credit travels with it. */
.vinyl-credit {
  position: absolute;
  z-index: 8;
  right: 21.8182%;
  bottom: 7.3435%;
  font-size: 10px;
  line-height: 1.2;
  letter-spacing: 0.06em;
  text-align: right;
  color: rgb(175, 18, 14);
  text-decoration: underline;
  text-decoration-color: rgba(175, 18, 14, 0.5);
  text-underline-offset: 0.2em;
}

/* Darker still on hover: the body behind it is bright, so going down the
   scale reads more clearly than going up. */
.vinyl-credit:hover {
  color: rgb(138, 12, 9);
  text-decoration-color: rgb(138, 12, 9);
}

@media (prefers-reduced-motion: reduce) {
  /* The record still plays; it just does not turn, the leaves hold still, and
     the arm stops asking to be touched. */
  .vinyl-disk { animation: none; }
  .vinyl:not(.is-touched) .vinyl-stick img { animation: none; }
  .vinyl:not(.is-dragging) .vinyl-stick { transition: none; }
  .htn-leaf { animation: none; transform: none; }
  .htn-bush, .htn-sprig, .htn-leaves { transform: none; }
  html[data-reveal] .vinyl { filter: none; transition: opacity 0.2s ease; }

  /* The weather stops. The pointer lean is already off — ui.js does not bind
     the parallax at all under this setting — so this is the drift alone. */
  .dir-sky { animation: none; transform: none; }

  /* The window still waits its turn, it just does not travel or defocus. */
  html[data-reveal] .dir-window.is-ready {
    animation: none;
    opacity: 1;
    transition: opacity 0.2s ease 0.35s;
  }

  /* The earth holds still — the observer in ui.js never starts the loop under
     this setting, so the banner is the poster frame — and it arrives by fading
     rather than by travelling out of a blur. */
  html[data-reveal] .desc-globe {
    animation: desc-globe-fade 0.4s ease 0.2s both;
  }

  @keyframes desc-globe-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

@media (max-width: 900px) {
  /* The record moves under the title and the banner grows to hold both. */
  .work-banner.has-hero { --banner-h: 420px; }

  /* Centred in the frame rather than standing at the bottom of it: with the
     flowers gone the field is empty above the record, and a record sitting low
     in an empty frame reads as having slipped. */
  .vinyl {
    top: 50%;
    bottom: auto;
    width: min(300px, 68vw);
    transform: translate(-50%, -50%);
  }

  .vinyl-credit { font-size: 7px; }
  .hero-thanks { font-size: 10px; max-width: 16rem; }

  /* Too narrow to hold a whole dashboard and still read it. The frame keeps
     its corner on the sky, and the picture pans inside it instead: the
     sidebar — a rail of nav labels, the least of what this screen is — winds
     off the left edge, and the phone is filled by the profile itself at a
     size its words survive. Panning it off the left would open a gap at the
     right, so the picture is set wider than the frame by the same amount it
     travels: 18.5% is the sidebar's share of its width, so both numbers are
     that one measurement. Being shares of the frame rather than pixels, the
     crop is the same picture on a 320px phone as on a 900px tablet. */
  .dir-window {
    top: 30%;
    left: 5%;
    transform: none;
    width: 150%;
    border-radius: 14px 14px 0 0;
  }

  .dir-screen { width: 133.3%; --dir-x: -18.5%; }

  .htn-leaf { animation-name: leaf-sway-narrow; }
  .htn-bush.is-left  { width: 54%; bottom: -30%; }
  .htn-bush.is-right { width: 50%; bottom: -34%; }
  /* The white flowers hang from the top edge, which is exactly where the
     title stands once the copy has the frame to itself — white on white. The
     bank is worth less than the sentence, so it goes. */
  .htn-scene .htn-flowers { display: none; }
  .htn-sprig.is-a { width: 9%; }
  .htn-sprig.is-b { width: 8%; }
  .htn-sprig.is-c { width: 8.5%; }
  .htn-sprig.is-d { width: 9.5%; }
}

/* ── the garden, where it is not wanted ─────────────────────────────────── */
/* The canvas always stays in the DOM: flowers.js holds this exact node for the
   whole visit. Case studies just fade it out, so it is still growing when the
   visitor comes back. */

#garden { transition: opacity 0.45s ease; }
html[data-garden="off"] #garden { opacity: 0; }

/* ── scrolling ──────────────────────────────────────────────────────────── */

/* The no-JS / reduced-motion path. Lenis drives scrolling itself when it is
   running, and sets .lenis-smooth to turn this off so the two do not fight. */
html { scroll-behavior: smooth; }
html.lenis { scroll-behavior: auto; }
html.lenis, html.lenis body { height: auto; }
html.lenis.lenis-stopped { overflow: hidden; }

/* The router resets scroll position on navigation, and an unqualified
   scrollTo() inherits the CSS behaviour — which would animate every page
   change as a slow glide to the top. Suppressed for the duration of a swap. */
html.is-navigating { scroll-behavior: auto; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ── about ──────────────────────────────────────────────────────────────── */
/* The one dark page. Its own ground and ink rather than the site's paper, so
   the tokens are redefined on the page instead of overriding rule by rule. */

.about {
  --on-ground: #f2f0e8;
  --on-ground-soft: #8f8f8f;

  position: relative;
  z-index: 1;
  min-height: 100svh;
  padding: var(--pad);
  color: var(--on-ground);
}

.about-mark {
  display: block;
  margin-bottom: 0.875rem;
  /* Shrink to the mark. As a plain block it filled the page width, and the
     transition was morphing that box into the homepage's 32px one — which is
     what read as a zoom. */
  width: fit-content;
  color: var(--on-ground);
  margin-bottom: 0.5rem;
}

/* The mark is the same 32px in the same place on both pages, so there is
   nothing to move: hold the group still and cross-fade the two snapshots, and
   the mascot simply changes colour rather than travelling or scaling. */
::view-transition-group(mascot) {
  animation: none;
}

::view-transition-old(mascot),
::view-transition-new(mascot) {
  animation-duration: 0.4s;
  animation-timing-function: ease;
  /* Both snapshots occupy the same box, so neither needs to be scaled to fit. */
  height: 100%;
  width: 100%;
  object-fit: fill;
}

/* Two 380px columns under the mark, split by the page inset, both starting on
   the same line. They wrap to a stack when there is no room for the pair. */
.about-grid {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--pad);
}

/* A basis rather than a fixed width, so the pair keeps sitting side by side
   into the high 700s instead of snapping to one column and leaving half the
   page empty. Capped at 380 so the copy never runs to an unreadable measure. */
.about-copy,
.about-side {
  flex: 1 1 320px;
  max-width: 380px;
}

/* One column. The panel is pulled up to sit directly under the copy: left in
   source order it lands below the experience list, which on a phone puts an
   opened note or artifact several hundred pixels below the word or the frame
   that opened it — far enough off screen that tapping looks like it did
   nothing at all.

   Selected through the parent rather than by class alone: both panels are
   defined further down this file, and matching their specificity would leave
   these rules losing to source order. */
@media (max-width: 760px) {
  .about-grid { flex-direction: column; }

  /* The two panels part company here. A marked word is in the copy, so its note
     follows the copy. An artifact is in the row at the foot of the page, so its
     write-up goes last — directly above the archive it was opened from, rather
     than the length of the experience list away from it. */
  .about-grid > .about-copy   { order: 1; }
  .about-grid > .about-gloss  { order: 2; }
  .about-grid > .about-side   { order: 3; }
  .about-grid > .about-detail { order: 4; }

  /* flex-basis is the main axis, and the main axis here is vertical: the 320px
     basis these carry for the two-column layout becomes a 320px minimum
     *height* once stacked, padding the page out with dead space between every
     block. Back to sizing on content. */
  .about-grid > .about-copy,
  .about-grid > .about-side,
  .about-grid > .about-detail,
  .about-grid > .about-gloss {
    flex: 0 0 auto;
    /* Full bleed inside the page inset. One column means every block shares
       the same two edges, and the page reads as one measure rather than a
       narrow ribbon with dead space down one side. */
    width: 100%;
    max-width: 100%;
  }

  /* The note carries its own reading measure for the narrow column it normally
     sits in; stacked, that cap is the one thing left not reaching the same
     edge as everything above it. Selected through the panel because .marginalia
     is defined further down this file. */
  .about-grid > .about-gloss .marginalia { max-width: 100%; }
}

/* The list and the note stack in the right-hand column. */
.about-side {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* A query container, so the row below stacks on its own width rather than on
   the viewport's — the columns fill the screen now, and the dates have far more
   room than a viewport breakpoint would guess. */
.cv {
  width: 100%;
  container-type: inline-size;
}

/* Sits on the same line as the two columns, held to the far edge. */
.about-note {
  color: #CBFFA3;
  font-size: var(--fs-body);
  line-height: 1.5;
  text-align: right;
  text-wrap: pretty;
}

/* The one line on the home page that is an announcement rather than copy. It
   reads in the column's own ink so the invitation sits with the copy above it
   rather than announcing itself in a second colour. */
.col p.open-note { color: var(--ink); }
.col .open-note a { color: inherit; }

/* About's hover reveal, on the home page's line. It holds its place in the
   layout at all times and only fades in, so revealing it cannot push the
   groups below it down the column. */
.open-note-cta {
  display: block;
  font-style: italic;
  opacity: 0;
  transform: translateY(-3px);
  transition: opacity 0.24s ease,
              transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.open-note a:hover .open-note-cta,
.open-note a:focus-visible .open-note-cta {
  opacity: 1;
  transform: none;
}

/* The invitation stays in the layout at all times and only fades in, so
   revealing it cannot nudge the line above or reflow the row. */
.about-note-cta {
  display: block;
  font-style: italic;
  opacity: 0;
  transform: translateY(-3px);
  transition: opacity 0.24s ease,
              transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.about-note:hover .about-note-cta,
.about-note:focus-visible .about-note-cta {
  opacity: 1;
  transform: none;
}

.about-copy p {
  font-size: var(--fs-body);
  line-height: 1.5;
  text-wrap: pretty;
}

.about-copy p + p { margin-top: 0.875rem; }

.about-copy em { font-style: italic; }

/* ── experience list ────────────────────────────────────────────────────── */

.cv {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.cv-row { display: block; }

/* The whole row is the hit area, logo included — a 12px company name is a
   mean target, and the row already reads as one object. `div` for a row with
   no site set, so the layout is the same either way. */
.cv-line {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  /* The row sits flush in the list; the ring needs air to read as focus rather
     than as a box that was always there. */
  border-radius: 6px;
  outline-offset: 4px;
}

/* Only a row that goes somewhere responds. The name is what brightens, since
   the name is what the label under the cursor is about to repeat — the role
   and the dates stay where they are so the list keeps its shape. */
a.cv-line { cursor: pointer; }

a.cv-line .cv-org { transition: color 0.2s ease; }
a.cv-line:hover .cv-org,
a.cv-line:focus-visible .cv-org { color: #fff; }

a.cv-line .cv-logo { transition: opacity 0.2s ease; }
a.cv-line:hover .cv-logo,
a.cv-line:focus-visible .cv-logo { opacity: 0.82; }

.cv-logo {
  flex: none;
  width: 32px;
  height: 32px;
  margin-top: 3px;
  border-radius: 6px;
  object-fit: contain;
}

/* Company left, dates hard right on the same line, title beneath — the dates
   form their own column down the list, which is what makes it scan as a table
   rather than a paragraph. The markup is in reading order (company, title,
   dates) and the grid lifts the dates up a row, so there is nothing to undo
   when it collapses to one column. */
.cv-body {
  flex: 1;
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "org  when"
    "role role";
  align-items: baseline;
  column-gap: 1rem;
}

.cv-org {
  grid-area: org;
  font-size: 0.75rem;
  font-weight: 500;
}

.cv-when {
  grid-area: when;
  justify-self: end;
  font-size: 0.75rem;
  color: var(--on-ground-soft);
}

.cv-role {
  grid-area: role;
  font-size: 0.75rem;
  color: var(--on-ground-soft);
}

/* White on the dark ground — the same off-white and near-black as the cursor
   label, so a run of selected text reads as the page's own chip rather than as
   a third colour. The pink wash used on paper all but disappears against
   #171717, which is what these two pages are. */
.about ::selection,
.play ::selection {
  background: #f2f0e8;
  color: #171717;
}

/* The dates ride the right edge until the longest row cannot hold them on one
   line, and only then do the three fall into the order the markup already has
   them in. 270px is that point, measured: the widest pair on the list is
   "PRISM Collective" + "June 2026 – Present" at 221px, plus the logo and its
   gap. The whole list turns together at one width rather than row by row —
   a mixed list stops scanning as a table. */
@container (max-width: 270px) {
  .cv-body {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "org" "role" "when";
    row-gap: 0.1rem;
  }

  .cv-when { justify-self: start; }
}

/* ── the label that follows the cursor ──────────────────────────────────── */
/* Over a row that goes somewhere, the company's name rides just off the
   pointer with the external-link mark beside it. Two things at once: which of
   the seven rows is actually under the cursor, and that the click leaves the
   site — neither of which the row itself can say without growing furniture it
   does not need the rest of the time.

   Fixed and positioned by transform from the top left corner, because ui.js
   feeds it viewport coordinates every frame and a transform is the one way to
   move something per frame without asking for layout back. `pointer-events`
   off so it never becomes the thing the pointer is over, which would put it in
   a loop of leaving the row it is following. */
.cv-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.375rem 0.625rem;

  background: #f2f0e8;
  color: #171717;
  border-radius: 4px;

  /* The page is #171717 and the label is nearly white, so the shadow is doing
     separation rather than depth: enough that the label sits above the list
     instead of being cut out of it. */
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);

  pointer-events: none;
  opacity: 0;

  /* Position and entrance are kept in two different properties on purpose.
     `translate` is the pointer position, rewritten every frame by ui.js and
     never transitioned — a transition there would smear the label a beat
     behind the cursor, the one thing it must not do. `transform` is the
     entrance, and is free to be eased because it is only ever the small offset
     the label glides in from. The two are separate properties and compose, so
     neither has to know about the other.

     It comes in from the cursor's side and settles outward: a short glide, no
     scale, nothing to bounce. ui.js sets the direction, since the label flips
     to the other side of the pointer near an edge and the glide has to flip
     with it — otherwise it would arrive travelling *into* the cursor. */
  translate: var(--tip-x, 0) var(--tip-y, 0);
  transform: translateX(var(--tip-in, -10px));

  /* Leaving is not the interesting half: it fades where it stands, quickly,
     and does not glide back — the return trip would draw the eye to a row the
     pointer has already left. */
  transition: opacity 0.12s ease, transform 0s linear 0.12s;
}

.cv-tip.is-on {
  opacity: 1;
  transform: none;

  /* The site's one easing curve, taken slow — the curve dumps most of the
     travel early and spends the rest of its time easing off, so a longer
     duration reads as the label coming to rest rather than as a slower
     journey. The opacity finishes a little inside the glide, so it is legible
     just before it stops rather than arriving and then still moving. */
  transition: opacity 0.28s ease,
              transform 0.44s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.cv-tip-name {
  font-size: 0.6875rem;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* No timing of its own: the mark fades on the label's own opacity, so name and
   mark arrive as one thing. */
.cv-tip-icon {
  flex: none;
  width: 12px;
  height: 12px;
}

@media (prefers-reduced-motion: reduce) {
  /* The label still fades, since something has to mark its arrival, but it
     arrives where it means to sit rather than gliding there. */
  .cv-tip,
  .cv-tip.is-on { transform: none; transition: opacity 0.12s ease; }
}

/* ── archive ────────────────────────────────────────────────────────────── */
/* A row of artifacts across the full page width. Each frame keeps its own
   aspect ratio and they all hang from a common top line, so the row reads as a
   set of things laid out on a surface rather than a grid of equal cells.

   Hovering one writes its note beside its number. Clicking opens it in the
   empty column at the top of the page — see .about-detail below. */

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

.archive { margin-top: 3rem; }

.artifacts {
  list-style: none;
  display: grid;
  /* A set number of columns rather than auto-fill: the row is meant to read as
     eight artifacts across, and auto-fill would quietly add a ninth on a wide
     display and drop to seven on a slightly narrow one. The count steps down
     at each width instead, which keeps every artifact between roughly 160 and
     200px wide the whole way down. */
  grid-template-columns: repeat(8, minmax(0, 1fr));
  column-gap: 1rem;
  row-gap: 2.5rem;
  /* Frames of different heights hang from the top of their row instead of
     being stretched to match the tallest one. */
  align-items: start;
}

/* Each step is set where the one above it would push an artifact under about
   150px, which is the width where the frames stop being able to show anything
   and the row turns into a strip of stamps. That keeps every artifact roughly
   150–210px wide the whole way down instead of, say, eight 129px slivers at
   1201px or three 113px ones at 430. */
@media (max-width: 1379px) { .artifacts { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
@media (max-width: 1159px) { .artifacts { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
@media (max-width: 939px)  { .artifacts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 719px)  { .artifacts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
/* Straight from three to one. Two columns on a phone puts each artifact at
   about 150px, which is small enough that the picture stops being the point —
   at one column it takes the same full width as the copy above it. */
@media (max-width: 539px) {
  .artifacts { grid-template-columns: minmax(0, 1fr); }

  /* The two reserved lines exist to keep the frames in a row hanging from one
     line. At a single column each frame is its own row, so the reserve is only
     a blank line above every picture. */
  .artifacts .artifact-line { min-height: 0; }
}

.artifact {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  outline-offset: 4px;
}

/* Number and note share one line above the frame. The note is always in the
   layout and only fades, so revealing it cannot shift the frame beneath it. */
.artifact-line {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  min-height: 2.1rem;          /* two lines of the note — see .artifact-note */
  font-size: 0.75rem;
  line-height: 1.4;
}

.artifact-no {
  flex: none;
  color: var(--on-ground);
  font-variant-numeric: tabular-nums;
}

.artifact-note {
  min-width: 0;
  color: var(--on-ground-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transform: translateX(-3px);
  transition: opacity 0.24s ease,
              transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* The pending slot has nothing to hover, so its note is simply shown. */
.artifact-note.is-static { opacity: 1; transform: none; }

/* A column this narrow cannot hold a note on one line, and clipping it is worse
   than wrapping it. The two lines are reserved on every cell rather than only
   the ones that use both, so the frames in a row still hang from one line. */
.artifact-note {
  white-space: normal;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

.artifact:hover .artifact-note,
.artifact:focus-visible .artifact-note,
.artifact.is-open .artifact-note { opacity: 1; transform: none; }

.artifact.is-open .artifact-note { color: var(--on-ground); }

/* The column fixes the width; the file fixes the height. Deliberately no
   aspect-ratio here — a CSS one would outrank the real image forever, and the
   varied heights that come from letting each artifact be its own shape are the
   whole point of the row. The pre-load reserve is done with the media's
   width/height attributes instead, which the browser treats as a default it
   gives up once it knows the real proportions. */
.artifact-frame {
  display: block;
  position: relative;
  width: 100%;
  overflow: hidden;
  background: rgba(242, 240, 232, 0.04);
  transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Nothing inside to give these a height, so they take the authored one. */
.artifact-frame.is-empty,
.artifact-frame.is-pending { aspect-ratio: var(--ratio, 4 / 3); }

/* Nothing filed here yet, so it is drawn rather than filled. A 2px dash both
   reads at this size and gives longer dashes — browsers scale the dash pattern
   off the border width — where a hairline broke up into dots. */
.artifact-frame.is-pending {
  background: none;
  border: 2px dashed rgba(242, 240, 232, 0.35);
}

/* Held back from the artifacts either side of it: it is a note about the
   archive, not an item in it. */
.artifact-cell.is-pending .artifact-no { color: var(--on-ground-soft); }

/* How the cells arrive — up and out of a blur, once their picture has decoded
   — is shared with the playground's wall and lives with it, under "the picture
   grids arrive". */

.artifact-media {
  width: 100%;
  height: auto;
  display: block;
}

/* The lift rides the frame, not the image inside it. The frame clips its
   contents, so moving the image up within it would only open a strip of frame
   background along the bottom edge — the whole artifact has to travel. */
.artifact:hover .artifact-frame,
.artifact:focus-visible .artifact-frame { transform: translateY(-4px); }

/* A row with no file yet: an empty frame rather than a broken image, so the
   archive can be filled in a few artifacts at a time. */
.artifact-empty {
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    45deg,
    rgba(242, 240, 232, 0.05) 0 1px,
    transparent 1px 9px
  );
}

/* ── the opened artifact ────────────────────────────────────────────────── */
/* Third column of the top row, empty until something is opened. The media sits
   at a column's width with the write-up beside it, both hanging off the same
   baseline as the copy and the experience list. */

.about-detail {
  /* A basis wide enough to hold the artifact and its writing side by side. Any
     less and the panel drops to its own full-width row rather than being
     squeezed into the leftover space beside two 380px columns — which at around
     1150 had shrunk the opened artifact to 119px, narrower than the thumbnail
     it was opened from. */
  flex: 1 1 520px;
  min-width: 0;
  display: grid;
  /* The artifact yields to the writing rather than holding a fixed width. Its
     240px is a ceiling, not a size, and the note's 200px floor is what takes it
     back: the panel is at its narrowest right where all three columns just fit
     across (around 1280), and a fixed image there left 76px of column for the
     note and ran it to twelve lines. */
  grid-template-columns: minmax(0, 240px) minmax(200px, 1fr);
  grid-template-areas:
    "no    ."
    "media note";
  column-gap: 1.5rem;
  row-gap: 0.5rem;
  animation: detailIn 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

/* The layout above is display:grid, which would otherwise beat the attribute. */
.about-detail[hidden] { display: none; }

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

/* Going away is the entrance run backwards and shorter — a dismissal should not
   take as long to read as an arrival, which is a thing you are meant to look
   at. Both users of the slot share it: whichever one is showing, closing it
   should feel like the same gesture. ui.js adds the class, then clears the
   panel once the fade is done rather than before it — emptying it first would
   leave an empty box fading, which is what this is here to avoid. */
.about-detail.is-closing,
.about-gloss.is-closing {
  animation: detailOut 0.22s cubic-bezier(0.4, 0, 1, 1) both;
}

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

.detail-no {
  grid-area: no;
  font-size: 0.75rem;
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
}

.detail-slot { grid-area: media; display: block; }

/* The frame is cloned out of the row, so it arrives sized for a column of the
   archive — which is not the size it wants once it is the thing being looked
   at. */
/* Bounded on both sides rather than given a size: max-width keeps it inside its
   column, max-height stops a tall portrait running the panel far past the
   experience list beside it, and auto on both dimensions means the artifact
   keeps its own proportions whichever of the two it meets first. */
.detail-slot .artifact-frame {
  width: max-content;
  max-width: 100%;
}

.detail-slot .artifact-frame.is-empty { width: 100%; }

.detail-slot .artifact-media {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 420px;
}

/* Sits on the artifact's bottom edge rather than its top, so the eye lands on
   the image first and the writing reads as a caption to it. */
.detail-note {
  grid-area: note;
  align-self: end;
  /* Only bites on a wide display, where the panel is roomy enough to run the
     line past a comfortable measure. */
  max-width: 62ch;
  font-size: 0.75rem;
  line-height: 1.5;
  text-wrap: pretty;
}

@media (max-width: 900px) {
  /* No room for a column beside the artifact; the write-up goes under it. */
  .about-detail {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "no" "media" "note";
  }
  /* Stacked, the write-up has the panel's full width to itself and shares its
     edges with the artifact above it. The reading measure the wide layout caps
     it to would leave it stopping short of everything else on the page. */
  .detail-note {
    align-self: start;
    max-width: 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .artifact-note,
  .artifact-frame { transition-duration: 0.01ms; }
  .artifact:hover .artifact-frame,
  .artifact:focus-visible .artifact-frame { transform: none; }
  .about-detail { animation-duration: 0.01ms; }
}

/* ── marginalia ─────────────────────────────────────────────────────────── */
/* Marked words in the copy open a note in the same clear space at the top right
   that an opened artifact uses. One place on the page where things expand. */

/* A span rather than a button, deliberately. A real <button> will not fragment
   across lines whatever its display is set to: a marked phrase longer than one
   line becomes a full-width block sitting in the middle of the paragraph. A
   span breaks like the words either side of it, which is the entire point of
   annotating running copy — so it takes the role and the key handling instead.
   See the keydown handler in ui.js. */
.gloss {
  cursor: pointer;
  /* Nudged off the text: the ring is drawn tight to an inline box, which on a
     phrase inside a paragraph reads as a border rather than as focus. */
  border-radius: 2px;
  outline-offset: 3px;
  /* Dashed rather than solid: the site's links are solid, and this does not go
     anywhere — it unfolds in place. Dashed rather than dotted because at 12px
     on this ground a dotted rule dissolves into the background and the word
     stops looking touchable at all, which is the one thing it has to do. */
  text-decoration: underline dashed;
  text-decoration-color: rgba(242, 240, 232, 0.7);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: text-decoration-color 0.24s ease,
              text-decoration-thickness 0.24s ease;
}

.gloss:hover,
.gloss:focus-visible,
.gloss[aria-pressed="true"] {
  text-decoration-color: var(--on-ground);
  text-decoration-thickness: 1.5px;
}

.gloss-mark {
  margin-left: 0.15em;
  font-size: 0.6em;
  vertical-align: super;
  line-height: 0;
  color: rgba(242, 240, 232, 0.8);
  font-variant-numeric: tabular-nums;
}

.gloss[aria-pressed="true"] .gloss-mark { color: var(--on-ground); }

/* Same idea, but a note is one column of text and needs far less room than an
   artifact beside its write-up. */
.about-gloss {
  flex: 1 1 360px;
  min-width: 0;
  animation: detailIn 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

.about-gloss[hidden] { display: none; }

/* The rule down the left is the whole idea: it says margin note rather than
   body copy, which is what keeps this from reading as a second column of the
   essay it is annotating. */
.marginalia {
  max-width: 46ch;
  padding-left: 1rem;
  border-left: 1px solid rgba(242, 240, 232, 0.22);
  font-size: 0.75rem;
  line-height: 1.5;
}

.marginalia-term {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  color: var(--on-ground);
}

.marginalia-mark {
  flex: none;
  color: var(--on-ground-soft);
  font-variant-numeric: tabular-nums;
}

.marginalia-note {
  color: var(--on-ground-soft);
  text-wrap: pretty;
}

.marginalia-quote {
  margin-top: 0.875rem;
  font-style: italic;
  text-wrap: pretty;
  /* Hangs the opening quote mark into the gutter, so the first line of the
     quotation starts on the same vertical as the note above it. */
  text-indent: -0.4em;
}

.marginalia-quote cite {
  display: block;
  margin-top: 0.375rem;
  font-style: normal;
  text-indent: 0;
  color: var(--on-ground-soft);
}

.marginalia-quote cite::before { content: "— "; }

@media (prefers-reduced-motion: reduce) {
  .gloss { transition-duration: 0.01ms; }
  .about-gloss { animation-duration: 0.01ms; }
}

/* ── the archive on a phone ─────────────────────────────────────────────── */
/* There is no hover to reveal a caption with and no room beside the copy for a
   write-up to open into, so the caption is simply always on and the artifact
   stops pretending to be a control. The tiles are taken out of the tab order in
   ui.js to match — a button that does nothing should not be reachable. */

@media (max-width: 560px) {
  .artifact-note {
    opacity: 1;
    transform: none;
  }

  .artifact { cursor: default; }

  .artifact:hover .artifact-frame,
  .artifact:focus-visible .artifact-frame { transform: none; }
}

/* ── playground ─────────────────────────────────────────────────────────── */
/* The second dark page. Same ground and ink as About, because they are the two
   pages that are not the paper — but where About is a column of writing with a
   catalogue under it, this is a wall and nothing else. The one control on it
   re-sorts that wall rather than filtering it: every tile is always present,
   and the sort only changes what standing next to what means. */

.play {
  --on-ground: #f2f0e8;
  --on-ground-soft: #8f8f8f;

  position: relative;
  z-index: 1;
  min-height: 100svh;
  padding: var(--pad);
  color: var(--on-ground);
}

.play-mark {
  display: block;
  /* Shrink to the mark, so the view transition from About's has nothing to
     scale — see the ::view-transition-group(mascot) rule above. */
  width: fit-content;
  margin-bottom: 0.5rem;
  color: var(--on-ground);
}

/* The lede and the sort share a line where there is room for both, with the
   sort held to the far edge. They stack in that order when there is not. */
.play-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem var(--pad);
  margin-bottom: 2.5rem;
}

.play-lede {
  flex: 1 1 320px;
  max-width: 420px;
  /* The site's prose size — the same --fs-body/1.5 About, the home page and
     the case studies set their copy in, and the same strength. This page is
     not a different publication. */
  font-size: var(--fs-body);
  line-height: 1.5;
  color: var(--on-ground);
  text-wrap: pretty;
}

/* ── the wall ───────────────────────────────────────── */
/* Justified rows, not a grid. A grid gives every row the height of its tallest
   tile, and with shapes running from 2/3 to 16/9 that leaves a hole under every
   short one — the wall reads as broken rather than as things laid on a surface.
   Flex solves it exactly, and the arithmetic is worth knowing because it is why
   nothing here is cropped:

     basis = ar × row      grow = ar

   A row’s leftover space is handed out in proportion to grow, so every tile
   ends up at ar × (row + leftover/Σar) wide — proportional to its own aspect
   ratio, with the same multiplier as its neighbours. Which is to say every tile
   in a row lands on exactly the same height, whatever shapes went into it, and
   each one is still its own shape. The rows justify themselves.

   It also needs no breakpoints. Where a tile’s basis is wider than the page it
   is simply alone on its row at full width — the phone layout, arrived at by
   the same rule as every other width rather than by a step written for it. */

.wall {
  /* The height a row would be if it had exactly enough to go round. Rows come
     out near this rather than on it: the leftover is what justifies them. */
  --row: clamp(150px, 19vw, 260px);

  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 2.25rem 1rem;
}

/* The ×10 on the grow factor is not decoration. Flexbox distributes only as
   much of a row’s free space as the grow factors sum to when that sum is under
   1 — so a lone portrait, grow 0.75, took three quarters of its row and left
   the last quarter empty. Grow factors are relative, so scaling every one of
   them changes no proportion; it only lifts the sum clear of that threshold. */
.cell {
  flex: calc(var(--ar, 1.333) * 10) 1 calc(var(--ar, 1.333) * var(--row));
  min-width: 0;
}

/* Absorbs whatever is left of the last row, so three tiles at the end are not
   blown up to four tiles’ width. Zero basis, so it never causes a wrap of its
   own — it only ever sits at the end of the row the last tile landed on.

   The order is the whole reason this works. ui.js writes an order of 0, 1, 2 …
   onto the cells when it sorts, and an order it did not write is 0 — which put
   this level with the first cell rather than after the last, so it swallowed
   the free space of the *first* row and left that row unjustified. It has to
   sit past anything the sort can assign. */
.wall::after {
  content: "";
  order: 9999;
  flex: 9999 1 0;
}

/* Both shapes the tile takes — <a> for somewhere to go, <button> for something
   to open — are drawn the same. Which one it is, is the cursor label's job to
   say, not the tile's. */
.tile {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  outline-offset: 4px;
}

/* The column fixes the width; the file fixes the height. Deliberately no
   aspect-ratio on a filled frame — a CSS one would outrank the real file
   forever, and letting each tile be its own shape is what gives the wall its
   rhythm. The pre-load reserve is done with the media's width/height
   attributes instead, which the browser treats as a default it gives up the
   moment it knows the real proportions. */
/* Unlike the archive’s, this frame carries a real CSS aspect-ratio. It has to:
   a justified row cannot work out its own height without knowing every tile’s
   proportions up front, and waiting for the files to arrive would mean the wall
   re-laying itself under the reader as each one landed. So `ratio` is the
   authority here rather than the hint it is on the archive, and the object-fit
   below covers for a row that was measured slightly wrong. */
.tile-frame {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: var(--ratio, 4 / 3);
  overflow: hidden;
  background: rgba(242, 240, 232, 0.04);
  transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.tile-media {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The same lift the archive's frames take, at the same distance and on the
   same curve — the site has one hover for a picture in a grid, not two. It
   rides the frame rather than the media inside it: the frame clips its
   contents, so moving the picture up within it would only open a strip of
   frame background along the bottom edge. Full colour at rest either way. The
   work is the reason to look at the wall, and hover is a lift, not a reveal. */
.tile:hover .tile-frame,
.tile:focus-visible .tile-frame { transform: translateY(-4px); }

/* The caption sits under the frame and is always on, unlike the archive's. The
   archive has numbers to scan by and this has none — a wall of captions that
   only appear one at a time would be a wall you cannot read without visiting
   every tile in it. It is set at the site's prose size, the same as the lede
   above it and as the copy on About, and at full strength: a caption you have
   to hover to read properly is not doing the job it is there for. */
.tile-line {
  display: flex;
  align-items: baseline;
  gap: 0.375rem;
  margin-top: 0.5rem;
  font-size: 0.75rem;
  line-height: 1.5;
}

.tile-note {
  min-width: 0;
  color: var(--on-ground);
  /* Two lines at most. A column this narrow cannot hold every caption on one
     line, and wrapping to three would push the tiles below it out of step. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

/* Marks the tiles that leave the site, so the wall can be read without a
   cursor on it — the cursor label says the same thing, but only to whoever is
   already pointing at that one tile. */
.tile-out {
  flex: none;
  width: 10px;
  height: 10px;
  transform: translateY(1px);
  color: var(--on-ground-soft);
  transition: color 0.24s ease;
}

.tile:hover .tile-out,
.tile:focus-visible .tile-out { color: var(--on-ground); }

/* A row with no file yet: an empty frame rather than a broken image, so the
   wall can be filled a few entries at a time. Same hatch as the archive's. */
.tile-empty {
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    45deg,
    rgba(242, 240, 232, 0.05) 0 1px,
    transparent 1px 9px
  );
}

/* The cursor label over a tile that opens in place rather than leaving. Same
   label, minus the mark that means "this goes somewhere else" — which is the
   only part of it that would be a lie here. */
.cv-tip.is-here .cv-tip-icon { display: none; }

/* The case study footer, on the two dark pages. The rules up at .work-foot are
   the shape of it and stay as they are; these are only what it is made of here.
   Three differences, all of them the dark ground's:

   - the ink. --ink-faint and --ink are paper's two strengths and neither can
     be read off #171717, so the row takes the page's own pair instead.
   - the rule. A hairline of ink on paper becomes a hairline of paper on ink,
     at the same weight the wall's other divisions are drawn at.
   - the width. A case study is a column and the footer finishes under it; the
     wall and About both run to both edges, so the footer does too, and its
     ends line up with what is above rather than sitting inside it. The page's
     own padding closes the bottom.

   One block for both pages: they are the same container — the same padding on
   the same dark ground — so a copy per page would only be two things to keep
   in step. */
.play .work-foot,
.about .work-foot {
  max-width: none;
  margin-top: 4rem;
  padding: var(--pad) 0 0;
  border-top: 1px solid rgba(242, 240, 232, 0.09);
  color: var(--on-ground-soft);
}

.play .work-foot a,
.about .work-foot a { color: var(--on-ground); }

/* What the wash behind the footer's type is made of here — the dark ground,
   for the same reason .to-top's is below: a wash is only invisible if it is
   the colour of what it is laid on. */
.play .work-foot,
.about .work-foot { --foot-wash: var(--ground); }

.play .dwell-label,
.about .dwell-label { color: var(--on-ground-soft); }

.play .dwell .clock-time,
.about .dwell .clock-time { color: var(--on-ground); }

/* The same link on the two dark pages, in their colours. The works version
   above is the shape; this is only what it is made of — faint ink at rest,
   full strength on hover, and the soft wash behind it carrying the dark ground
   instead of paper so it stays invisible over the page and readable over a
   tile. */
.play .to-top,
.about .to-top { color: var(--on-ground-soft); }

.play .to-top:hover,
.about .to-top:hover { color: var(--on-ground); }

.play .to-top::before,
.about .to-top::before {
  background: radial-gradient(closest-side, var(--ground) 60%, transparent);
}

/* About asks for it only once it is one column — the same 760px the grid
   stacks at, so it appears exactly when the page becomes the long single
   ribbon that needs it. */
.to-top.is-small-only { display: none; }

@media (max-width: 760px) {
  .to-top.is-small-only { display: flex; }
}

/* ── the picture grids arrive ───────────────────────────────────────────── */
/* A grid whose tiles pop in one at a time as each file lands reads as a page
   still being built. So the cells start held back and are let in deliberately,
   in the order they are laid out — which is the order they were made in — once
   the pictures behind them have actually decoded. ui.js does the waiting.

   Both of the site's picture grids arrive this way: the playground's wall, and
   the archive on About. One rule rather than a copy per page, so the two
   cannot drift into being almost the same motion.

   Same curve, distance and blur as the case-study reveal above: this is the
   site's one piece of arrival motion, not a second one that happens to look
   similar. Behind [data-reveal], so with no JavaScript a grid is simply there
   rather than permanently invisible. */
html[data-reveal] :is(.wall .cell, .artifacts .artifact-cell) {
  opacity: 0;
  transform: translateY(10px);
  filter: blur(6px);
  transition:
    opacity 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s),
    transform 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s),
    filter 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0s);
}

html[data-reveal] :is(.wall .cell, .artifacts .artifact-cell).is-in {
  opacity: 1;
  transform: none;
  filter: blur(0);
}

/* A blur holds a compositing layer open, and the playground is already running
   six videos. Once a tile has arrived it stops being filtered at all. */
html[data-reveal] :is(.wall .cell, .artifacts .artifact-cell).is-revealed { filter: none; }

@media (prefers-reduced-motion: reduce) {
  /* The tiles still arrive in order — that is the point of the sequence — but
     nothing travels or defocuses to do it. */
  html[data-reveal] :is(.wall .cell, .artifacts .artifact-cell) {
    transform: none;
    filter: none;
    transition: opacity 0.2s ease var(--reveal-delay, 0s);
  }
}

/* ── the lightbox ─────────────────────────────────────────────────────── */
/* Where a tile with no link opens. A real <dialog>: the focus trap, the inert
   background and Escape are the platform's. */

.lightbox {
  width: min(1100px, calc(100vw - var(--pad) * 2));
  max-width: none;
  max-height: calc(100svh - var(--pad) * 2);
  margin: auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--on-ground);
  overflow: visible;
}

.lightbox::backdrop {
  background: rgba(12, 12, 12, 0.88);
  /* Nothing behind it is meant to be read, but the shape of the wall showing
     through is what says you are still on the same page. */
  backdrop-filter: blur(6px);
}

/* Both the panel and the ground it sits on arrive together rather than the
   panel sliding onto an already-dark page — one movement, not two. */
.lightbox[open],
.lightbox[open]::backdrop { animation: lightbox-in 0.28s ease both; }

@keyframes lightbox-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.lightbox[open] .lightbox-figure {
  animation: lightbox-rise 0.34s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

@keyframes lightbox-rise {
  from { transform: translateY(8px); }
  to   { transform: none; }
}

.lightbox-figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
}

/* The media is capped by the shorter of the two axes rather than given a box to
   fit inside, so a tall file and a wide one are both shown whole and neither is
   letterboxed into the other's shape. The 5rem is the caption and the breathing
   room under it. */
.lightbox-slot :is(img, video) {
  display: block;
  max-width: 100%;
  max-height: calc(100svh - var(--pad) * 2 - 5rem);
  width: auto;
  height: auto;
  object-fit: contain;
}

.lightbox-note {
  font-size: 0.75rem;
  line-height: 1.4;
  color: var(--on-ground-soft);
  text-align: center;
}

/* The three controls share one drawing. They sit on the backdrop rather than on
   the media, so they are legible whatever is open behind them. */
.lightbox-close,
.lightbox-sound,
.lightbox-step {
  position: fixed;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(242, 240, 232, 0.08);
  color: var(--on-ground);
  cursor: pointer;
  opacity: 0.65;
  transition: opacity 0.2s ease, background-color 0.2s ease;
}

.lightbox-close:hover,
.lightbox-sound:hover,
.lightbox-step:hover,
.lightbox-close:focus-visible,
.lightbox-sound:focus-visible,
.lightbox-step:focus-visible {
  opacity: 1;
  background: rgba(242, 240, 232, 0.16);
}

.lightbox-close svg,
.lightbox-sound svg,
.lightbox-step svg { width: 16px; height: 16px; }

.lightbox-close { top: var(--pad); right: var(--pad); }

/* Beside the close button, and only there for a clip with sound. The two icons
   live in the one button and swap on `aria-pressed`, so the state the button
   reports and the state it draws cannot come apart. */
.lightbox-sound { top: var(--pad); right: calc(var(--pad) + 52px); }

/* `display` above outranks the browser's own rule for [hidden], so stepping
   from a clip with sound to one without would leave the button behind. */
.lightbox-sound[hidden] { display: none; }

.lightbox-sound[aria-pressed='false'] { opacity: 0.85; }
.lightbox-sound .sound-on,
.lightbox-sound[aria-pressed='false'] .sound-off { display: block; }
.lightbox-sound .sound-off,
.lightbox-sound[aria-pressed='false'] .sound-on { display: none; }
.lightbox-step  { top: 50%; translate: 0 -50%; }
.lightbox-step.is-prev { left: var(--pad); }
.lightbox-step.is-next { right: var(--pad); }

/* One tile in the wall means there is nowhere to step to. ui.js sets this
   rather than the buttons being removed, so the dialog's markup is the same
   whatever is in it. */
.lightbox.is-alone .lightbox-step { display: none; }

/* No room beside the media for the arrows, so they come down to the foot of the
   screen and sit either side of the close button. */
@media (max-width: 719px) {
  .lightbox-step { top: auto; bottom: var(--pad); translate: none; }
  .lightbox-step.is-prev { left: var(--pad); }
  .lightbox-step.is-next { left: calc(var(--pad) + 52px); right: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .lightbox[open],
  .lightbox[open]::backdrop,
  .lightbox[open] .lightbox-figure { animation-duration: 0.01ms; }

  .tile-frame { transition-duration: 0.01ms; }
  .tile:hover .tile-frame,
  .tile:focus-visible .tile-frame { transform: none; }
}

/* ── the wall on a phone ──────────────────────────────────────────────── */
/* No hover, so nothing may depend on it: the medium tag is simply on, and the
   caption is already always on. Videos are played by the observer in ui.js
   when they come into view rather than by a pointer that does not exist. */

@media (hover: none) {
}

/* ── writing ──────────────────────────────────────────────────────────────── */
/* The reading half of the site. Same ground and the same ink as About, on
   purpose: the homepage is paper you look at, and this is a room you read in.
   Both pages set --ink* as well as --on-ground*, which is what lets every
   .prose rule written for paper further up this file land correctly on the
   dark ground without being written a second time. */

.writing,
.piece-page {
  --on-ground: #f2f0e8;
  --on-ground-soft: #8f8f8f;

  /* Body copy on #171717 wants to sit nearer the ink than the meta does:
     #8f8f8f is right for a label and too dim to read three paragraphs of. */
  --ink: var(--on-ground);
  --ink-soft: #cbc9c0;
  --ink-faint: var(--on-ground-soft);

  position: relative;
  z-index: 1;
  min-height: 100svh;
  padding: var(--pad);
  color: var(--on-ground);
}

/* The cyan the About page uses. The pink wash all but disappears here. */
.writing ::selection,
.piece-page ::selection {
  background: rgba(55, 190, 228, 0.32);
  color: #e8fbff;
}

/* ── the index ────────────────────────────────────────────────────────────── */

.writing-head {
  max-width: 34rem;
  margin: 2.5rem 0 2rem;
}

.writing-title {
  font-size: 1rem;
  font-weight: 500;
  margin-bottom: 0.5rem;
}

.writing-lede {
  font-size: 0.92rem;
  line-height: 1.5;
  color: var(--ink-soft);
  text-wrap: pretty;
}

/* Filters. They only ever narrow an already-complete list, so they are a row of
   quiet pills rather than anything that looks like navigation. */
.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 2rem;
}

.chip {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  padding: 0.28rem 0.7rem;
  border: 1px solid rgba(242, 240, 232, 0.18);
  border-radius: 999px;
  background: none;
  font: inherit;
  font-size: 0.75rem;
  color: var(--on-ground-soft);
  cursor: pointer;
  transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}

.chip:hover { color: var(--on-ground); border-color: rgba(242, 240, 232, 0.4); }
.chip:focus-visible { outline: 1.5px solid var(--on-ground); outline-offset: 3px; }

.chip.is-on {
  color: #171717;
  background: var(--on-ground);
  border-color: var(--on-ground);
}

.chip-n {
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
}

/* A year is a rule with a number on it — the one piece of structure in a list
   that is otherwise deliberately flat. Capped, because a row spread across a
   large display puts its date the better part of a metre from its title. */
.year {
  max-width: 52rem;
  margin-bottom: 2rem;
}

.year[hidden] { display: none; }

.year-mark {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 0.25rem;
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--on-ground-soft);
  font-variant-numeric: tabular-nums;
}

.year-mark::after {
  content: "";
  flex: 1;
  height: 1px;
  background: rgba(242, 240, 232, 0.14);
}

.pieces { list-style: none; }

.piece { border-bottom: 1px solid rgba(242, 240, 232, 0.09); }
.piece[hidden] { display: none; }

/* Every kind is the same row. What changes is the word in the left margin and
   which way the arrow at the end points — not the shape of the thing, so the
   list reads as one list rather than four lists interleaved. */
.piece-row {
  display: grid;
  grid-template-columns: 5rem minmax(0, 1fr) auto 1.25rem;
  gap: 0.25rem 1rem;
  align-items: baseline;
  width: 100%;
  padding: 0.85rem 0;
  border: 0;
  background: none;
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
  transition: font-weight var(--ink-settle);
}

.piece-row:focus-visible { outline: 1.5px solid var(--on-ground); outline-offset: 2px; }

.piece-kind {
  font-size: 0.7rem;
  color: var(--on-ground-soft);
  transition: color 0.2s ease;
}

.piece-main { min-width: 0; }

.piece-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem;
}

.piece-title {
  font-size: 0.92rem;
  transition: font-weight var(--ink-settle);
}

.piece-author,
.piece-draft {
  font-size: 0.72rem;
  color: var(--on-ground-soft);
}

.piece-draft {
  padding: 0.05rem 0.35rem;
  border: 1px solid rgba(242, 240, 232, 0.22);
  border-radius: 2px;
}

.piece-blurb {
  display: block;
  margin-top: 0.2rem;
  font-size: 0.8rem;
  line-height: 1.45;
  color: var(--on-ground-soft);
  text-wrap: pretty;
}

.piece-date {
  font-size: 0.72rem;
  color: var(--on-ground-soft);
  font-variant-numeric: tabular-nums;
}

.piece-go {
  font-size: 0.8rem;
  color: var(--on-ground-soft);
  justify-self: end;
  transition: color 0.2s ease, transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.piece-row:hover .piece-title { font-weight: calc(400 + var(--ink-gain)); }
.piece-row:hover .piece-kind,
.piece-row:hover .piece-go { color: var(--on-ground); }
.piece-row:hover .piece-go { transform: translateX(2px); }

/* The arrow is the state: it points down at a closed row and up at an open one,
   so the only thing that turns is the thing that said which way this goes. */
.piece-row[aria-expanded="true"] .piece-go,
.piece-row[aria-expanded="true"]:hover .piece-go { transform: rotate(180deg); }

/* Opened in place. Indented to the title's column so it reads as belonging to
   the row above it rather than as the next row along. */
.piece-open {
  padding: 0 1.25rem 1.1rem calc(5rem + 1rem);
  animation: pieceOpen 0.3s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

.piece-open[hidden] { display: none; }

@keyframes pieceOpen {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}

.piece-open .prose { max-width: 46rem; }

.piece-open .prose p {
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--ink-soft);
}

.writing-empty,
.writing-none {
  font-size: 0.85rem;
  color: var(--on-ground-soft);
  padding: 1.5rem 0;
}

.writing-none[hidden] { display: none; }

.writing-foot { margin-top: 2.5rem; }

/* One column. The margin label goes above the title rather than beside it, and
   the date joins it there — at this width a four-column row is four columns of
   about nine characters each. */
@media (max-width: 700px) {
  .piece-row {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "kind date"
      "main main";
    row-gap: 0.35rem;
  }

  .piece-kind { grid-area: kind; }
  .piece-date { grid-area: date; }
  .piece-main { grid-area: main; }
  .piece-go   { display: none; }

  .piece-open { padding-left: 0; padding-right: 0; }
}

/* ── one piece ────────────────────────────────────────────────────────────── */

.piece-shell {
  display: grid;
  grid-template-columns: 180px minmax(0, 62ch) minmax(0, 260px);
  gap: clamp(1.5rem, 4vw, 3.5rem);
  align-items: start;
  max-width: 1400px;
  margin: 0 auto;
  padding-top: 1.5rem;
}

.piece-rail {
  position: sticky;
  top: var(--pad);
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

.piece-toc { list-style: none; margin: 0; }

.piece-header { margin-bottom: 2rem; }

.piece-stamp {
  display: flex;
  gap: 0.6rem;
  margin-bottom: 0.6rem;
  font-size: 0.72rem;
  color: var(--on-ground-soft);
  font-variant-numeric: tabular-nums;
}

.piece-h1 {
  font-size: 1.5rem;
  font-weight: 500;
  line-height: 1.2;
  text-wrap: balance;
}

.piece-lede {
  margin-top: 0.75rem;
  font-size: 0.92rem;
  line-height: 1.5;
  color: var(--on-ground-soft);
  text-wrap: pretty;
}

.piece-prose p {
  font-size: 0.92rem;
  line-height: 1.65;
  color: var(--ink-soft);
}

.piece-prose p + p { margin-top: 1.1rem; }

.piece-prose h2,
.piece-prose h3 {
  margin: 2.25rem 0 0.6rem;
  color: var(--on-ground);
  scroll-margin-top: var(--pad);
}

.piece-prose h3 { font-size: 0.85rem; font-weight: 500; }

.piece-prose blockquote {
  margin: 1.5rem 0;
  padding-left: 1rem;
  border-left: 1px solid rgba(242, 240, 232, 0.22);
  font-style: italic;
  color: var(--ink-soft);
}

.piece-prose strong,
.piece-prose b { font-weight: 600; color: var(--on-ground); }

.piece-prose a:not([data-footnote-ref]):not([data-footnote-backref]) {
  text-decoration: underline;
  text-decoration-color: rgba(242, 240, 232, 0.35);
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s ease;
}

.piece-prose a:not([data-footnote-ref]):hover { text-decoration-color: var(--on-ground); }

/* ── sidenotes ────────────────────────────────────────────────────────────── */
/* A Markdown footnote is written as a footnote and, where there is room, read as
   a margin note: ui.js lifts each one out of the list at the foot of the prose
   and sets it level with the word that called it. Everything here is written so
   the page still works if that never happens — with no JavaScript the notes are
   a numbered list at the bottom, which is exactly what they are. */

.piece-margin { position: relative; }

/* The reference number in the running copy. The dashed underline the About page
   puts under a marked word would be noise at footnote density, so the mark alone
   carries it — and it fills rather than moves, because a superscript that shifts
   on hover drags the whole line of type with it. */
.piece-prose [data-footnote-ref] {
  color: var(--on-ground-soft);
  font-variant-numeric: tabular-nums;
  text-decoration: none;
  /* The padding is what gives the filled state on hover a shape rather than a
     smear, and the negative margin gives most of it back to the line — a mark
     that has visibly pushed the following comma away is a typo, not a note. */
  padding: 0.1em 0.12em;
  margin: 0 -0.06em;
  border-radius: 2px;
  transition: color 0.2s ease, background-color 0.2s ease;
}

.piece-prose [data-footnote-ref]:hover,
.piece-prose [data-footnote-ref].is-lit {
  color: #171717;
  background: var(--on-ground);
}

/* A lifted note. Reuses .marginalia for the rule down its left and its type
   size, so an annotation on an essay and an annotation on the About page are
   visibly the same object. */
.sidenote {
  display: flex;
  gap: 0.5rem;
  transition: border-color 0.24s ease;
}

.sidenote-body { min-width: 0; }

.sidenote-body p {
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--on-ground-soft);
  text-wrap: pretty;
  transition: color 0.24s ease;
}

.sidenote-body p + p { margin-top: 0.5rem; }

.sidenote-body a {
  text-decoration: underline;
  text-decoration-color: rgba(242, 240, 232, 0.3);
  text-underline-offset: 2px;
}

/* The backref arrow is for walking back up a page from the foot of it. Once a
   note is sitting beside its own sentence there is nothing to walk back to. */
.sidenote [data-footnote-backref] { display: none; }

.sidenote.is-lit { border-left-color: var(--on-ground); }

.sidenote.is-lit .sidenote-body p,
.sidenote.is-lit .marginalia-mark { color: var(--on-ground); }

/* Placed in the margin column: absolute, because each note's top is its own
   sentence's top, and no two of those are one row apart. */
.piece-margin .sidenote {
  position: absolute;
  left: 0;
  right: 0;
  max-width: none;
}

/* Tucked under its own paragraph instead, where there is no margin to put it
   in. Inset from the copy and ruled the same way, so it still reads as an aside
   rather than as the next paragraph. */
.piece-prose .sidenote {
  margin: 0.85rem 0 1.1rem 1.25rem;
}

/* The original list, which is where every note starts and where they stay if
   the script never runs. Hidden only once they have actually been moved out. */
.piece-prose [data-footnotes] { margin-top: 3rem; }
.piece-prose[data-lifted] [data-footnotes] { display: none; }

.piece-prose [data-footnotes] ol { margin-left: 1.1rem; }

.piece-prose [data-footnotes] li {
  font-size: 0.78rem;
  color: var(--on-ground-soft);
  scroll-margin-top: var(--pad);
}

/* No room for a third column. The rail and the prose keep theirs; the notes go
   back into the copy. layoutSidenotes in ui.js watches the same width. */
@media (max-width: 1079px) {
  .piece-shell { grid-template-columns: 160px minmax(0, 1fr); }
  .piece-margin { display: none; }
}

@media (max-width: 760px) {
  .piece-shell { grid-template-columns: minmax(0, 1fr); }

  /* The rail stops being a rail: there is no column beside the copy for it to
     stand in, so it becomes a line above it. */
  .piece-rail {
    position: static;
    flex-direction: row;
    align-items: baseline;
    gap: 1rem;
  }

  .piece-toc { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .piece-open { animation-duration: 0.01ms; }
  .piece-go,
  .chip,
  .sidenote,
  .sidenote-body p { transition-duration: 0.01ms; }
}

/* ── the MIDI maker ─────────────────────────────────────────────────────────
   The banner for Gamified Hacker Applications. The panel artwork is a single
   935 × 944 image with the key wells, the row labels and the slider track
   already painted into it, so everything that sits on the panel is placed as a
   percentage of that frame and scales with it — one size to change, not
   fourteen.

   The panel is a size container, which is what lets the blurs and glows below
   be written in cqw: they were authored against a 178px key, and a share of
   the panel keeps that proportion at any banner height. */
.midi {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  /* The scene is scenery; only the keys and the slider take a pointer. */
  pointer-events: none;
  /* Held back until the ground has arrived, so the panel does not appear on
     bare paper. ui.js lifts this. */
  opacity: 0;
  transition: opacity 0.6s ease;

  /* The panel is sized from the banner's height, and everything else from the
     panel, so all of it stays in proportion. --banner-h is inherited from
     .work-banner rather than restated here: the frame is what decides how tall
     it is, at every width, and one value cannot drift from the other if there
     is only one. */
  --panel-h: calc(var(--banner-h) * 0.94);
  --panel-w: calc(var(--panel-h) * 935 / 944);
}

.midi.is-ready { opacity: 1; }

.midi img {
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

/* ── the ground ─────────────────────────────────────────────────────────── */

.midi-ground { position: absolute; inset: 0; }

/* The field, in two halves — one either side of the path, each laid from its
   own outer edge inward. A plate is about 313px wide at this height and a
   banner can be four times that, so the field has to repeat whatever happens;
   anchoring each half to its own edge is what keeps a whole plate against the
   frame and puts the only place two of them meet in the middle, under the
   path, where the instrument is already standing.

   `auto 100%` fits the plate to the banner's height and repeats it sideways,
   so the blades stay the size they were drawn however wide the window gets.
   Covering instead would scale one plate up to the full width and leave a
   single sprig standing taller than the panel. */
.midi-grass {
  position: absolute;
  top: 0;
  bottom: 0;
  background-size: auto 100%;
  background-repeat: repeat-x;
}

.midi-grass.is-left { left: 0; right: 50%; background-position: left bottom; }
.midi-grass.is-right { left: 50%; right: 0; background-position: right bottom; }

/* And the path is worn down the middle of it, under the instrument. Wider
   than the panel so the ground reads as a way through rather than as a mat cut
   to fit, and faded out at both edges so it meets the field instead of being
   ruled against it. */
.midi-path {
  position: absolute;
  left: 50%;
  top: 0;
  width: calc(var(--panel-w) * 1.75);
  height: 100%;
  object-fit: cover;
  transform: translateX(-50%);
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18%, #000 82%, transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 18%, #000 82%, transparent 100%);
}

/* ── the panel ──────────────────────────────────────────────────────────── */

.midi-panel {
  position: absolute;
  bottom: 0;
  left: 50%;
  height: var(--panel-h);
  width: var(--panel-w);
  transform: translateX(-50%);
  /* Everything inside is a share of this box. */
  container-type: size;
}

.midi-base {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* The screen at the top. Its box is the dark window in the artwork. */
.midi-screen {
  position: absolute;
  left: 7.1%;
  top: 5.6%;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 85.6%;
  height: 14.8%;
  margin: 0;
  padding: 0 4%;
  color: #cfe8a8;
  font-size: clamp(9px, 4.2cqw, 26px);
  font-weight: 500;
  line-height: 1.2;
  text-align: center;
  text-wrap: balance;
}

/* ── the keys ───────────────────────────────────────────────────────────── */

/* The nine keys, landed on the wells painted into the panel. Measured off the
   artwork: a key is 176 × 175 of the 935 × 944 frame, the columns are 226.5
   apart and the rows 223, and the block starts at 65.5, 240. Every number
   below is one of those as a share of the box it sits in — which is why the
   grid gets an explicit height as well as a width, so the row gap has a
   definite size to be a percentage of. */
.midi-grid {
  position: absolute;
  left: 7%;
  top: 25.4%;
  width: 67.3%;
  height: 65.8%;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  column-gap: 8.05%;
  row-gap: 7.75%;
}

.midi-key {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  pointer-events: auto;
  /* The glows reach well past the key, so nothing may clip them. */
  overflow: visible;
  isolation: isolate;
  -webkit-tap-highlight-color: transparent;
}

.midi-key:disabled { cursor: not-allowed; }

.midi-key:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 4px;
  border-radius: 2px;
}

/* Four washes behind the key. Two are the neutral highlight it takes under the
   pointer, two are its own colour once it is chosen; each is set by the
   component from the original's variant table. The blur was 39px on a 178px
   key — 22% of its width — and is written as a share of the panel here so it
   holds that proportion at any size. */
.midi-glow {
  position: absolute;
  inset: 5%;
  z-index: 0;
  display: block;
  opacity: 0;
  pointer-events: none;
  filter: blur(4.1cqw);
  transition: opacity 0.25s ease-in-out;
}

.midi-glow.is-hover-base { background: #a8fff6; }
.midi-glow.is-hover-tint { background: linear-gradient(180deg, #f6f6f6 0%, #9a9a9a 100%); }
.midi-glow.is-base { background: var(--glow-base); }
.midi-glow.is-tint { background: linear-gradient(180deg, var(--glow-top) 0%, var(--glow-bottom) 100%); }

/* Chosen: the key's own two glows come up. */
.midi-key[aria-pressed="true"] .midi-glow.is-base,
.midi-key[aria-pressed="true"] .midi-glow.is-tint { opacity: 1; }

/* Under the pointer and not yet chosen: the neutral pair instead. */
.midi-key:not([aria-pressed="true"]):not(:disabled):hover .midi-glow.is-hover-base,
.midi-key:not([aria-pressed="true"]):not(:disabled):hover .midi-glow.is-hover-tint { opacity: 1; }

/* The key face: the frosted tile, and a gradient laid over it in hard-light so
   the colour comes through the texture rather than sitting on top of it. */
.midi-key-face {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: block;
  isolation: isolate;
  pointer-events: none;
}

.midi-key-face img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transition: opacity 0.18s ease;
}

/* Locked keys wear the darker tile. */
.midi-key-off { opacity: 0; }
.midi-key-on { opacity: 1; }
.midi-key:disabled .midi-key-off { opacity: 1; }
.midi-key:disabled .midi-key-on { opacity: 0; }

.midi-key-face::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: radial-gradient(
    68.14% 87.21% at 50.29% 37.21%,
    var(--face-inner) 0%,
    var(--face-outer) 100%
  );
  mix-blend-mode: hard-light;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.8s ease-in-out;
}

.midi-key[aria-pressed="true"] .midi-key-face::after { opacity: 1; }

/* The same overlay in grey is what a key shows under the pointer before it has
   been chosen. */
.midi-key:not([aria-pressed="true"]):not(:disabled):hover {
  --face-inner: #dfdfdf;
  --face-outer: #757575;
}

.midi-key:not([aria-pressed="true"]):not(:disabled):hover .midi-key-face::after { opacity: 1; }

/* The icon spans the key and hangs from its top edge, which is what leaves the
   painted lip along the bottom of the tile showing. */
.midi-key-icon {
  position: absolute;
  left: 50%;
  top: 0;
  z-index: 2;
  width: 100%;
  height: auto;
  transform: translateX(-50%);
  pointer-events: none;
}

/* ── the volume slider ──────────────────────────────────────────────────── */

/* The track is painted on the panel; this is only the thumb's travel. */
.midi-volume {
  position: absolute;
  left: 81.8%;
  top: 37%;
  width: 11.2%;
  height: 40%;
  cursor: grab;
  touch-action: none;
  pointer-events: auto;
}

.midi-volume:active { cursor: grabbing; }

.midi-volume:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 4px;
  border-radius: 3px;
}

/* The thumb rides the full height of the track, so its offset is a straight
   percentage of the travel: 1 is the top, 0 the bottom. */
.midi-thumb {
  position: absolute;
  inset: 0;
  display: block;
}

.midi-thumb img {
  position: absolute;
  left: 0;
  top: calc((1 - var(--volume, 0.75)) * 100%);
  width: 100%;
  height: auto;
  transform: translateY(-50%);
}

/* ── the Hack Pals ──────────────────────────────────────────────────────── */

/* The three companions a signal can end in, idling in the field either side of
   the instrument. Placed against the frame rather than the panel, because what
   they have to stay clear of is the title in one corner and the credit in the
   other, not the keys. */
.midi-pals {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.midi-pal {
  position: absolute;
  display: block;
  border: 0;
  /* The Lottie pages render on transparent, so the field shows through. */
  background: transparent;
  /* Scenery: nothing inside them is clickable, and they are not stops on the
     way to the keys. */
  pointer-events: none;
  /* Square canvases, with the character drawn inside the middle of them. */
  aspect-ratio: 1;
  /* Follows the drawing rather than the box it is in, which is the whole point
     of shadowing a transparent frame instead of putting an ellipse under it:
     each pal casts its own silhouette onto the field. Warm and weak — they are
     standing in grass in flat daylight, not lit from a window. */
  filter: drop-shadow(0 0.6vw 0.7vw rgba(38, 56, 14, 0.3));
}

/* Each leans its own way and by its own amount, so three characters standing
   in a field do not read as three stickers pasted at the same angle. The lean
   is set with `rotate` and the drift below moves `translate`: they are separate
   properties, so the animation never has to restate the angle to avoid
   cancelling it. */
.midi-pal {
  animation: midi-pal-drift 4s ease-in-out infinite alternate;
}

.midi-pal.is-vinyl {
  left: 2.5%;
  bottom: 0%;
  width: 16.5%;
  rotate: -6deg;
  animation-duration: 4.6s;
  animation-delay: -2.4s;
}

.midi-pal.is-patch {
  right: 17%;
  bottom: 8%;
  width: 14.5%;
  rotate: 5deg;
  animation-duration: 3.9s;
  animation-delay: -0.7s;
}

.midi-pal.is-ginny {
  right: 1%;
  top: 4%;
  width: 15.5%;
  rotate: -4deg;
  animation-duration: 5.2s;
}

/* Shallower than the notes': a note is weightless and a Hack Pal is standing
   on the ground, so it breathes rather than bobs. */
@keyframes midi-pal-drift {
  from { translate: 0 -2.2%; }
  to   { translate: 0 2.2%; }
}

/* ── the notes ──────────────────────────────────────────────────────────── */

/* A box centred on the panel and half again as wide, with a note near each of
   its corners: they ride around the instrument, clear of the keys, and behind
   the panel wherever the two meet. */
.midi-notes {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: calc(var(--panel-w) * 1.62);
  height: var(--panel-h);
  transform: translateX(-50%);
}

.midi-note {
  position: absolute;
  height: auto;
  animation: midi-note-drift 3s ease-in-out infinite alternate;
}

.midi-note:nth-child(1) { left: 3%;  top: 16%; width: 4.6%; animation-duration: 2.8s; animation-delay: -1.9s; }
.midi-note:nth-child(2) { left: 6%;  top: 63%; width: 6.6%; animation-duration: 3.4s; animation-delay: -0.5s; }
.midi-note:nth-child(3) { right: 4%; top: 26%; width: 6.6%; animation-duration: 3.1s; }
.midi-note:nth-child(4) { right: 7%; top: 71%; width: 4.1%; animation-duration: 2.5s; animation-delay: -1.5s; }

@keyframes midi-note-drift {
  from { transform: translateY(-9%); }
  to   { transform: translateY(9%); }
}

@media (max-width: 900px) {
  .midi {
    /* Short enough to clear the title, which at this width wraps to two lines
       across the top of the frame rather than sitting beside the panel. The
       frame is 420 here; the share of it is what changes. Centred rather than
       floor-standing (below), so the share has to leave room at both ends: the
       two-line title above, and the code credit in the bottom corner. */
    --panel-h: calc(var(--banner-h) * 0.66);
  }
  /* Centred in the frame rather than standing on its floor, the way the record
     is on HTN's banner: the title has the top of the frame to itself here, so
     a panel anchored to the bottom leaves all the empty ground in one strip
     above it and none below. */
  .midi-panel {
    top: 50%;
    bottom: auto;
    transform: translate(-50%, -50%);
  }

  /* At this width the notes and the pals would sit under the panel or off the
     frame, and three more players is a lot to load for scenery nobody can see. */
  .midi-notes,
  .midi-pals { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .midi { transition: none; }
  .midi-note,
  .midi-pal { animation: none; }
  .midi-glow,
  .midi-key-face img,
  .midi-key-face::after { transition: none; }
}
