/* ============================================================
   APP — chrome, the two views, the shared section list, footer.

   The whole page is two views over one content model:
     [data-view="read"]  → one category in a rail + preview column
     [data-view="index"] → all categories side by side, no preview
   The .section-list component renders identically in both; only
   its container changes. That is what makes the transition read
   as one object rearranging rather than two pages swapping.

   Scrolling: the DOCUMENT scrolls, as on the reference. Nothing
   traps a wheel event except the rail, which pins to the viewport
   and only scrolls internally when its own content overflows.
   ============================================================ */

/* ---------------------------------------------------------------
   1. CHROME — top nav
   --------------------------------------------------------------- */

/* Every band — nav, stage, site map, legal — shares one measure:
   padded by --pad-x, then capped at --maxw and centred. */
.nav {
  position: fixed; inset: 0 0 auto 0; z-index: 40;
  height: var(--nav-h);
}
/* The scrim runs past the nav's own height, down behind the pager, so
   everything fixed on top of the page sits on the same dissolve rather than
   on whatever happens to be scrolling under it. */
.nav::before {
  content: ""; position: absolute; inset: 0 0 auto 0;
  height: var(--scrim-h);
  background: linear-gradient(var(--ink) 62%, transparent);
  pointer-events: none;
}
.nav-inner {
  /* must sit above .nav::before. The scrim is absolutely positioned, so
     without this it paints over the nav's own contents — an absolutely
     positioned pseudo-element outranks a static sibling in paint order,
     and the nav simply disappears while still reporting visible. */
  position: relative; z-index: 1;
  max-width: var(--maxw); margin-inline: auto;
  padding-inline: var(--pad-x);
  height: 100%;
  display: flex; align-items: center; gap: var(--s4);
}

.brand {
  display: flex; align-items: center; gap: var(--s2);
  flex: none; margin-right: var(--s3);
}
/* Same 17px as the mark it replaces. Height follows the artwork's 200:203
   ratio rather than being forced square, so nothing is stretched. The logo
   is drawn with fills, not strokes — the previous rule forced fill:none and
   would render it as nothing at all. */
.brand-mark { width: 17px; height: auto; flex: none; }
.brand-name { letter-spacing: 0.2em; }

/* margin-left:auto, not flex:1 — the links sit with Contact at the right
   edge rather than spreading from the wordmark, so the whole nav reads as
   brand on one side, navigation on the other. */
.nav-links {
  display: flex; align-items: center; gap: var(--s4);
  margin-left: auto; flex: none; min-width: 0;
}
.nav-link {
  color: var(--t-second);
  transition: color var(--fast) var(--ease);
  white-space: nowrap;
}
.nav-link:hover, .nav-link[aria-current="true"] { color: var(--t-primary); }

/* Tools, Views, and Models are the work; what follows is not. One hairline
   says that without a second row or a heading. It sits on the gap, so the
   rule is the only thing that moves when the gap does. */
.nav-sep {
  width: 1px; height: 11px; flex: none;
  background: var(--hair);
  margin-inline: calc(var(--s4) * -0.25);
}

/* ---------------------------------------------------------------
   1a. BUTTON — the primary control, used anywhere an action is offered.
   Pair with .mono for the label. Add .btn--quiet for a secondary one.
   --------------------------------------------------------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s2);
  height: var(--btn-h);
  padding: 0 var(--btn-pad-x);
  border-radius: var(--radius-btn);
  background: var(--btn-bg);
  color: var(--btn-fg);
  white-space: nowrap;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.btn:hover { background: var(--btn-bg-hover); }
.btn:active { background: var(--btn-bg-active); }

.btn--quiet { background: transparent; color: var(--t-second); }
.btn--quiet:hover { background: var(--btn-bg); color: var(--t-primary); }

.bullet { width: 6px; height: 6px; background: currentColor; flex: none; }

/* ---------------------------------------------------------------
   1b. PAGER — one mark per category, centred under the nav.
   It belongs to the expanded view only: in the index all three categories
   are already on screen, so a control that points at them is noise. It
   sits ABOVE the nav in z so it reads against the nav's scrim rather than
   being painted over by it.
   --------------------------------------------------------------- */

.pager {
  --mark: 6px;               /* must match MARK in app.js */
  position: fixed; z-index: 41;
  top: calc(var(--nav-h) - 24px);
  left: 50%; transform: translateX(-50%);
  display: flex; align-items: center;
  pointer-events: none;

  /* Solid greys rather than the usual translucent whites: the marks sit on the
     nav's scrim, which is already solid ink where they are, so there is nothing
     for an alpha to blend with and a flat value is the honest way to say it. */
  --mark-rest: #4f4f4f;
  --mark-hot: #9c9c9c;
}
[data-view="read"] .pager { pointer-events: auto; }

/* the button is the hit target (44px tall, comfortably tappable) and nothing
   else — the mark it stands for is drawn in the layer below, so a button never
   carries both. Fixed width: the layer positions its marks off it. */
.pager-dot {
  position: relative;
  width: 24px; height: 44px;
}

/* the mark layer — every mark the row draws, under the buttons rather than
   inside them, so a button never has to carry both a hit target and a mark */
.pager-marks {
  position: absolute; inset: 0;
  pointer-events: none;
}
.pager-blob {
  position: absolute; top: 50%; left: 0;
  width: var(--mark); height: var(--mark);
  transform: translateX(var(--x, 0px)) translate(-50%, -50%);
  background: var(--mark-rest);
  transition: background var(--mid) var(--ease);
}
.pager-blob[data-hot] { background: var(--mark-hot); }

/* The current category is not a state on a mark — it is a mark of its own,
   sitting on top of the one it is reporting. Same placement as the marks it
   sits on, so the two coincide exactly. */
.pager-travel {
  position: absolute; top: 50%; left: 0;
  width: var(--mark); height: var(--mark);
  transform: translateX(var(--x, 0px)) translate(-50%, -50%);
  background: var(--paper);
}

/* The row assembles itself when a category EXPANDS — that movement is what
   tells you the view changed under the nav. On first load there is no change
   to report, so it is simply already there: the nav band has to be still
   while the page arrives, or the chrome looks like it is loading too.
   app.js sets data-entered on the first view change after boot. */
.pager-marks { opacity: 0; }
[data-view="read"] .pager-marks { opacity: 1; }

[data-entered][data-view="read"] .pager-marks { animation: pager-fade var(--mid) var(--ease) 300ms both; }
[data-entered][data-view="read"] .pager-blob { animation: pager-rise var(--mid) var(--ease) 300ms both; }
[data-entered][data-view="read"] .pager-blob:nth-child(2) { animation-delay: 370ms; }
[data-entered][data-view="read"] .pager-blob:nth-child(3) { animation-delay: 440ms; }

@keyframes pager-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes pager-rise {
  from { transform: translateX(var(--x, 0px)) translate(-50%, calc(-50% - 7px)); }
  to   { transform: translateX(var(--x, 0px)) translate(-50%, -50%); }
}
@media (prefers-reduced-motion: reduce) {
  [data-entered][data-view="read"] .pager-marks { animation: none; opacity: 1; }
  [data-entered][data-view="read"] .pager-blob { animation: none; }
}

/* the name is held back until asked for — hovering a mark is the question.
   It sits ABOVE the row and rises into place: the answer arrives from the
   direction of the nav, and never covers the content being navigated to. */
.pager-name {
  position: absolute; bottom: calc(50% + 10px); left: 50%;
  transform: translate(-50%, 4px);
  color: var(--t-dim);
  white-space: nowrap; pointer-events: none;
  opacity: 0;
  transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.pager-dot:hover .pager-name,
.pager-dot:focus-visible .pager-name {
  opacity: 1; transform: translate(-50%, 0);
  color: var(--t-second);
}

/* ---------------------------------------------------------------
   2. STAGE — the two views, in normal document flow
   --------------------------------------------------------------- */

/* No horizontal padding here: the page gutter IS the column's own --col-pad,
   so a column spans a clean third of the container and its text still starts
   one gutter in. That is what keeps the two views on the same measure. */
/* No padding at all here — not even top. The index columns must reach the
   very top of the page so their dividers run the full height of the section
   and meet the footer rule, exactly as on the reference. The top offset is
   therefore applied to the content inside each column instead. */
.stage {
  padding: 0;
  max-width: var(--maxw);
  margin-inline: auto;
  /* Switching category slides the rail and preview sideways, which is real
     overflow while it runs and would flash a horizontal scrollbar. `clip`,
     not `hidden`: hidden would make the stage a scroll container and the
     rail's sticky pinning would start measuring against that instead of the
     viewport. Clip only clips — no scroll box, no effect on sticky — and the
     y axis stays visible so nothing vertical is cut. */
  overflow-x: clip;
}

.view { display: none; }
.view[data-active="true"] { display: grid; }

/* --- 2a. READ: pinned rail + scrolling preview ------------------ */

/* the rail occupies exactly one column, so it measures the same as an index
   column at every width — same third, same --col-pad, same --col cap */
#view-read {
  padding-top: calc(var(--nav-h) + var(--s6));
  padding-bottom: var(--col-end);
  grid-template-columns: calc(100% / 3) 1fr;
  /* items must STRETCH: a sticky child needs a tall grid area to travel in.
     align-items:start would collapse the area and kill the pinning. */
  align-items: stretch;
}

/* The rail pins to the viewport while the preview column scrolls past —
   the reference's fixed sidebar, expressed as sticky so the page keeps
   one scrollbar. It only scrolls itself if its own content is taller. */
.rail { min-width: 0; padding-inline: var(--col-pad); }
.rail-inner {
  max-width: var(--col);
  position: sticky;
  /* must equal .stage's padding-top, which is where the rail already sits.
     Any smaller offset and the rail drifts up by the difference before it
     catches — it reads as a jolt at the very start of the scroll. */
  top: calc(var(--nav-h) + var(--s6));
  /* no padding here: the rail and an index column must give their content
     the same measure, or the same text wraps differently between views */
  /* deliberately no max-height/overflow: the page is the only scroller.
     A category therefore has to fit one viewport — a real constraint on
     how many sections a category can carry, and a healthy one. */
}

/* The stage carries no horizontal padding — the gutter is each column's own
   --col-pad — but the preview is the one thing that runs to the container's
   right edge, so below --maxw it met the viewport with nothing between them.
   It takes the page inset instead: the plate's right edge now lands on the
   same line as Contact in the nav and the footer rule below. */
.preview {
  display: grid; gap: var(--s4);
  padding-right: var(--pad-x);
  min-width: 0;
}

/* --- 2b. INDEX: three columns, natural height ------------------- */

/* stretch, not start: every column runs to the height of the tallest, so the
   dividers are one unbroken line from the top of the page to the footer rule */
#view-index {
  grid-template-columns: repeat(3, 1fr);
  align-items: stretch;
}

.column {
  padding: 0 var(--col-pad);
  border-left: 1px solid var(--hair-soft);
  min-width: 0;
}
.column:first-child { border-left: 0; }

/* the stage's top offset lives here, so the column box itself starts at y=0 */
.column-inner {
  max-width: var(--col);
  padding-top: calc(var(--nav-h) + var(--s6));
  padding-bottom: var(--col-end);
}

/* --- index → read -----------------------------------------------
   The slide itself is driven by the Web Animations API in app.js, not
   from here: the distance each column travels has to be measured at
   click time, and a transition whose transform comes from a custom
   property set in the same frame does not reliably animate.
   All this rule does is drop the hover tag while the columns leave. */

/* the preview has no counterpart in the index, so it arrives on its own */
.preview[data-entering="true"] { animation: preview-in var(--slow) var(--ease) both; }
@keyframes preview-in {
  from { opacity: 0; transform: translateX(34px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .column { transition: none; }
  .preview[data-entering="true"] { animation: none; }
}

/* ---------------------------------------------------------------
   3. SECTION LIST — the shared component
   --------------------------------------------------------------- */

.cat-head { margin-bottom: var(--s6); }

/* back to the index — the way out of an expanded category.
   Rendered in both views but only meaningful in Read; display:none in the
   index columns keeps it out of the accessibility tree there too. */
.back {
  display: inline-flex; align-items: center; gap: var(--s2);
  margin-bottom: var(--s4);
  color: var(--t-dim);
  transition: color var(--fast) var(--ease);
}
.back:hover { color: var(--t-primary); }
.back-arrow {
  /* the return glyph is wider than the chevron it replaced and reads
     smaller at the same box, so it gets a point back */
  width: 12px; height: 12px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.5;
  stroke-linecap: square;
  transition: transform var(--fast) var(--ease);
}
.back:hover .back-arrow { transform: translateX(-3px); }

/* The outbound arrow — "this goes somewhere else". Drawn rather than set
   as ↗, because the character is a different weight and a different
   slope in every family it appears in, and this one has to sit beside
   both the blog's 12px mono and the Models tag's 10px. Two strokes: the
   shaft, then the head as a corner. Sized by whatever uses it. */
.arrow {
  width: 14px; height: 14px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.1;
  stroke-linecap: square;
  transition: transform var(--fast) var(--ease);
}

/* Swiping past the first category lights this for a beat before the index
   arrives — bounceHome() in app.js. `transition: none` on the way in: the
   flash lasts 100ms and a 180ms fade would never reach full brightness, so
   the gesture would look like it did nothing. */
.back[data-ping] { color: var(--paper); transition: none; }
.back[data-ping] .back-arrow { transform: translateX(-3px); transition: none; }

#view-index .back { display: none; }
/* the header is the second way into a category, alongside its sections */
.cat-hit {
  position: relative;                /* anchors its EXPLORE tag */
  display: block; width: 100%; text-align: left;
  cursor: default;
}
#view-index .cat-hit { cursor: pointer; }

.cat-kicker {
  display: flex; align-items: center; gap: var(--s2);
  color: var(--t-primary); margin-bottom: var(--s3);
}
.cat-title { display: block; color: var(--t-primary); }

/* A section owns three colour slots. Every state — resting, scroll-current,
   hover-lit, hover-dimmed — is expressed by reassigning these, so a state can
   never light one part of a section and forget another. */
.section {
  position: relative;              /* anchors the EXPLORE tag */
  padding-bottom: var(--s6);
  --label: var(--t-primary);     /* every section header reads white */
  --text: var(--t-second);
  --chip-bg: var(--chip);
}

/* The pinned rail runs tighter than the index columns: it has one viewport to
   live in, and a few stray pixels are enough to raise a nested scrollbar. */
.rail-inner .section { padding-bottom: var(--s5); }
/* the container's own --col-end sets the space before the footer rule; a
   trailing section padding would stack on top of it and double the gap */
.section:last-child { padding-bottom: 0; }
.rail-inner .cat-head { margin-bottom: var(--s5); }

.section-label {
  color: var(--label);
  margin-bottom: var(--s4);
  transition: color var(--fast) var(--ease);
}
.section-lede {
  color: var(--text);
  margin-bottom: var(--s4);
  /* no measure cap — the column IS the measure, as on the reference */
  transition: color var(--fast) var(--ease);
}

/* a row = [chip] + stacked items, on a fixed two-column grid so the
   chips align down the whole rail like a spec sheet */
.row {
  display: grid;
  grid-template-columns: 92px 1fr;   /* chips align down the column, per reference */
  gap: var(--s4);
  margin-bottom: var(--s4);
  align-items: start;
}
.row:last-child { margin-bottom: 0; }

.chip {
  justify-self: start;
  background: var(--chip-bg);
  border-radius: var(--radius-sm);
  padding: 3px 6px;
  color: var(--text);
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}

.row-items { display: grid; gap: 1px; justify-items: start; }
.row-item {
  position: relative;
  color: var(--text);
  text-align: left;
  transition: color var(--fast) var(--ease);
}

/* the whole section is the target, so it all takes the pointer —
   EXPLORE promises that, and the rows have to honour it */
.section { cursor: pointer; }
.section-hit { display: block; width: 100%; text-align: left; cursor: pointer; }

/* EXPLORE lives in the cursor now — see section 10. It is one line in the
   reticle's readout rather than a label per section, because it has to
   stack under the coordinates instead of landing on top of them. */

/* --- state: READ -------------------------------------------------
   The rail is a scroll control for the preview, so the lit section is
   whichever plate you are looking at. Hover deliberately changes no
   colour here — it would compete with the scroll position for the same
   signal and you could never trust which section you were actually on.
   The EXPLORE tag on a row item is the whole hover affordance. */
.rail-inner .section[data-current="true"] {
  --label: var(--t-primary);
  --text: var(--t-primary);
  --chip-bg: var(--chip-hi);
}

/* --- state: INDEX ------------------------------------------------
   No preview to drive a highlight, so the pointer does — but by
   subtraction only. The hovered section is left exactly as it rests and
   every OTHER section recedes; nothing brightens, so no text changes
   under the cursor. Column headers are never dimmed.
   :has() scopes it to an actual section hover, so it never fires from
   the gaps between columns; :not(:hover) exempts the one you are on. */
#view-index:has(.section:hover) .section:not(:hover) {
  --label: var(--recede-label);
  --text: var(--recede-text);
  --chip-bg: var(--recede-chip);
}

/* ---------------------------------------------------------------
   4. PREVIEW PLATE — the right-hand viewport
   --------------------------------------------------------------- */

/* A plate SCALES, it does not squash. Height comes from the column's width
   through one ratio, so a narrower window gives a smaller plate of the same
   shape — the reference's behaviour. A fixed height would instead hold the
   plate tall while the column narrowed, and the figure inside it would go
   from a landscape panel to a portrait slot. The floor keeps the very
   smallest plate from dropping under its own content. */
.plate {
  position: relative;
  border-radius: var(--radius);
  background: var(--plate);
  color: var(--p-primary);
  overflow: hidden;
  aspect-ratio: 13 / 10;
  min-height: 460px;
  display: flex; flex-direction: column;
  scroll-margin-top: calc(var(--nav-h) + var(--s4));
}
.plate[data-tone="light"] { background: var(--plate-alt); }

/* Plates arrive as you reach them, one at a time — the same data-seen flag
   the captions already wait on, so a plate and its labels resolve together.
   It is set once and never cleared: scrolling back up must not re-run the
   entrance, or the column would flicker on every pass. */
/* Small numbers on purpose. The plate is the largest object on the page, so
   travel that would read as gentle on a chip reads as the whole column
   heaving — a couple of percent of scale and a few pixels of rise is enough
   to say "this just arrived" and nothing more. The transform outruns the fade
   by a beat, so the last of the movement happens on a solid surface rather
   than on something half transparent. */
.plate {
  opacity: 0; transform: translateY(18px) scale(0.985);
  transform-origin: 50% 85%;   /* it grows into place from where it entered */
  transition:
    opacity calc(var(--reveal) * 0.75) var(--ease-soft),
    transform var(--reveal) var(--ease-soft);
}
.plate[data-seen="true"] { opacity: 1; transform: none; }

/* Only the plates lit on render carry --open-i, so only they stagger. A plate
   revealed by scrolling has no index and no delay: it is answering the scroll
   and must not lag behind it. */
.plate { transition-delay: calc(var(--open-i, 0) * 140ms); }
@media (prefers-reduced-motion: reduce) {
  .plate { opacity: 1; transform: none; transition: none; }
}

.plate-head {
  position: relative; z-index: 2;
  padding: var(--plate-pad);
  pointer-events: none;
}
.plate-title { max-width: 280px; }   /* the reference wraps its display line tight */
.plate-sub { margin-top: var(--s3); color: var(--p-second); max-width: 40ch; }

.plate-figure { position: relative; flex: 1; min-height: 300px; }

/* metaball graphic — the reference's signature visual */
.goo { position: absolute; inset: 0; filter: url(#goo); }
.goo-blob {
  position: absolute;
  border-radius: 50%;
  background: radial-gradient(circle at 42% 36%, var(--accent) 0%, var(--accent-deep) 68%);
  will-change: transform;
  animation-name: drift;
  animation-timing-function: var(--ease-io);
  animation-iteration-count: infinite;
  animation-direction: alternate;
}
@keyframes drift {
  from { transform: translate(0, 0); }
  to   { transform: translate(var(--drift-x, 8px), var(--drift-y, -8px)); }
}
@media (prefers-reduced-motion: reduce) { .goo-blob { animation: none; } }

.plate-captions {
  position: absolute; inset: 0; z-index: 2;
  display: flex; justify-content: space-around; align-items: center;
  gap: var(--s4);
  padding: 0 var(--plate-pad);
  pointer-events: none;
}
.plate-caption {
  color: #fff; text-align: center; max-width: 22ch;
  opacity: 0; transform: translateY(6px);
  /* same curve as the plate they ride on, so the two read as one arrival */
  transition: opacity var(--slow) var(--ease-soft), transform var(--slow) var(--ease-soft);
}
.plate[data-seen="true"] .plate-caption { opacity: 1; transform: none; }
.plate[data-seen="true"] .plate-caption:nth-child(2) { transition-delay: 90ms; }
.plate[data-seen="true"] .plate-caption:nth-child(3) { transition-delay: 180ms; }

.plate-meta {
  position: relative; z-index: 2;
  margin-top: auto;
  padding: var(--plate-pad);
  display: flex; flex-wrap: wrap; gap: var(--s2);
}
.plate-tag {
  border: 1px solid rgba(10, 10, 10, 0.18);
  border-radius: var(--radius-sm);
  padding: 3px 7px;
  color: var(--p-second);
}

/* ---------------------------------------------------------------
   5. SITE MAP + FOOTER
   --------------------------------------------------------------- */

/* The rule above the site map spans the full container — it is the line the
   column dividers run down to meet. The one above the legal row is inset by
   --pad-x instead, so it stops short of both edges. Both per the reference. */
.footer-inner {
  max-width: var(--maxw);
  margin-inline: auto;
  border-top: 1px solid var(--hair-soft);
  padding-top: var(--footer-pad);
}

.sitemap {
  padding: 0 var(--pad-x) var(--s6);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--s5) var(--s4);
}
.sitemap-col { display: grid; align-content: start; gap: var(--s3); }
.sitemap-head { color: var(--t-dim); }
.sitemap-link {
  color: var(--t-second);
  transition: color var(--fast) var(--ease);
  justify-self: start;
}
.sitemap-link:hover { color: var(--t-primary); }

.legal {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s4);
  margin-inline: var(--pad-x);          /* inset, so the rule stops short */
  padding: var(--s4) 0 var(--s5);
  border-top: 1px solid var(--hair-soft);
  color: var(--t-dim);
}
.legal-mark { display: flex; align-items: center; gap: var(--s2); }
.legal a { transition: color var(--fast) var(--ease); }
.legal a:hover { color: var(--t-primary); }
.legal-live { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.legal-dot {
  width: 5px; height: 5px; border-radius: 50%;
  background: var(--live);
  animation: pulse 2.4s var(--ease-io) infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
@media (prefers-reduced-motion: reduce) { .legal-dot { animation: none; } }

/* ---------------------------------------------------------------
   6. ENTRY
   --------------------------------------------------------------- */

[data-reveal] {
  opacity: 0;
  transform: translateY(9px);
  transition: opacity var(--slow) var(--ease), transform var(--slow) var(--ease);
}
[data-reveal].lit { opacity: 1; transform: none; }

#curtain {
  position: fixed; inset: 0; z-index: 90;
  background: var(--ink);
  pointer-events: none;
  transition: opacity 620ms var(--ease);
}
#curtain[data-done="true"] { opacity: 0; }

/* ---------------------------------------------------------------
   7. RESPONSIVE
   --------------------------------------------------------------- */

/* Below this, three columns can no longer give their content the full --col
   (3 × 340 stops fitting the page), and the index would start reading
   narrower than the rail. Drop a column rather than the measure: a section
   is then the same width, and wraps identically, in both views. */
/* Both views change at the same width, so their measure can never diverge.
   The preview needs room the page no longer has, so it goes; the rail then
   spans the page, and the index stacks to match it one-for-one.
   The reference instead keeps three columns down to 768 and display:nones
   two of them — that hides two thirds of the work, so we stack instead. */
@media (max-width: 1080px) {
  #view-read { grid-template-columns: 1fr; }
  .rail-inner { position: static; }
  .preview { display: none; }
  #view-index { grid-template-columns: 1fr; }
  .column { border-left: 0; }
  .column + .column { border-top: 1px solid var(--hair-soft); padding-top: var(--s5); }
}

@media (max-width: 720px) {
  :root { --nav-h: 52px; }
  .nav-links { display: none; }
  .nav { gap: var(--s3); }
  .brand { margin-right: auto; }
  .brand-name { display: none; }

  /* the pager stays: it costs no nav width — it is centred under the nav,
     not inside it — and it is the only category control at this size */

  /* stacking and the gutter step are already handled above and in tokens;
     nothing further to restate here */
}

/* ---------------------------------------------------------------
   8. SIMPLE PAGES — Approach, Blog, Tools, Contact.
   The case-studies page is the three-column instrument; these are
   ordinary documents that share its chrome and measure. One column,
   set on the same --col-pad gutter so the text starts exactly where
   a case-study column's text does.
   --------------------------------------------------------------- */

.page {
  max-width: var(--maxw);
  margin-inline: auto;
  padding: calc(var(--nav-h) + var(--s7)) var(--col-pad) var(--s7);
  min-height: calc(100dvh - var(--nav-h));
}

.page-head { margin-bottom: var(--s7); }
.page-kicker {
  display: flex; align-items: center; gap: var(--s2);
  color: var(--t-primary); margin-bottom: var(--s3);
}
.page-title {
  font-family: var(--serif);
  font-size: var(--display);
  line-height: 1.1;
  letter-spacing: -0.005em;
  color: var(--t-primary);
  max-width: 20ch;
}
.page-lede {
  margin-top: var(--s4);
  color: var(--t-second);
  max-width: 52ch;
}

/* the holding line on a page that has no content yet — it says so rather
   than looking like a page that failed to load */
.page-empty {
  border-top: 1px solid var(--hair-soft);
  padding-top: var(--s5);
  color: var(--t-dim);
}

/* ---- contact form ----------------------------------------------
   Measured off haiqu.ai/contactform at 1600px and matched: card 650 wide,
   40px padding, 28px radius on a 7% fill; 32px between blocks, 16px
   between fields in a row, 8px from label to input; inputs 42px tall on
   13/16 padding, 10px radius, 12% fill.

   The sizes here (11 / 12 / 13 / 15px) are the reference's own and sit
   outside the site's 10/12 mono scale on purpose — this is one borrowed
   component, and splitting the difference would match neither.
   --------------------------------------------------------------- */

/* No min-height here. The sheet is tall enough to carry the page on its
   own, and .page's viewport-filling minimum was stretching the gap under
   the form to whatever was left of the screen. */
.page--form { padding-block: 80px 100px; min-height: 0; }

.sheet {
  max-width: 650px;
  margin-inline: auto;
  padding: var(--form-pad);
  border-radius: var(--radius-sheet);
  background: rgba(255, 255, 255, 0.07);
}

.form { display: flex; flex-direction: column; gap: 32px; }

/* a row of fields; they wrap rather than squeeze */
.fields { display: flex; flex-wrap: wrap; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 8px; }
/* a field in a row takes the whole row unless it is explicitly a half —
   without this it is a flex child with no grow and shrinks to its content */
.fields > .field { flex: 1 1 100%; min-width: 0; }
.fields > .field--half { flex: 1 1 240px; }

.label {
  font-family: var(--mono);
  font-size: 11px; line-height: 1.25;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--t-primary);
}

.input {
  width: 100%;
  height: var(--field-h);
  padding: 13px 16px;
  border: 0;
  border-radius: var(--radius-field);
  background: var(--field-bg);
  color: var(--t-primary);
  /* Sans here, not the mono the reference uses. Mono at 0.1em stretches a
     placeholder into something that reads as spaced-out rather than typed,
     and it is the one thing on this page you look at before anything else.
     Both the placeholder and what you type use it, so nothing shifts. */
  font-family: var(--sans); font-size: 13px; letter-spacing: normal;
  transition: background var(--fast) var(--ease);
}
.input::placeholder { color: rgba(255, 255, 255, 0.42); }
.input:hover, .input:focus { background: rgba(255, 255, 255, 0.17); }
.input:focus { outline: none; }
textarea.input { height: auto; min-height: 120px; resize: vertical; }

/* A row of radios reads as one block, so it wants separating on both
   sides: further from its own label than a text field needs, and with a
   little more air under it before the next question. */
.field--choices { gap: 16px; padding-bottom: 10px; }

/* two up, as on the reference — never one long column of choices */
.radios { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.radio { display: flex; align-items: center; gap: 14px; cursor: pointer; }
.radio input {
  appearance: none; -webkit-appearance: none;
  width: 18px; height: 18px; flex: none;
  margin: 0; border: 0; border-radius: 50%;
  background: var(--field-bg);
  cursor: pointer;
  transition: background var(--fast) var(--ease);
}
.radio:hover input { background: rgba(255, 255, 255, 0.22); }
.radio input:checked { background: var(--paper); }
.radio input:focus-visible { outline: 1px solid var(--paper); outline-offset: 2px; }
.radio span {
  font-family: var(--sans); font-size: 13px; line-height: 1.4;
  color: rgba(255, 255, 255, 0.8);
}

/* the form's one action: full measure, and inverted so it is unmistakably
   the thing to press. .btn stays the site's quiet grey control. */
.submit {
  width: 100%;
  /* 39px total, as the reference measures. It gets there with a very tight
     line-height under 14px padding; setting the height and centring reaches
     the same figure without risking clipped descenders. */
  height: 39px;
  display: flex; align-items: center; justify-content: center;
  padding: 0 32px;
  border: 0; border-radius: var(--radius-field);
  background: var(--paper); color: var(--ink);
  font-family: var(--mono); font-size: 15px;
  cursor: pointer;
  transition: opacity var(--fast) var(--ease);
}
.submit:hover { opacity: 0.88; }

/* the reason a field was refused, under the field it belongs to */
.error {
  color: var(--warn);
  font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
  min-height: 0;
}
.error:empty { display: none; }
.input[aria-invalid="true"] { box-shadow: inset 0 0 0 1px var(--warn); }

/* the ways to reach me that are not this form, kept to the foot of the
   sheet — present, but not competing with the thing you came to do */
.sheet-foot {
  display: flex; flex-wrap: wrap; gap: var(--s2);
  padding-top: 32px;
  border-top: 1px solid var(--hair-soft);
}

@media (max-width: 720px) {
  .page--form { padding-block: var(--s6); }
  .sheet { padding: 24px; }
  .radios { grid-template-columns: 1fr; }
}

/* the home page is empty for now; the shell still has to hold its shape so
   the footer sits at the foot of the screen rather than under the nav */
.page--home { min-height: calc(100dvh - var(--nav-h) - var(--s7)); }

/* ---- contact page entry ----------------------------------------
   CSS only, on purpose: this page does not load app.js, and an entry
   animation must not depend on a script that may not arrive. The card
   fades, then its rows rise in sequence — the form assembling itself
   rather than the whole thing appearing at once.

   The card fades without moving and the rows move without the card, so
   the two transforms never compound into a doubled slide.
   --------------------------------------------------------------- */

@keyframes sheet-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes row-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}

.page--form .sheet { animation: sheet-in 420ms var(--ease) both; }

.page--form .form > * { animation: row-in 520ms var(--ease) both; }
.page--form .form > *:nth-child(1) { animation-delay: 120ms; }
.page--form .form > *:nth-child(2) { animation-delay: 180ms; }
.page--form .form > *:nth-child(3) { animation-delay: 240ms; }
.page--form .form > *:nth-child(4) { animation-delay: 300ms; }
.page--form .form > *:nth-child(5) { animation-delay: 360ms; }
.page--form .form > *:nth-child(6) { animation-delay: 420ms; }

@media (prefers-reduced-motion: reduce) {
  .page--form .sheet,
  .page--form .form > * { animation: none; }
}

/* ---------------------------------------------------------------
   9. FLASHLIGHT — the cursor lifts the section text it passes over.

   A copy of the section, painted white, sitting exactly on top of the
   original and masked to a circle at the cursor. Only the copy's TEXT
   is lit, so the black ground underneath stays black — a glow blended
   over the whole area would grey the background and read as a spot
   lamp rather than as the words themselves brightening.

   The copy needs no styles of its own: a section already takes its
   colours from three tokens, so reassigning those three here paints
   the whole duplicate white in one move.
   --------------------------------------------------------------- */

.section-glow {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;                    /* the real section keeps every event */
  opacity: 0;
  transition: opacity var(--fast) var(--ease);

  --label: var(--paper);
  --text: var(--paper);
  --chip-bg: transparent;                  /* the pill is already drawn below */

  /* --fx/--fy are written on the section by app.js on mousemove. Starting
     them off-canvas means nothing is lit before the pointer has arrived. */
  -webkit-mask-image: radial-gradient(circle var(--flash-r) at var(--fx, -999px) var(--fy, -999px),
    #000 0%, #000 42%, transparent 100%);
  mask-image: radial-gradient(circle var(--flash-r) at var(--fx, -999px) var(--fy, -999px),
    #000 0%, #000 42%, transparent 100%);
}

.section[data-flash="true"] .section-glow {
  opacity: 1;
  /* The dip rides on brightness, not opacity: opacity is already carrying
     the fade as the pointer enters a section, and an animation on the same
     property would take that job over and the entry would snap. */
  animation: flash-flicker 9.7s linear infinite;
}

/* Mostly nothing. Two brief unsteady moments in ten seconds, a few percent
   each — at that size it registers as the light being real rather than as
   an effect running. app.js gives each section a random negative delay so
   the cycle is already somewhere in the middle when you arrive, and the dip
   never lands at a predictable moment after the pointer enters. */
@keyframes flash-flicker {
  0%, 37.8%      { filter: brightness(1); }
  38.4%          { filter: brightness(0.9); }
  39%            { filter: brightness(1); }
  39.7%          { filter: brightness(0.94); }
  40.3%, 72.6%   { filter: brightness(1); }
  73.1%          { filter: brightness(0.88); }
  73.9%, 100%    { filter: brightness(1); }
}

@media (prefers-reduced-motion: reduce) {
  .section[data-flash="true"] .section-glow { animation: none; }
}

/* a pointer is the whole premise; without one there is nothing to follow */
@media (hover: none) { .section-glow { display: none; } }


/* ---------------------------------------------------------------
   10. CURSOR — a reticle and a coordinate readout, in place of the
   system arrow.

   The arrow is an operating system's idea of a pointer. This site is
   an instrument, so the pointer is one too: four corner marks framing
   the exact point, a diamond sitting on it, and the point's screen
   coordinates read out below and to the right.

   Hiding the native cursor is gated on :root[data-cursor="custom"],
   which only cursor.js sets. If the module never runs, the arrow is
   still there — the alternative is a page with no pointer at all.
   --------------------------------------------------------------- */

@media (hover: hover) and (pointer: fine) {
  /* the universal selector is deliberate: buttons, links and .section all
     set their own cursor, and every one of them has to give it up. This
     block sits last in the file so source order settles it without
     !important. */
  :root[data-cursor="custom"] * { cursor: none; }

  /* except a caret, which is information — it says where the next
     character will land, and nothing here replaces that. */
  :root[data-cursor="custom"] :is(input, textarea, [contenteditable]) { cursor: text; }
}

.cursor {
  position: fixed; top: 0; left: 0; z-index: 100;   /* over the nav, over the curtain */
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--fast) var(--ease);
  will-change: transform;
}

/* Nothing until the pointer has actually moved. Otherwise the reticle
   spends the first moment of every page parked in the top-left corner,
   pointing at a place the pointer may not be. */
.cursor[data-live="true"] { opacity: 1; }

/* Over a field the caret is the pointer. Two would be one too many. */
.cursor[data-over="text"] { opacity: 0; }

/* The frame's size and offset are both variables, because the frame has
   two jobs. At rest it is --reticle square and centred on the point, and
   the percentage offsets do that against its own size. Over a croppable
   object, cursor.js overwrites all four with pixel values measured off
   that object, and the same box becomes crop marks around it. One
   element, one transition, two behaviours — declaring a second frame for
   the crop would mean two things fading past each other on every
   approach.

   The scale is a variable too, and not part of the transform's own
   value, so the states below can adjust it without having to restate an
   offset they know nothing about. */
.cursor-frame {
  position: absolute; top: 0; left: 0;
  width: var(--cw, var(--reticle));
  height: var(--ch, var(--reticle));
  transform: translate(var(--cx, -50%), var(--cy, -50%)) scale(var(--cs, 1));
  color: var(--t-second);
  transition: transform var(--fast) var(--ease), color var(--fast) var(--ease),
              width var(--fast) var(--ease), height var(--fast) var(--ease);
}

/* Four corners, drawn as two borders each. A full square would enclose
   the thing under the pointer; corners only frame it. */
.cursor-frame i {
  position: absolute;
  width: var(--corner, 7px); height: var(--corner, 7px);
  border: 0 solid currentColor;
  transition: width var(--fast) var(--ease), height var(--fast) var(--ease);
}
.cursor-frame i:nth-child(1) { top: 0;    left: 0;  border-width: 1px 0 0 1px; }
.cursor-frame i:nth-child(2) { top: 0;    right: 0; border-width: 1px 1px 0 0; }
.cursor-frame i:nth-child(3) { bottom: 0; right: 0; border-width: 0 1px 1px 0; }
.cursor-frame i:nth-child(4) { bottom: 0; left: 0;  border-width: 0 0 1px 1px; }

/* The point itself — a sibling of the frame now, not a child, and always
   at the pointer. When the frame leaves to crop an object, this is what
   keeps the cursor a cursor. */
.cursor-eye {
  position: absolute; top: 0; left: 0;
  width: 7px; height: 7px;
  border: 1px solid currentColor;
  color: var(--t-second);
  transform: translate(-50%, -50%) rotate(45deg) scale(var(--es, 1));
  transition: transform var(--fast) var(--ease), color var(--fast) var(--ease);
}

/* Over something clickable the frame closes in and goes to full white —
   the reticle taking a reading rather than sweeping. Contracting reads as
   attention; growing would read as a hit area, which it is not. */
.cursor[data-over="hit"] .cursor-frame,
.cursor[data-over="jump"] .cursor-frame { color: var(--paper); --cs: 0.8; }
.cursor[data-over="hit"] .cursor-eye,
.cursor[data-over="jump"] .cursor-eye { color: var(--paper); }

/* and closes further under the press, eye included */
.cursor[data-down="true"] .cursor-frame { --cs: 0.68; }
.cursor[data-down="true"] .cursor-eye { --es: 0.6; }

/* ---- cropping ------------------------------------------------------
   The one case where the frame grows. Over an object it opens out to
   take the whole thing in, which is the opposite gesture from the
   contraction above and means the opposite thing: not "there is a target
   at this point" but "this entire object is the subject". The corners
   lengthen with it — a 7px mark on a 40px frame is a corner, and the
   same mark on a 300px one is a speck.

   --cs is reset because the size is now measured, not scaled: the press
   contraction would shrink a frame that has already been told exactly
   how big to be.
   -------------------------------------------------------------------- */
.cursor[data-crop="true"] .cursor-frame {
  --cs: 1;
  --corner: 18px;
  color: var(--paper);
}

/* ---- the reticle on a light ground -------------------------------
   Everything above draws the reticle in white, which is right on this
   site's ink ground and invisible the moment a page puts a pale
   surface under it — the blog's article sheet, the Models canvas.
   cursor.js marks the pointer data-ground="light" for exactly as long
   as it is inside anything that declares itself light, and these rules
   are what that mark is for.

   They belong here, beside the reticle, and not in the stylesheet of
   whichever page needed them first: a page that forgets to load that
   stylesheet does not get a white cursor on white — it gets no cursor
   at all, and there is no way to tell from the page that it is the
   missing rule rather than the pointer having left the window.

   The eye is listed alongside the frame in every one of these now. It
   used to be a child of the frame and took its colour by inheritance;
   as a sibling it has to be told, and a rule that quietly stops
   covering half the cursor is exactly the kind of thing that survives
   a review. */
.cursor[data-ground="light"] .cursor-frame,
.cursor[data-ground="light"] .cursor-eye { color: rgba(0, 0, 0, 0.55); }
.cursor[data-ground="light"][data-over="hit"] .cursor-frame,
.cursor[data-ground="light"][data-over="jump"] .cursor-frame,
.cursor[data-ground="light"][data-over="hit"] .cursor-eye,
.cursor[data-ground="light"][data-over="jump"] .cursor-eye,
.cursor[data-ground="light"][data-crop="true"] .cursor-frame { color: var(--ink); }
.cursor[data-ground="light"] .cursor-read,
.cursor[data-ground="light"] .cursor-hint { color: rgba(0, 0, 0, 0.55); }

/* The readout, off the reticle's bottom-right corner. Both lines share one
   left edge so the label sits in the readout's column rather than beside it. */
.cursor-read,
.cursor-hint {
  position: absolute;
  left: calc(var(--reticle) / 2 + 6px);
  top: calc(var(--reticle) / 2 + 10px);
  white-space: nowrap;
}
.cursor-read { color: var(--t-second); }

/* EXPLORE — "this whole section is a link". One line below the coordinates,
   sections only: a category header is a link too, but it already reads as
   one, and a label chasing the cursor across a heading is noise. */
.cursor-hint {
  margin-top: calc(var(--mono-size) * var(--mono-lh));   /* exactly one line down */
  color: var(--t-primary);
  opacity: 0;
  transition: opacity var(--fast) var(--ease);
}
.cursor[data-over="jump"] .cursor-hint { opacity: 1; }
