/* ============================================================
   Frank Le — Portfolio
   Style system: black ink on white paper, ONE typeface doing
   every job, a restrained type scale, no boxes around anything
   the visitor can press, stark hairlines, smooth restrained
   motion. Navigation is the node-tree canvas (index.html +
   js/node-tree.js), not a menu of links.
   See docs/style-direction.md for the rationale behind every
   choice here, including the Sept 2026 revisions.
   ============================================================ */

:root {
  /* Color — Frank's call (Sept 2026): paper is now pure #FFFFFF,
     reversing the earlier warm off-white. Ink stays a near-black
     rather than #000 so the page reads as printed, not clinical.
     The two grays were retoned neutral at the same time — the old
     warm tan/olive pair looked dirty against a pure white sheet. */
  --paper: #ffffff;
  --paper-dim: #f1f1f1;
  --ink: #16140f;
  --ink-soft: #5c5a55;
  --line: var(--ink); /* stark hairlines, not a soft gray — Sept 2026 revision */
  color-scheme: light;

  /* Type — ONE typeface, every job (Frank's call, Sept 2026).
     The old split serif/grotesk system is gone: Times does the
     headlines, the labels, the metadata and the node tree alike.
     Hierarchy now comes from size, case and spacing only, which is
     a harder discipline and a more distinctive result. Setting a
     serif in small tracked caps is what carries the "structural"
     voice the grotesk used to. Don't reintroduce a second family
     without asking — it undoes the whole idea. */
  /* Inter Tight (Frank, Sept 14 2026) — replaces Times, which had
     been the one face since Sept 8. Loaded from Google Fonts in each
     page's <head> as a variable font, so every weight is available
     without a second file. The fallbacks are sans, so a slow load
     shows a grotesk rather than a serif for a moment. */
  --font: "Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif;

  /* Scale — one modular ladder, ratio 1.2 (Sept 8 2026 rebuild).
     Every size on the site comes from this ladder; there are no
     hand-picked font-sizes left in the file. Before this there were
     five tokens and thirty-three one-off values clustered so tightly
     (0.7 / 0.72 / 0.75 / 0.78 / 0.8 / 0.85rem) that no one could
     tell them apart — a scale on paper that the CSS ignored.

     The range is the same as before; the rungs are twice as dense,
     which is what a tight ratio buys you. Each step is 1.2x the one
     below, at BOTH ends of its clamp, so the ratio holds at every
     viewport rather than only at one width. Body is --step-2, and
     the ladder is anchored there: 16px on a phone, 17.5px on a desk.

     The anchor has moved twice: down from 16/18px when the site read
     too large, then back up to 16/17.5px on Sept 10 2026 when it read
     too small to sit and read. 16px is the floor for body copy and
     the reason it went back up — below that, prose is a squint. Because every rung is derived
     from the base, moving it moved all nine together and the
     proportions are untouched; this is the only place to change if
     the whole site should get bigger or smaller again. Do NOT nudge
     one rung on its own — that snaps it out of the 1.2 relationship
     with its neighbours and puts us back to the ad-hoc scale this
     ladder replaced.

     In use today: 0 (micro labels), 1 (small text and controls),
     2 (body), 4 (node d1, card titles), 6 (node d0, section titles),
     8 (page titles). Steps 3, 5 and 7 are the headroom the tighter
     ratio creates — real rungs, not yet needed. Reach for one of
     them before inventing a number. */
  --step-0: clamp(0.694rem, 0.675rem + 0.085vw, 0.760rem); /* 11.1 - 12.2px */
  --step-1: clamp(0.833rem, 0.810rem + 0.101vw, 0.911rem); /* 13.3 - 14.6px */
  --step-2: clamp(1rem,     0.973rem + 0.121vw, 1.094rem); /* 16.0 - 17.5px */
  --step-3: clamp(1.2rem,   1.167rem + 0.146vw, 1.313rem); /* 19.2 - 21.0px */
  --step-4: clamp(1.44rem,  1.401rem + 0.174vw, 1.575rem); /* 23.0 - 25.2px */
  --step-5: clamp(1.728rem, 1.681rem + 0.209vw, 1.890rem); /* 27.6 - 30.2px */
  --step-6: clamp(2.074rem, 2.018rem + 0.250vw, 2.268rem); /* 33.2 - 36.3px */
  --step-7: clamp(2.488rem, 2.420rem + 0.302vw, 2.722rem); /* 39.8 - 43.5px */
  --step-8: clamp(2.986rem, 2.905rem + 0.361vw, 3.266rem); /* 47.8 - 52.3px */

  --max-w: none;
  --gutter: clamp(1.25rem, 3vw, 3rem);
  /* Section rhythm, pulled from the reference's literal 200px
     section-gap token (adapted down for a multi-page site — 200px
     between every section on every page reads as excessive here) */
  --section-gap: clamp(3rem, 11vw, 12.5rem);

  /* Motion. --ease is the slow, editorial curve for content
     entering on scroll; --ease-out is the punchier UI curve for
     things the visitor operates directly (menu, toggle, buttons),
     which stay under 250ms so the interface never feels laggy. */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}

* { box-sizing: border-box; }

html {
  color-scheme: light;
  scroll-behavior: smooth;
  /* The tree panel locks body scroll while it's open; reserving the
     gutter stops the page jumping sideways as the scrollbar goes. */
  scrollbar-gutter: stable;
}

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

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font);
  font-optical-sizing: auto;
  font-size: var(--step-2);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img, video { max-width: 100%; display: block; }

a { color: inherit; }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: var(--gutter);
  top: -100%;
  z-index: 50;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--font);
  font-size: var(--step-1);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-decoration: none;
  padding: 0.7rem 1rem;
}

.skip-link:focus { top: 0.5rem; }

/* Small structural label, used for eyebrows and section headings —
   the grotesk "structure" voice. */
.label {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ---------- Layout shell ---------- */

main { display: block; }

/* The same opening as every canvas page. works.html is the only page
   left using .section, and since its heading and rules came out there
   was nothing at the top of it to justify --section-gap - the filter
   bar simply started 60px lower than the first line of every other
   page. These figures are .field's, deliberately: change one, change
   both. */
.section {
  padding: clamp(2rem, 7vw, 5rem) var(--gutter) clamp(4rem, 10vw, 8rem);
}

.section--tight { padding-block: clamp(2rem, 5vw, 4rem); }
.section--flush-top { padding-top: 0; }

/* The homepage grid slot renders nothing until a third-party grid is
   embedded in it — until then it shouldn't reserve section padding. */
.section:empty { padding: 0; }

.grid-12 {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: clamp(1rem, 2vw, 1.75rem);
  max-width: var(--max-w);
  margin-inline: auto;
}

.rule {
  grid-column: 1 / -1;
  height: 1px;
  background: var(--line);
  border: 0;
  margin: 0;
}

/* ---------- Section headings ---------- */

/* The section head and its rule were removed Sept 11 2026 along with
   the one on the filter row: works.html was the last page carrying
   them, and ruled-off headers are the document look this site left
   behind. git has the rules. */

/* Text link with a rule that draws itself on hover */
.link-line {
  font-family: var(--font);
  font-size: var(--step-1);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-decoration: none;
  padding-bottom: 3px;
  background-image: linear-gradient(var(--ink), var(--ink));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 1px;
  transition: background-size 0.25s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .link-line:hover { background-size: 0 1px; }
}

/* ---------- Media ---------- */

.media-frame {
  position: relative;
  display: block;          /* it is an <a> on the works grid */
  color: inherit;
  text-decoration: none;
  aspect-ratio: 4 / 5;
  width: 100%;
  overflow: hidden;
  background: var(--paper-dim);
  /* The frame declares the scroll timeline that the layer inside it
     reads. It has to be declared HERE and not on that layer: overflow
     makes this frame a scroll container, so a view() timeline created
     inside it would measure the layer against the frame - which never
     scrolls - and sit frozen at 50% forever. Measured from the frame,
     it measures against the page, which is the thing moving. */
  view-timeline-name: --card-view;
  view-timeline-axis: block;
}

.media-frame img,
.media-frame video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.4s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  a:hover .media-frame img,
  a:hover .media-frame video { transform: scale(1.03); }
}

.media-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 1rem;
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ---------- Card caption (works grid) ---------- */

/* The caption carries the link now that the media is something you
   pick up rather than click through. */
.caption {
  margin-top: 0.85rem;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0 0.9rem;
  align-items: baseline;
  text-decoration: none;
  color: inherit;
  cursor: pointer;
}

.caption__index {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.1em;
  color: var(--ink-soft);
}

.caption__title {
  font-family: var(--font);
  font-size: var(--step-4);
  line-height: 1.25;
  /* justify-self, or the underline stretches the whole grid column
     and reads as a rule rather than a link. */
  justify-self: start;
  max-width: 100%;
  border-bottom: 1px solid transparent;
  transition: border-color 200ms var(--ease-out);
}

.caption__meta {
  grid-column: 2;
  margin-top: 0.3rem;
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ---------- Works grid + filters ---------- */

.filters {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 2rem;
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
  font-family: var(--font);
}

.filter-group {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.filter-group__label {
  font-size: var(--step-0);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-right: 0.25rem;
}

.filter-btn {
  /* No box (Frank's call, Sept 2026). State is carried by the rule
     under the word and by ink weight, not by a container. */
  font-family: var(--font);
  font-size: var(--step-1);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  background: none;
  border: 0;
  color: var(--ink-soft);
  padding: 0.25rem 0;
  margin-right: 0.35rem;
  cursor: pointer;
  border-bottom: 1px solid transparent;
  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out), transform 0.14s var(--ease-out);
}

.filter-btn:active { transform: scale(0.97); }

@media (hover: hover) and (pointer: fine) {
  .filter-btn:hover { color: var(--ink); border-bottom-color: var(--ink); }
}

/* Selected filter: the word goes to full ink and keeps its rule. No
   fill, no container — same reasoning as removing the borders. */
.filter-btn[aria-pressed="true"] {
  color: var(--ink);
  border-bottom-color: var(--ink);
}

/* The project counter was removed Sept 11 2026. The grid is short
   enough to see, and a running total is a database's idea of a
   portfolio. */

.works-grid {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: clamp(2rem, 5vw, 4rem) clamp(1rem, 2vw, 1.75rem);
}

.work-card {
  grid-column: span 6;
  position: relative;     /* so a card being dragged can ride above */
  display: block;
  color: inherit;
}

/* The layer that reveals on scroll. It is not .work-card itself
   because the drag writes a transform there, and one element cannot
   hold two. */
.work-card__body { display: block; }

.work-card[data-draggable="on"] {
  cursor: grab;
  touch-action: none;
  will-change: transform;
}

.work-card.is-dragging { cursor: grabbing; z-index: 3; }

/* Broken rhythm: a repeating four-item pattern of uneven spans,
   offsets and aspect ratios. This is where the "unconventional"
   energy lives, since type and motion stay calm. */
.work-card:nth-child(4n + 1) { grid-column: 1 / span 7; }
.work-card:nth-child(4n + 2) { grid-column: 9 / span 4; margin-top: clamp(2rem, 8vw, 7rem); }
.work-card:nth-child(4n + 3) { grid-column: 2 / span 5; }
.work-card:nth-child(4n)     { grid-column: 8 / span 5; margin-top: clamp(1rem, 4vw, 3.5rem); }

.work-card:nth-child(4n + 1) .media-frame { aspect-ratio: 16 / 9; }
.work-card:nth-child(4n + 2) .media-frame { aspect-ratio: 3 / 4; }
.work-card:nth-child(4n + 3) .media-frame { aspect-ratio: 4 / 3; }
.work-card:nth-child(4n)     .media-frame { aspect-ratio: 1 / 1; }

/* Native ratio on the grid too (Frank, Sept 12 2026 — the last place
   the rule wasn't applied). A frame whose media declares its pixels
   takes the file's own shape from --ratio, set inline by cardMarkup();
   the composed crops above are for entries that don't say. Same
   specificity as those rules and later in the file, so it wins. */
.work-card .media-frame.media-frame--native { aspect-ratio: var(--ratio); }

/* Portrait media is capped by HEIGHT, width following, so a 9:16 clip
   in a seven-column card is ~380x680 rather than 1400px tall. It is
   `width`, not `max-width`, on purpose: the resize grip writes an
   inline width, and inline wins, so the frame can still be taken
   larger by hand. */
.work-card .media-frame--upright { width: min(100%, calc(min(70vh, 48rem) * var(--ratio))); }

@media (max-width: 860px) {
  .work-card,
  .work-card:nth-child(4n + 1),
  .work-card:nth-child(4n + 2),
  .work-card:nth-child(4n + 3),
  .work-card:nth-child(4n) {
    grid-column: 1 / -1;
    margin-top: 0;
  }
}

.works-empty {
  grid-column: 1 / -1;
  font-family: var(--font);
  color: var(--ink-soft);
  padding: 3rem 0;
}

/* The inverted footer slab was removed Sept 11 2026. It was the last
   piece of the pre-canvas design still standing, on works.html alone,
   and every other page had already moved to .end-marks. Its rules
   lived here; git has them if the title-card idea ever comes back. */

/* ---------- Motion: scroll reveals ----------
   Smooth and slow, per the brief — content lifts into place rather
   than snapping. The .reveal class is added by js/site.js to any
   [data-reveal] element, so with JS off nothing is ever hidden. */

.reveal {
  opacity: 0;
  transform: translateY(1.5rem);
  transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
  transition-delay: var(--reveal-delay, 0s);
}

.reveal.is-in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  /* Gentler, not zero. Fades and color changes stay — they carry
     meaning and don't cause motion sickness. Everything that MOVES
     is switched off. */
  *, *::before, *::after {
    transition-property: opacity, color, background-color, border-color, text-decoration-color !important;
    transition-duration: 0.2s !important;
    animation-duration: 0.01ms !important;
  }

  .reveal { opacity: 1; transform: none; }
  .tree-panel { transform: none; }
  .tree__branch { transition: none; }
  .tree__list > li { transform: none; }

  .nav-tree-open:active,
  .nav-mode__btn:active,
  .tree-panel__close:active,
  .tree__toggle:active,
  .filter-btn:active,
  a.cv-slot:active { transform: none; }

  a:hover .media-frame img,
  a:hover .media-frame video { transform: none; }
}


/* ---------- Node-tree canvas (the homepage) ----------
   The site's navigation, and the whole of index.html. Blank paper,
   nodes the visitor can drag anywhere, branches that open in place.
   Behaviour lives in js/node-tree.js; the notes there explain the
   motion decisions. Nothing in here uses a box, a fill or a shadow —
   the diagram is drawn in hairlines and type only. ---------- */

.page-canvas {
  height: 100dvh;
  overflow: hidden;
}

.canvas {
  position: relative;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
  /* Nodes are dragged with pointer events, so the browser must not
     claim the gesture for scrolling first. */
  touch-action: none;
}

/* The sheet of paper. transform-origin sits at its top-left corner
   so screen and world coordinates convert with a plain
   (screen - pan) / scale — put the origin in the middle and every
   calculation in js/node-tree.js grows a term. */
.canvas__world {
  position: absolute;
  inset: 0;
  transform-origin: 0 0;
  will-change: transform;
}

.canvas__edges {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;   /* an SVG clips to its own box by default, and
                          the tree runs well outside it once panned */
}

/* Grabbing the paper itself, rather than a node on it. */
.canvas.is-panning { cursor: grabbing; }
.canvas.is-panning .node { pointer-events: none; }

/* Recessive on purpose: the lines describe the structure, the words
   carry it. At full ink the diagram reads as a cage. */
.canvas__edge {
  stroke: var(--ink);
  stroke-width: 1;
  stroke-opacity: 0.32;
}

.canvas__nodes {
  position: absolute;
  inset: 0;
}

.node {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  cursor: grab;
  touch-action: none;
  will-change: transform;
  transition: transform 260ms var(--ease-out), opacity 260ms var(--ease-out);
}

/* No transition while a node is under the pointer — it should track
   the hand exactly, not lag behind it by a quarter second. */
.node.is-dragging {
  cursor: grabbing;
  transition: none;
  z-index: 3;
}

.node.is-entering { opacity: 0; }

.node.is-leaving {
  opacity: 0;
  transition-duration: 170ms;
  transition-delay: 0ms !important;
}

.node__hit {
  display: inline-flex;
  flex-direction: column;   /* media above, naming below — the default
                               for every node with a frame */
  align-items: flex-start;
  gap: 0.55em;
  font-family: var(--font);
  font-weight: 400;
  background: none;
  border: 0;
  padding: 0.3rem 0.15rem;
  margin: 0;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 140ms var(--ease-out);
}

.node__hit:active { transform: scale(0.97); }
.canvas.is-dragging .node__hit { cursor: grabbing; }

.node__hit:focus-visible {
  outline: 1px solid var(--ink);
  outline-offset: 5px;
}

/* Size carries depth — the one hierarchy signal left now that
   everything is set in the same face. */
/* Two rungs between each depth (a 1.44x jump) so the branch levels
   read as clearly different, then only one rung down to the leaves —
   project titles are the point of the tree and shouldn't shrink out
   of reading size just to complete a pattern. */
.node--d0 .node__hit { font-size: var(--step-6); }
.node--d1 .node__hit { font-size: var(--step-4); }
.node--d2 .node__hit { font-size: var(--step-2); }
.node--d3 .node__hit { font-size: var(--step-1); color: var(--ink-soft); }

.node__label {
  border-bottom: 1px solid transparent;
  transition: border-color 200ms var(--ease-out);
}

/* Two things sit beside a node's name and below it in weight: the
   name Frank works under, and a project's year. Same treatment —
   subordinate on the same baseline, which .node__line provides. */
.node__alias,
.node__meta {
  color: var(--ink-soft);
  letter-spacing: 0.01em;
}

.node__alias { font-size: 0.62em; }

/* 0.75em, not the alias's 0.62em. The alias sits beside the root's
   36px name, where 0.62em is still 22px; a project node is 17px, and
   the same ratio would set its year at 10.7px — under --step-0, the
   smallest size anywhere on the site. A ratio that works at one end
   of the scale does not automatically work at the other. */
.node__meta { font-size: 0.75em; }

@media (hover: hover) and (pointer: fine) {
  .node__hit:hover .node__label { border-color: currentColor; }
}

.node__glyph {
  font-size: 0.75em;
  letter-spacing: 0.1em;
  color: var(--ink-soft);
  min-width: 0.6em;
  /* Extra air before the +/− only. Widening .node__hit's flex gap
     would have pushed "Frank Le" off the name it belongs to; the
     glyph is a separate thing from the naming, so the space that
     separates it should be too. In em, so it stays proportional at
     every depth of the tree rather than looking cramped on the root
     and gaping on a leaf. */
  margin-left: 0.5em;
}

/* A still or clip beside the title. Sized in em, so it scales with
   the node's own depth without a rule per level. */
/* The naming row: everything that used to sit directly in the hit. */
.node__line {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
}

/* Tripled (Sept 10 2026) and moved above the title. A node carrying
   work should show the work, not a stamp beside a word — at 4em the
   clip was a detail on the label; at 12em the label is a caption on
   the clip. Still em-based, so it scales with the node's depth
   without a rule per level. */
.node__media {
  --edge: 12em;             /* the frame's LONGER side, whichever that is */
  flex: none;
  display: block;
  width: var(--edge);
  aspect-ratio: 4 / 3;      /* a holding shape only, until the file's own
                               ratio is known — see applyRatio() in
                               js/node-tree.js */
  overflow: hidden;
  background: var(--paper-dim);
}

/* The root's is proportionally smaller — it sits with the biggest type
   on the canvas — and holds a portrait shape while it waits. */
.node--d0 .node__media {
  --edge: 6em;   /* 9.6em was set before a portrait existed; at the
                    root's type size it came out at 345px, nearly
                    twice any project frame. Frank asked for a small
                    portrait — 6em is ~216px, a portrait beside a
                    name rather than a hero image. Sept 13 2026. */
  width: auto;
  height: var(--edge);
  aspect-ratio: 3 / 4;
}

/* Every imported file is shown at its native ratio (Frank's rule,
   Sept 12 2026): the inline aspect-ratio carries the file's own
   proportions, and the frame is sized by its longer edge. Sizing by
   width alone would let a portrait clip grow to twice the area of a
   landscape one; by the longer edge, the two carry the same weight
   and neither is cropped. Double-class selectors so these outrank the
   root's holding shape above. */
.node__media.node__media--wide { width: var(--edge); height: auto; }
.node__media.node__media--tall { height: var(--edge); width: auto; }

.node__media img,
.node__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 400ms var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .node__hit:hover .node__media img,
  .node__hit:hover .node__media video { transform: scale(1.04); }
}

/* Media nodes need no special alignment now that the hit is a column
   — the frame and its title both start at the left edge. */

/* Describes the node it sits under; not a second thing to click.
   It carries a sentence rather than a list of labels, so it is set in
   reading case: the site's small tracked caps are a label treatment,
   and this one has commas and a subordinate clause in it. Same rung
   on the ladder as before — only the case, tracking and measure
   changed. */
.node__caption {
  margin-top: 0.5rem;
  padding-left: 0.15rem;
  /* display:block is load-bearing. As a bare <span> the caption is
     inline, so it sat on the SAME line as the names and ran the whole
     width of the canvas — and max-width, which does not apply to
     inline boxes, was silently ignored. Block puts the statement on
     its own line under the names and lets the measure below bite. */
  display: block;
  /* Sized to break across two lines at the statement's current
     length. If the copy in ROOT_CAPTION changes materially, re-check
     the wrap — this measure is tuned to it, not to a rule.
     60vw, not 70: on a phone the root's box is the caption's width,
     and at 70vw it reached far enough across the canvas to collide
     with the branch beside it. */
  max-width: min(34rem, 60vw);
  /* --step-3, not --step-0. This is the hero line; it was sitting on
     the label rung, three steps below body copy. */
  font-size: var(--step-3);
  line-height: 1.45;
  color: var(--ink);
  user-select: none;
}

.canvas__fallback {
  list-style: none;
  margin: 0;
  padding: var(--gutter);
  display: flex;
  gap: 1.5rem;
}

/* ---------- Canvas corner marks ---------- */

.canvas__marks {
  position: fixed;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}

.mark {
  position: absolute;
  margin: 0;
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  line-height: 1.8;
}

/* The drag hint is gone (Frank's call, Sept 10 2026) — the lower left
   belongs to the reset now. Dragging and zooming are left to be found
   rather than announced. */

.mark--mail {
  right: var(--gutter);
  bottom: var(--gutter);
  pointer-events: auto;
  text-decoration: none;
  color: var(--ink);
  border-bottom: 1px solid transparent;
  transition: border-color 200ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .mark--mail:hover { border-bottom-color: var(--ink); }
}

@media (max-width: 720px) {
  .mark--mail { display: none; } /* the Contact node covers it */
  .node--d0 .node__hit { font-size: var(--step-4); }
  .node--d1 .node__hit { font-size: var(--step-2); }
  .node--d2 .node__hit,
  .node--d3 .node__hit { font-size: var(--step-0); }
}

/* Reduced motion: nodes still appear and disappear, they just don't
   travel to do it. Movement is what causes trouble, not opacity. */
@media (prefers-reduced-motion: reduce) {
  .node { transition: opacity 200ms var(--ease-out); }
  .node__hit:active { transform: none; }
  .node__hit:hover .node__media img,
  .node__hit:hover .node__media video { transform: none; }
}

/* ---------- Inner-page head ----------
   There is no list menu anywhere on the site. Every page that isn't
   the canvas gets its nameplate and one way back to the tree. ---------- */

/* One thing lives here: the colour mode, held to the right. The
   nameplate and the back link were removed Sept 11 2026 - see the
   comment in any page's markup, and the Navigate cue below. */
.page-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 1rem;
  /* var(--gutter), not 1.5rem. The homepage holds its colour-mode
     control at top: var(--gutter), and a header that used a different
     figure made the Light/Dark pair jump 14px up the moment you left
     the canvas. The two are now the same distance from the same
     corner - measured at 1280px: 38.39px on every page. */
  padding: var(--gutter) var(--gutter) 0;
}

/* The nameplate, back on the inner pages (Frank, Sept 13 2026) as the
   full name rather than the alias — the same name the root node
   carries, so leaving the canvas doesn't change who the site belongs
   to. It is a link home, quietly: no rule at rest, one on hover, the
   same small tracked caps as every other mark. The canvas itself has
   no header; the root node is its nameplate. */
.page-head__mark {
  font-family: var(--font);
  font-size: var(--step-1);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.2s var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .page-head__mark:hover { border-bottom-color: var(--ink); }
}


/* ============================================================
   Canvas pages — about, contact, work detail (Sept 9 2026)

   These were conventional documents: a column of text, ruled
   sections, boxed media, an inverted footer slab. Against the node
   tree they read as a different website. Rebuilt as scrolling
   canvases on the same terms as the homepage:

   - No rule ever separates two things. Space does it.
   - No box is ever drawn around anything.
   - Nothing sits in a column. An invisible 12-column grid places
     blocks so they step down and inward.
   - Media is a free object: draggable anywhere (js/drag.js) and
     drifting gently on its own.

   The grid is a placement tool, not a visible structure — if you
   ever find yourself adding a border to make the layout legible,
   the placement is wrong, not the border.
   ============================================================ */

.field,
.work-field {
  position: relative;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: clamp(1rem, 2vw, 1.75rem);
  row-gap: clamp(2.5rem, 7vw, 6rem);
  align-items: start;
  padding: clamp(2rem, 7vw, 5rem) var(--gutter) clamp(4rem, 10vw, 8rem);
}

/* ---------- Reading blocks ---------- */

.passage {
  margin: 0;
  font-size: var(--step-2);
  line-height: 1.55;
  text-wrap: pretty;
}

/* Four passages, evenly distributed (Frank, Sept 14 2026): the same
   measure for each (5 columns), one column of stair between steps,
   and one row-gap between rows — no pair pulled tight, no step wider
   than the next. All four end at column 8 or earlier, which is what
   lets auto-placement keep them in rows 1-4 beside the portrait; a
   passage reaching column 9 overlaps the portrait's cell and the grid
   pushes it below the portrait's whole span (Sept 13 — see
   .floater--portrait). */
.passage--a { grid-column: 1 / span 5; }
.passage--b { grid-column: 2 / span 5; }
.passage--c { grid-column: 3 / span 5; }
.passage--d { grid-column: 4 / span 5; }

.note {
  margin: 0;
  font-size: var(--step-1);
  line-height: 1.6;
  color: var(--ink-soft);
  max-width: 46ch;
}

/* Loose facts. A list, not a table — the old <dl> needed rules to
   hold its columns apart, which is exactly what we're removing. */
.notes {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.85rem;
  font-size: var(--step-1);
  line-height: 1.5;
}

.notes .label { display: block; color: var(--ink-soft); }

.notes--work    { grid-column: 9 / span 4; }
.notes--awards  { grid-column: 9 / span 4; }

/* ---------- Credits ----------
   Two columns, never more: a film credit list is long and a single
   column of fifteen roles turns the page into a ladder. Anything past
   five rows is folded away by default and the fifth row fades out, so
   the block advertises that it continues without spending the height
   to prove it. js/work-detail.js decides whether to fold — the rule
   is rows, not entries, and it applies to every project. */

.credits { grid-column: 1 / span 7; }

.notes--credits {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(1.5rem, 4vw, 3rem);
}

/* Ten items is five rows across two columns. Past that the list is
   clipped rather than shortened — the entries still exist, they are
   just not spending space until asked for. */
.notes--credits.is-folded > li:nth-child(n + 11) { display: none; }

.notes--credits.is-folded {
  -webkit-mask-image: linear-gradient(to bottom, #000 62%, transparent 97%);
  mask-image: linear-gradient(to bottom, #000 62%, transparent 97%);
}

.credits__more {
  margin-top: 1rem;
  font-family: var(--font);
  font-size: var(--step-1);
  line-height: 1;
  background: none;
  border: 0;
  padding: 0;
  color: var(--ink-soft);
  cursor: pointer;
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  transition: color 0.2s var(--ease-out), transform 0.14s var(--ease-out);
}

.credits__more:active { transform: scale(0.94); }
.credits__more:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

@media (hover: hover) and (pointer: fine) {
  .credits__more:hover { color: var(--ink); }
}

@media (max-width: 860px) {
  .notes--credits { grid-template-columns: minmax(0, 1fr); }
  /* One column on a phone, so five rows is five entries. */
  .notes--credits.is-folded > li:nth-child(n + 6) { display: none; }
}

/* ---------- Free-floating media ---------- */

.floater {
  margin: 0;
  position: relative;
  touch-action: none;
  cursor: grab;
  will-change: transform;
}

.floater.is-dragging { cursor: grabbing; z-index: 6; }

/* ---------- Behind the scenes: the pile ----------
   Three prints at the right margin, beside the CV. The .pile grid
   item has NO height of its own — the stage is absolute — so the CV's
   row is exactly as tall as the CV and the prints simply hang over
   the empty columns to its right. One click spreads the pile into a
   cascade down and to the left; another stacks it. Prints are sized
   by their longer edge (--edge), like the node frames, so the
   portrait and the two landscapes carry the same weight. */
.pile {
  grid-column: 9 / span 4;
  align-self: start;
  position: relative;
  height: 0;
}

.pile__stage {
  position: absolute;
  top: 0;
  right: 0;
  --edge: clamp(11rem, 18vw, 16rem);
  width: calc(var(--edge) * 1.1);
  height: calc(var(--edge) * 1.35);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.pile__stage:focus-visible { outline: 1px solid var(--ink); outline-offset: 6px; }

.pile__item {
  position: absolute;
  right: var(--x);
  top: var(--y);
  margin: 0;
  width: var(--w);
  aspect-ratio: var(--ratio);
  z-index: var(--z);
  /* Two of the three transforms this print carries live here, on the
     figure, as the separate translate and rotate properties; the
     drift animates transform on the inner layer. None overwrites
     another. Spread moves translate and rotate only — compositor
     work, no layout. */
  translate: var(--tx, 0) var(--ty, 0);
  rotate: var(--rot);
  transform-origin: 50% 50%;
  transition: translate 260ms var(--ease-out), rotate 260ms var(--ease-out);
  transition-delay: var(--delay, 0ms);
}

/* Stacking back is faster than spreading, and reverses the stagger,
   the way a branch closes on the canvas. */
.pile__stage[aria-pressed="false"] .pile__item {
  transition-duration: 170ms;
  transition-delay: calc(90ms - var(--delay, 0ms));
}

.pile__item .drift { display: block; }

.pile__item img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}

/* Resting: a tight stack, offsets of a few pixels, a degree or two
   each way. Later in the DOM sits on top; the portrait leads. */
.pile__item--1 { --ratio: 16 / 9;      --w: var(--edge);               --x: 0.6rem; --y: 1.1rem; --rot: -2deg;  --z: 1; --delay: 0ms; }
.pile__item--2 { --ratio: 1600 / 1066; --w: var(--edge);               --x: 0.2rem; --y: 0.5rem; --rot: 1.5deg; --z: 2; --delay: 45ms; }
.pile__item--3 { --ratio: 3 / 4;       --w: calc(var(--edge) * 0.75);  --x: 0;      --y: 0;      --rot: -1deg;  --z: 3; --delay: 90ms; }

/* Spread: a cascade down and to the left, off the top of the stack —
   the print on top travels least, the one at the bottom furthest, so
   all three end up fully visible in a loose diagonal beside the CV. */
.pile__stage[aria-pressed="true"] .pile__item--3 { --tx: 0;                            --ty: 0;                          --rot: 1deg; }
.pile__stage[aria-pressed="true"] .pile__item--2 { --tx: calc(var(--edge) * -0.55);   --ty: calc(var(--edge) * 0.62);   --rot: -2.5deg; }
.pile__stage[aria-pressed="true"] .pile__item--1 { --tx: calc(var(--edge) * -0.2);    --ty: calc(var(--edge) * 1.3);    --rot: 2deg; }

@media (max-width: 860px) {
  /* The field is a block stack here, so the pile takes real height
     and sits between the bio and the CV; the stage is centred and the
     cascade goes straight down. */
  .pile { height: auto; margin-bottom: clamp(2rem, 9vw, 3.5rem); }
  .pile__stage {
    position: relative;
    margin-inline: auto;
    --edge: min(64vw, 15rem);
    height: calc(var(--edge) * 1.35);
    transition: height 260ms var(--ease-out);
  }
  .pile__stage[aria-pressed="true"] { height: calc(var(--edge) * 2.6); }
  .pile__stage[aria-pressed="true"] .pile__item--2 { --tx: calc(var(--edge) * -0.08); --ty: calc(var(--edge) * 0.7); }
  .pile__stage[aria-pressed="true"] .pile__item--1 { --tx: calc(var(--edge) * 0.05);  --ty: calc(var(--edge) * 1.5); }
}
.floater figcaption { margin-top: 0.6rem; }

/* The sound switch under a clip. No box - a word with a rule under it
   when it is on, the way a selected filter reads. It starts soft and
   goes to full ink when on, so the one clip with audio is the one
   that reads loudest on the page. */
.shot__sound {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  background: none;
  border: 0;
  border-bottom: 1px solid transparent;
  padding: 0.25rem 0;
  margin: 0;
  cursor: pointer;
  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out), transform 0.14s var(--ease-out);
}

.shot__sound:active { transform: scale(0.97); }
.shot__sound:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }
.shot__sound[aria-pressed="true"] { color: var(--ink); border-bottom-color: var(--ink); }

@media (hover: hover) and (pointer: fine) {
  .shot__sound:hover { color: var(--ink); }
}

@media (prefers-reduced-motion: reduce) {
  .shot__sound:active { transform: none; }
}

/* The drift layer exists so three transforms never fight over one
   element: .floater carries the drag offset, .drift carries the
   ambient motion, and anything inside is free to do its own thing. */
.drift {
  display: block;
  will-change: transform;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
  animation-fill-mode: both;
}

/* A frame with no frame. The tint is a placeholder standing in for
   an image that hasn't landed yet — once a real src exists the
   picture covers it and no rectangle is drawn at all. */
.shot {
  display: block;
  position: relative;
  overflow: hidden;
  background: var(--paper-dim);
}

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

.shot--wide     { aspect-ratio: 16 / 9; }
.shot--tall     { aspect-ratio: 3 / 4; }
.shot--square   { aspect-ratio: 1 / 1; }
.shot--portrait { aspect-ratio: 4 / 5; }

/* Native ratio, from --ratio set inline by js/work-detail.js from the
   file's own pixels. Nothing is cropped: the box IS the file's shape. */
.shot--native   { aspect-ratio: var(--ratio); }

/* Portrait media is capped by height rather than filling its grid
   slot's width — a 9:16 clip across nine columns would be taller than
   the screen. Width follows from the ratio; the figure sits at the
   start of its slot rather than stretching. */
.floater--upright {
  max-width: calc(min(78vh, 56rem) * var(--ratio));
  justify-self: start;
}

.shot__empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 1rem;
  text-align: center;
  font-size: var(--step-0);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.floater--portrait {
  grid-column: 9 / span 4;
  /* Four rows: a, b, c, d - exactly the rows the passages occupy,
     and no more. The count is load-bearing in a way that took three
     tries to see (Sept 13 2026). Auto-placed items fill rows the
     portrait doesn't block, so if its span runs past the last passage
     row, the next item has to skip to the row after the span and
     every row in between exists as an EMPTY track - two 46px rows,
     once, and a passage a screen below the one before it. Four
     passages with a row-gap between them are taller than the
     portrait, so nothing stretches. If a fifth passage is ever
     added, this becomes span 5. */
  grid-row: 1 / span 4;
  align-self: start;
}
.floater--lead     { grid-column: 2 / span 9; }
.floater--b        { grid-column: 1 / span 5; }
.floater--c        { grid-column: 7 / span 5; margin-top: clamp(1.5rem, 5vw, 4rem); }
.floater--d        { grid-column: 3 / span 6; }

/* ---------- Work detail ---------- */

/* justify-self matters more than it looks: a grid item is blockified,
   so .link-line would stretch to fill its column and its underline —
   drawn with a background gradient at 100% width — would render as a
   300px rule across the page. Exactly the thing this layout removes. */
.link-line { justify-self: start; }
.work-field__loading { grid-column: 1 / span 6; font-size: var(--step-2); margin: 0; }

.work-title {
  grid-column: 1 / span 8;
  margin: 0;
  font-size: var(--step-8);
  font-weight: 400;
  line-height: 1.04;
  letter-spacing: -0.01em;
}

.work-marks {
  grid-column: 9 / span 4;
  align-self: end;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.25rem;
}

/* ---------- The case study, as a chain ----------
   Five passages read in order. The number is the whole handle: it
   marks position in the sequence AND folds its passage away. There
   are no prose headings between the paragraphs — they chopped the
   reading — and no connecting lines, because edges between short
   labels read as a diagram but edges between paragraphs read as a
   flowchart of prose, which is the opposite of what these pages are.
   Order is carried by the numbers and by the step down the page. */

/* Two columns of passages, read down then across, the way columns of
   prose are read. Multi-column layout rather than a two-column grid:
   it balances the two columns by height on its own, and a numbered
   sequence flowing 01-02-03 down the left and 04-05 down the right is
   the newspaper convention. A grid would have paired 01 with 02 on a
   row and left holes under whichever of the pair ran shorter.

   The descending indent from the single column is kept (Frank's
   call), and it runs as ONE staircase through the whole sequence
   rather than restarting in each column: passage 03 sits two steps
   in wherever it lands, 05 four. That is what lets it survive
   multicol at all — the break between columns moves with content and
   viewport, so a stagger that restarted at the top of the right
   column would need to know where the break fell. A continuous one
   doesn't care.

   The step is about two-thirds of what the single column used.
   Indent is subtracted from the measure, and five steps of the old
   size would have taken the last passage under 40 characters. */
.beats {
  grid-column: 2 / span 10; /* two more than the single column had:
                               two measures side by side, each giving
                               up to five steps of indent, need the
                               room or the lines run short */
  column-count: 2;
  column-gap: clamp(2rem, 4vw, 4rem);
}

.beat {
  --step: clamp(0.75rem, 1.5vw, 1.5rem);
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: clamp(1rem, 2.5vw, 2rem);
  align-items: start;
  break-inside: avoid;        /* a passage never splits across the fold */
  margin-bottom: clamp(1.5rem, 3.5vw, 2.5rem);
}

/* Capped at the fifth step — past that the indent eats the measure
   instead of shaping it. */
.beat:nth-child(2) { margin-left: var(--step); }
.beat:nth-child(3) { margin-left: calc(var(--step) * 2); }
.beat:nth-child(4) { margin-left: calc(var(--step) * 3); }
.beat:nth-child(5) { margin-left: calc(var(--step) * 4); }
.beat:nth-child(n + 6) { margin-left: calc(var(--step) * 5); }

.beat__num {
  font-family: var(--font);
  font-size: var(--step-1);
  line-height: 1.75;          /* sits on the first line of the passage */
  background: none;
  border: 0;
  padding: 0;
  color: var(--ink-soft);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
  transition: color 0.2s var(--ease-out), transform 0.14s var(--ease-out);
}

.beat__num:active { transform: scale(0.94); }
.beat__num:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

@media (hover: hover) and (pointer: fine) {
  .beat__num:hover { color: var(--ink); }
}

/* A folded number keeps its place in the chain so the sequence never
   renumbers itself under the reader. */
.beat.is-closed .beat__num { color: var(--ink-soft); opacity: 0.55; }

/* grid-template-rows 1fr -> 0fr is the one clean way to animate a
   collapse to its own content height. It touches layout, which
   transform and opacity avoid — accordions are the case where there
   is no transform equivalent, so it is the sanctioned exception. */
.beat__body {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 240ms var(--ease-out),
              opacity 240ms var(--ease-out);
}

.beat.is-closed .beat__body {
  grid-template-rows: 0fr;
  opacity: 0;
  transition-duration: 180ms;   /* folding away is faster than opening */
}

.beat__body > p {
  overflow: hidden;             /* required for the 0fr row to clip */
  margin: 0;
  font-size: var(--step-2);     /* this is the body copy of the page */
  line-height: 1.6;
  max-width: 56ch;
}

@media (prefers-reduced-motion: reduce) {
  .beat__body { transition: opacity 150ms var(--ease-out); }
}

.next-node {
  grid-column: 1 / span 6;
  display: grid;
  gap: 0.3rem;
  text-decoration: none;
  color: var(--ink);
}

.next-node__title {
  font-size: var(--step-6);
  line-height: 1.1;
  border-bottom: 1px solid transparent;
  transition: border-color 0.2s var(--ease-out);
  justify-self: start;
}

@media (hover: hover) and (pointer: fine) {
  .next-node:hover .next-node__title { border-bottom-color: var(--ink); }
}

/* Two next-nodes side by side, as the About page closes (Sept 13
   2026). Same component as the project pages' "Next project"; the
   second sits in the right half of the field so the pair reads as a
   fork, not a list. */
.next-nodes {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: clamp(1rem, 2vw, 1.75rem);
  row-gap: clamp(2rem, 6vw, 4rem);
}

.next-nodes .next-node { grid-column: 1 / span 5; }
.next-nodes .next-node--second { grid-column: 7 / span 5; }

@media (max-width: 860px) {
  .next-nodes { display: grid; }
  .next-nodes .next-node,
  .next-nodes .next-node--second { grid-column: 1 / -1; }
}

/* ---------- Contact ---------- */

.passage--name {
  grid-column: 1 / span 8;
  margin: 0;
  font-size: var(--step-6);
  font-weight: 400;
  line-height: 1.1;
}

.passage--name span { color: var(--ink-soft); }

.reach { margin: 0; display: grid; gap: 0.3rem; justify-items: start; }

/* Rows are named here rather than left to auto-placement. With seven
   objects on the field, the order they happen to be written in stops
   being a composition - the two addresses together, the four outward
   links scattered across two rows, the CV last and the note beside
   it. Below 860px the field becomes a single column and every one of
   these is ignored, which is the intent. */
.reach--a { grid-column: 1 / span 5; grid-row: 2; }
.reach--b { grid-column: 7 / span 5; grid-row: 2; }
.reach--c { grid-column: 2 / span 3; grid-row: 3; }
.reach--e { grid-column: 7 / span 3; grid-row: 3; }
.reach--f { grid-column: 1 / span 3; grid-row: 4; }
.reach--g { grid-column: 6 / span 3; grid-row: 4; }
.reach--d { grid-column: 9 / span 4; grid-row: 5; }
.reach .label { color: var(--ink-soft); }

.reach__link {
  font-size: var(--step-4);
  line-height: 1.2;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.2s var(--ease-out);
}

.reach__link--waiting { color: var(--ink-soft); }

/* ---------- Links that leave the site ----------
   The place name is the whole label. A mark appears beside it to say
   the link opens elsewhere - drawn as a hairline rather than set as
   the arrow character, because the page has exactly one typeface and
   it cannot be relied on to carry that glyph. ---------- */

.reach__link--out {
  display: inline-flex;
  align-items: baseline;
  gap: 0.3em;
}

/* The mark holds its space at all times, shown or not: revealing it
   must not shove the word it belongs to sideways. */
.reach__out {
  display: inline-block;
  flex: none;
  width: 0.5em;
  color: var(--ink-soft);
}

.reach__out svg { display: block; width: 100%; height: auto; }

@media (hover: hover) and (pointer: fine) {
  a.reach__link:hover { border-bottom-color: var(--ink); }

  /* Where there is a pointer, the mark waits to be asked - this is a
     hover, seen dozens of times a session, so it is 140ms and two
     pixels and no more. On touch there is nothing to ask with, so the
     mark simply stays out, which is why this lives in here. */
  .reach__out {
    opacity: 0;
    transform: translate3d(-2px, 2px, 0);
    transition: opacity 140ms var(--ease-out),
                transform 140ms var(--ease-out);
  }

  .reach__link--out:hover .reach__out,
  .reach__link--out:focus-visible .reach__out {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

/* Keyboard reaches it the same way a pointer does. */
.reach__link--out:focus-visible .reach__out { opacity: 1; }

/* The mark still arrives, it just arrives without travelling. */
@media (prefers-reduced-motion: reduce) {
  .reach__out,
  .reach__link--out:hover .reach__out,
  .reach__link--out:focus-visible .reach__out { transform: none; }
}

.note--contact { grid-column: 2 / span 5; grid-row: 5; }

/* ---------- End marks (replaces the inverted footer slab) ---------- */

/* Both marks sit to the left. They used to hold opposite ends of the
   line, but the lower right belongs to the Navigate cue now, and a
   branch opening on top of the copyright is worse than an asymmetry
   nobody will notice. */
.end-marks {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: flex-start;
  gap: clamp(1rem, 4vw, 3rem);
  padding: 0 var(--gutter) clamp(2rem, 5vw, 3.5rem);
}

.end-marks p { margin: 0; }
.end-marks a { color: inherit; }

.end-marks__mail {
  font-size: var(--step-4);
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.2s var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .end-marks__mail:hover { border-bottom-color: var(--ink); }
}

/* .end-marks__inline is gone: work pages closed on a single small
   line while every other page closed on the large address and a quiet
   second line. They all use the same <footer class="end-marks"> now. */

/* ---------- Narrow screens ----------
   The grid is what creates the loose placement, and there isn't room
   for it on a phone. Blocks stack in reading order instead; the drift
   and the dragging both still work. */

@media (max-width: 860px) {
  .field,
  .work-field { display: block; padding-bottom: clamp(3rem, 12vw, 5rem); }

  .field > *,
  .work-field > * { margin-bottom: clamp(2rem, 9vw, 3.5rem); }

  .floater--c { margin-top: 0; }
  .beats { column-count: 1; }  /* one column of prose on a phone */
  .beat { --step: 0.5rem; }    /* the staircase stays, in miniature —
                                  five full steps would be an eighth
                                  of a phone's width */
  .work-title { font-size: var(--step-7); }
}

/* ============================================================
   Ambient drift

   Nodes and free media breathe slightly rather than sitting dead
   still. Amplitude is 2-4px on purpose: these are click targets, and
   anything larger starts to move the thing you are reaching for.
   For the same reason the motion PAUSES the moment a pointer is over
   a node or a drag begins — by the time you commit to a target it is
   holding still.

   CSS animation rather than JS: it is predetermined, it loops
   forever, and it runs off the main thread, so it stays smooth while
   the page is still loading images.

   The easing is the built-in ease-in-out rather than this file's
   --ease-in-out. The strong curve is right for a one-shot move, but
   on an infinite loop it reads as a lurch at each turn.
   ============================================================ */

@keyframes drift-a {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(2px, -4px, 0); }
}

@keyframes drift-b {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(-3px, 3px, 0); }
}

@keyframes drift-c {
  0%, 100% { transform: translate3d(0, 0, 0); }
  33%      { transform: translate3d(3px, 2px, 0); }
  66%      { transform: translate3d(-2px, -3px, 0); }
}

.node__drift {
  display: inline-block;
  will-change: transform;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
  animation-fill-mode: both;
}

/* Hold still when the visitor is going for it. */
.node.is-dragging .node__drift,
.floater.is-dragging .drift,
.is-dragging-something .node__drift,
.is-dragging-something .drift { animation-play-state: paused; }

.node:focus-within .node__drift,
.floater:focus-within .drift { animation-play-state: paused; }

@media (hover: hover) and (pointer: fine) {
  .node:hover .node__drift,
  .floater:hover .drift,
  .reach__link:hover { animation-play-state: paused; }
}

@media (prefers-reduced-motion: reduce) {
  .node__drift,
  .drift { animation: none !important; }
}

/* ---------- Dark ----------
   A print negative of the same sheet, not a second design: the ink
   and the paper swap and nothing else moves. Neither end is pure —
   #000 paper under #fff ink glares, and the site's whole idea is
   printed rather than clinical.

   Three states, handled in this order:
     1. no choice made        -> follow prefers-color-scheme
     2. explicit "dark"       -> dark regardless of the machine
     3. explicit "light"      -> light regardless of the machine
   The :not([data-theme="light"]) guard on the media query is what
   makes 3 possible; without it a light choice loses to a dark OS.

   Media is untouched. The work is the work in either mode. ---------- */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper: #111110;
    --paper-dim: #1c1b19;
    --ink: #f2f0ea;
    --ink-soft: #a5a29a;
    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --paper: #111110;
  --paper-dim: #1c1b19;
  --ink: #f2f0ea;
  --ink-soft: #a5a29a;
  color-scheme: dark;
}

/* ---------- The light / dark control ----------
   Same language as the works filters: no box, a rule under the word,
   and the active one at full ink. Two words rather than one, because
   a lone "Dark" in a corner reads ambiguously as either the state or
   the action. ---------- */

.theme {
  display: inline-flex;
  align-items: baseline;
  gap: 0.9rem;
}

.theme__btn {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  background: none;
  border: 0;
  padding: 0.2rem 0;
  color: var(--ink-soft);
  cursor: pointer;
  border-bottom: 1px solid transparent;
  transition: color 0.2s var(--ease-out),
              border-color 0.2s var(--ease-out),
              transform 0.14s var(--ease-out);
}

.theme__btn:active { transform: scale(0.94); }
.theme__btn:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

.theme__btn[aria-pressed="true"] {
  color: var(--ink);
  border-bottom-color: var(--ink);
}

@media (hover: hover) and (pointer: fine) {
  .theme__btn:hover { color: var(--ink); }
}

/* On the canvas it is a corner mark like the others. */
.mark--theme {
  top: var(--gutter);
  right: var(--gutter);
  pointer-events: auto;
}

/* Only offered once the view has actually moved — a reset for a thing
   that is already at rest is noise. */
/* The lower left is the view corner: the zoom read-out on top, the
   reset under it, one grid so the reset can come and go without
   shifting anything above it. */
.mark--view {
  left: var(--gutter);
  bottom: var(--gutter);
  pointer-events: auto;
  display: grid;
  gap: 0.55rem;
  justify-items: start;
}

/* Only offered once the view has actually moved - a reset for a thing
   that is already at rest is noise. It fades instead of vanishing,
   and keeps its row either way: a button that appeared by shoving the
   zoom bar upward would make the corner twitch every time a visitor
   touched the paper. */
.view-reset {
  opacity: 0;
  visibility: hidden;
  transition: opacity 160ms var(--ease-out),
              visibility 0s linear 160ms,
              color 0.2s var(--ease-out),
              transform 0.14s var(--ease-out);
}

.view-reset.is-shown {
  opacity: 1;
  visibility: visible;
  transition: opacity 200ms var(--ease-out),
              visibility 0s linear 0s,
              color 0.2s var(--ease-out),
              transform 0.14s var(--ease-out);
}

/* ---------- Zoom ----------
   A hairline, a tick riding it, and the number beside it. No box and
   no plus/minus buttons: the same drafting-mark language as the rest
   of the corners.

   It is a real <input type="range">, which is why there is a page of
   vendor pseudo-elements below. The alternative - a div with pointer
   handlers - would have cost arrow keys, Home/End, page-up, the
   drag-anywhere-on-the-track behaviour and the screen-reader
   announcement, all of which come free here. ---------- */

.zoom {
  display: flex;
  align-items: center;
  gap: 0.7rem;
}

.zoom__track {
  position: relative;
  display: block;
  width: 8.5rem;
}

/* Where 1x sits on the track. --zoom-home is written by
   js/node-tree.js from the same limits the slider maps, so the mark
   cannot drift away from the truth if those limits change. */
.zoom__track::after {
  content: "";
  position: absolute;
  left: var(--zoom-home, 52%);
  top: calc(50% + 0.2rem);
  width: 1px;
  height: 0.28rem;
  background: var(--ink-soft);
  opacity: 0.7;
  pointer-events: none;
}

.zoom__range {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 1.1rem;
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  cursor: ew-resize;
}

.zoom__range::-webkit-slider-runnable-track {
  height: 1px;
  background: var(--ink-soft);
  opacity: 0.55;
}

.zoom__range::-moz-range-track {
  height: 1px;
  background: var(--ink-soft);
  opacity: 0.55;
}

/* A tick, not a knob. Thin enough to read as a drafting mark; the
   whole track takes the press, so it never has to be aimed at. */
.zoom__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 2px;
  height: 0.85rem;
  margin-top: -0.42rem;
  background: var(--ink);
  border: 0;
  border-radius: 0;
  transition: transform 120ms var(--ease-out);
}

.zoom__range::-moz-range-thumb {
  width: 2px;
  height: 0.85rem;
  background: var(--ink);
  border: 0;
  border-radius: 0;
  transition: transform 120ms var(--ease-out);
}

/* The only motion here: the tick stands up under the finger. */
.zoom__range:active::-webkit-slider-thumb { transform: scaleY(1.25); }
.zoom__range:active::-moz-range-thumb { transform: scaleY(1.25); }

.zoom__range:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

.zoom__value {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  min-width: 3.4em;   /* 100% -> 95% must not shuffle the corner */
}

@media (prefers-reduced-motion: reduce) {
  .zoom__range:active::-webkit-slider-thumb { transform: none; }
  .zoom__range:active::-moz-range-thumb { transform: none; }
}

/* ---------- Compass ----------
   When the root is off screen, an arrow rides the edge of the canvas
   pointing at it. The button is placed and the arrow inside it is
   rotated separately, so the rotation never reaches the accessible
   name or the focus ring. ---------- */

.compass {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 5;
  display: grid;
  place-items: center;
  width: 2.6rem;
  height: 2.6rem;
  background: none;
  border: 0;
  padding: 0;
  /* Full ink, not the soft grey the other marks use. Every other mark
     is a quiet aside; this one only exists when the visitor is lost,
     and something that appears to be found should not be faint. */
  color: var(--ink);
  cursor: pointer;
  will-change: transform, opacity;

  /* Hidden is the resting state, and it fades rather than cuts.
     `hidden` was doing this job before and had to go: display:none
     cannot transition, so the arrow snapped into existence at the
     exact moment the visitor was already disoriented.

     Only opacity moves. The button's transform is rewritten by
     js/node-tree.js on every frame to place and aim it, so transform
     is not a channel this can borrow — which is just as well, since
     an arrow that slides while pointing would read as a second
     direction.

     visibility rides along with a delay so it flips only after the
     fade finishes, keeping the button out of the tab order and out of
     the way of clicks while it is invisible.

     No prefers-reduced-motion override, deliberately: this is a pure
     opacity fade with no movement in it, which is already the gentle
     variant that override would ask for. */
  opacity: 0;
  visibility: hidden;
  transition: opacity 140ms var(--ease-out),
              visibility 0s linear 140ms;
}

/* Exits faster than it enters: arriving is information, leaving is
   just tidying up after the visitor already found their way back. */
.compass.is-shown {
  opacity: 1;
  visibility: visible;
  transition: opacity 200ms var(--ease-out),
              visibility 0s linear 0s;
}

.compass__arrow {
  display: block;
  width: 1.15rem;   /* the stepper's arrow, at the stepper's size */
}

/* The shared arrow points down; the canvas aims this span with 0deg
   meaning right (atan2 in js/node-tree.js). So the SVG itself is
   turned a quarter anticlockwise, once, and the span's rotation
   composes on top of it. */
.compass__arrow svg {
  display: block;
  width: 100%;
  height: auto;
  transform: rotate(-90deg);
}

.compass:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; }

@media (hover: hover) and (pointer: fine) {
  .compass:hover { opacity: 0.6; }
}

/* On a page it sits with the way back, both to the right. */
@media (prefers-reduced-motion: reduce) {
  .theme__btn:active { transform: none; }
}


/* ---------- Light / dark crossfade ----------
   Switching mode repaints every surface on the page at once. Cut
   straight from one to the other and the eye reads it as a flash;
   dissolved over a quarter second it reads as the lights changing.

   Nothing moves - this is a blend of two colour states and no more -
   which is also why there is no prefers-reduced-motion override here.
   A motionless crossfade is already the gentle variant that override
   would ask for, and cutting it would put the flash back.

   The duration sits at the short end (260ms) because the switch is a
   control, not a scene: long enough to read as a dissolve, short
   enough that a visitor toggling twice is not waiting on it.
   ---------- */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 260ms;
  /* Linear, deliberately. The two snapshots are composited together;
     easing either one makes their opacities sum to something other
     than 1 through the middle of the fade, which shows up as a bright
     or dark pulse halfway across. */
  animation-timing-function: linear;
}

/* Registration for the fallback path in js/theme.js. An unregistered
   custom property is just a string to the engine, so `transition:
   --ink` does nothing at all; with a syntax and an initial value it
   becomes a colour that can be interpolated. The initial values are
   the light tokens - they are never actually used, since :root sets
   all four, but a registered property must declare one. */
@property --paper {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffffff;
}

@property --paper-dim {
  syntax: "<color>";
  inherits: true;
  initial-value: #f1f1f1;
}

@property --ink {
  syntax: "<color>";
  inherits: true;
  initial-value: #16140f;
}

@property --ink-soft {
  syntax: "<color>";
  inherits: true;
  initial-value: #5c5a55;
}

/* Only while a switch is in flight. Left on permanently it would put
   a style recalculation of the entire document behind every colour
   change on the page. */
:root.is-theme-fading {
  transition: --paper 260ms linear,
              --paper-dim 260ms linear,
              --ink 260ms linear,
              --ink-soft 260ms linear;
}


/* ---------- The Navigate cue ----------
   The only navigation on an inner page, and the node tree's logic
   folded into one corner: a quiet word in the lower right that grows a
   short branch upward when asked - Navigate, then All works, then
   Home.

   It holds low ink until it is wanted. The corner is the emptiest part
   of these pages and the place a hand already rests, and a navigation
   that announces itself on a page whose whole subject is one piece of
   work would be competing with the work.

   The open state is a class, not :hover. A touch browser fires a
   synthetic hover on tap that never leaves, which strands a CSS-only
   hover menu open on a phone; js/navcue.js opens on pointer only where
   there is a real pointer, and on focus for the keyboard. ---------- */

.navcue {
  --navcue-gap: 1.1rem;
  --rungs: 3;              /* how many sit above the trigger: Contact,
                              All works, Home. Feeds the closing
                              stagger only. The filter cue overrides
                              it - it has four. */
  position: fixed;
  right: var(--gutter);
  bottom: var(--gutter);
  z-index: 6;
  display: flex;
  flex-direction: column-reverse;   /* the trigger sits at the bottom */
  align-items: flex-end;
  gap: var(--navcue-gap);
  margin: 0;
}

.navcue__trigger {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  background: none;
  border: 0;
  padding: 0.2rem 0;
  color: var(--ink);
  cursor: pointer;
  opacity: 0.4;
  transition: opacity 200ms var(--ease-out),
              transform 140ms var(--ease-out);
}

.navcue__trigger:active { transform: scale(0.94); }
.navcue__trigger:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }
.navcue.is-open .navcue__trigger { opacity: 1; }

.navcue__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-end;
  gap: var(--navcue-gap);
}

/* Closed. Each rung waits a little further down than the one below it,
   so the branch is folded into the trigger rather than stacked above
   it out of sight - opening unfolds, it does not slide a finished list
   into view. --i is the rung's distance from the trigger. */
.navcue__item {
  position: relative;
  opacity: 0;
  visibility: hidden;
  transform: translate3d(0, calc(var(--i) * 0.8rem), 0);
  transition: opacity 170ms var(--ease-out) calc((var(--rungs) - var(--i)) * 35ms),
              transform 170ms var(--ease-out) calc((var(--rungs) - var(--i)) * 35ms),
              visibility 0s linear 240ms;
}

.navcue__item:nth-child(1) { --i: 1; }
.navcue__item:nth-child(2) { --i: 2; }
.navcue__item:nth-child(3) { --i: 3; }
.navcue__item:nth-child(4) { --i: 4; }

/* The edge down to whatever sits below - the same hairline the node
   tree draws between a parent and its children, stood on end. It grows
   from its bottom, so the branch reads as travelling up out of the
   trigger rather than dropping in from above. */
.navcue__item::after {
  content: "";
  position: absolute;
  right: 0.5em;
  top: 100%;
  width: 1px;
  height: var(--navcue-gap);
  background: var(--ink);
  transform: scaleY(0);
  transform-origin: 50% 100%;
  transition: transform 170ms var(--ease-out) calc((var(--rungs) - var(--i)) * 35ms);
}

/* Open. 260ms out and 170ms back, staggered 45ms - the same figures
   the node tree expands and collapses on, because this is the same
   gesture. Opening staggers upward from the trigger; closing runs the
   other way, so the branch retracts into the word it came from. */
.navcue.is-open .navcue__item {
  opacity: 1;
  visibility: visible;
  transform: translate3d(0, 0, 0);
  transition: opacity 260ms var(--ease-out) calc((var(--i) - 1) * 45ms),
              transform 260ms var(--ease-out) calc((var(--i) - 1) * 45ms),
              visibility 0s linear 0s;
}

.navcue.is-open .navcue__item::after {
  transform: scaleY(1);
  transition: transform 260ms var(--ease-out) calc((var(--i) - 1) * 45ms);
}

/* One rung is a button (Back to top) and two are links. They are the
   same object to look at, so the button is stripped back to type. */
.navcue__link {
  font-family: var(--font);
  font-size: var(--step-2);
  line-height: 1.2;
  color: var(--ink);
  background: none;
  padding: 0;
  text-decoration: none;
  border: 0;
  border-bottom: 1px solid transparent;
  cursor: pointer;
  transition: border-color 200ms var(--ease-out);
}

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

/* The cues used to wear a halo of paper - five stacked text-shadows
   in the page's own colour - so they stayed legible when a card slid
   under them. Over a picture that halo is a white glow around the
   letters (Frank, Sept 12 2026), so it is gone. Instead a cue that
   has media under it turns its words white, the way a caption sits on
   a photograph, and drops the rest-state fade so the white actually
   reads - 40% white on a mid-tone frame is no colour at all. Same in
   both modes: a photograph is a photograph whichever paper it sits
   on. "Under it" is measured by js/navcue.js, not guessed. */
.navcue.is-over-media .navcue__trigger,
.navcue.is-over-media .navcue__link,
.navcue.is-over-media .filter-btn,
.navcue.is-over-media .filter-group__label {
  color: #fff;
  opacity: 1;
}

.navcue.is-over-media .navcue__item::after { background: #fff; }

@media (hover: hover) and (pointer: fine) {
  .navcue.is-over-media .navcue__link:hover,
  .navcue.is-over-media .filter-btn:hover { border-bottom-color: #fff; }
}

/* The page you are already standing on is named, not offered. */
.navcue__link[aria-current="page"] { color: var(--ink-soft); }

@media (hover: hover) and (pointer: fine) {
  .navcue__link:hover { border-bottom-color: var(--ink); }
  .navcue__link[aria-current="page"]:hover { border-bottom-color: transparent; }
}

/* Room at the foot of every page so the cue never sits on the closing
   marks. */
.end-marks { padding-bottom: clamp(4rem, 8vw, 5.5rem); }

/* The branch still arrives and leaves, it just does not travel to do
   it - and the edges are structure rather than motion, so they are
   simply there. */
@media (prefers-reduced-motion: reduce) {
  .navcue__item,
  .navcue.is-open .navcue__item { transform: none; }
  .navcue__item::after,
  .navcue.is-open .navcue__item::after { transform: scaleY(1); }
  .navcue__trigger:active { transform: none; }
}


/* ---------- Works grid: parallax, hover, resize ----------
   Three behaviours on one card, each on its own element.

   SCROLL. The media drifts a few per cent against the page as the
   card crosses the viewport - enough to separate the image from the
   paper it sits on, not enough to notice as an effect. It is a
   view() scroll timeline rather than a scroll handler, so it runs off
   the main thread and cannot stutter while images decode; where the
   browser has no such timeline nothing moves, which is a fine place
   to land.

   HOVER. 1.03 over 400ms, the same figure the site already uses on
   media elsewhere. A grid is browsed dozens of times in a sitting, so
   this stays at the near-imperceptible end.

   RESIZE. A corner grip, two hairlines, that waits for the pointer
   the way the outward-link mark on Contact does. ---------- */

.media-frame__move {
  position: absolute;
  inset: -4% 0;           /* taller than the frame, so no edge shows */
  /* Longhands, not the shorthand: `animation: name linear both` sets
     animation-duration to 0s, and a progress-based timeline needs it
     left at auto to stretch across the range. */
  animation-name: card-parallax;
  animation-duration: auto;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: --card-view;
  animation-range: cover 0% cover 100%;
  will-change: transform;
}

@keyframes card-parallax {
  from { transform: translate3d(0, -3%, 0); }
  to   { transform: translate3d(0, 3%, 0); }
}

.media-frame__grip {
  position: absolute;
  right: 0;
  bottom: 0;
  z-index: 2;
  width: 1.15rem;
  height: 1.15rem;
  cursor: nwse-resize;
  touch-action: none;
}

.media-frame__grip::before,
.media-frame__grip::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 0;
  background: var(--ink);
  transition: opacity 160ms var(--ease-out);
}

.media-frame__grip::before { width: 1.15rem; height: 1px; }
.media-frame__grip::after  { width: 1px; height: 1.15rem; }

/* While a frame is being re-cropped it should track the hand exactly,
   so the image inside holds still and nothing eases. */
.media-frame.is-resizing .media-frame__move { animation-play-state: paused; }

@media (hover: hover) and (pointer: fine) {
  .work-card:hover .media-frame img,
  .work-card:hover .media-frame video { transform: scale(1.03); }

  .work-card:hover .caption__title { border-bottom-color: var(--ink); }

  /* A card in the hand is being moved, not admired. */
  .work-card.is-dragging .media-frame img,
  .work-card.is-dragging .media-frame video { transform: none; }

  /* The grip waits to be asked. On touch there is no hover to ask
     with, so it simply stays out. */
  .media-frame__grip::before,
  .media-frame__grip::after { opacity: 0; }

  .work-card:hover .media-frame__grip::before,
  .work-card:hover .media-frame__grip::after,
  .media-frame.is-resizing .media-frame__grip::before,
  .media-frame.is-resizing .media-frame__grip::after { opacity: 1; }
}

/* Nothing drifts and nothing scales. The frame keeps its media, the
   grip keeps working - resizing is a thing the visitor asked for, not
   motion the page decided on. */
@media (prefers-reduced-motion: reduce) {
  .media-frame__move { animation: none; inset: 0; }
}


/* ---------- The filter cue ----------
   The same object as the Navigate cue in every way that matters -
   same classes, same open and close behaviour, same figures, same
   direction - standing in the opposite corner.

   Lower left, growing upward, mirroring Navigate across the page. The
   two corners at the foot of the screen are where this site keeps the
   things you reach for rather than read, and a pair that behave
   identically is easier to learn than two that each have their own
   idea about which way a branch goes.

   Everything below is a reflection: the geometry, the folding and the
   edges all come from .navcue unchanged. js/works.js shows it only
   while the real bar is off screen and the foot of the page is not.
   ---------- */

/* ---------- Section stepper ----------
   Two arrows, bottom centre, between the two corner cues. Same rest
   opacity, same hairline arrow as the sort control, same turn white
   over media. The pair keeps its shape at either end: the arrow with
   nowhere to go fades, it doesn't leave. Built by js/stepper.js. */
.stepper {
  position: fixed;
  left: 50%;
  bottom: var(--gutter);
  translate: -50% 0;
  z-index: 30;
  /* Up above down — a two-way control, like the pair on a lift —
     rather than side by side. */
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  align-items: center;
}

.stepper[hidden] { display: none; }

.stepper__btn {
  background: none;
  border: 0;
  padding: 0.35rem 0.3rem;
  margin: 0;
  color: var(--ink);
  cursor: pointer;
  opacity: 0.4;
  transition: opacity 200ms var(--ease-out), transform 140ms var(--ease-out);
}

.stepper__btn:active { transform: scale(0.94); }
.stepper__btn:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; opacity: 1; }

@media (hover: hover) and (pointer: fine) {
  .stepper__btn:hover { opacity: 1; }
}

.stepper__btn.is-end { opacity: 0.15; pointer-events: none; }

.stepper__glyph { display: block; width: 0.72rem; } /* the sort arrow's size */
.stepper__glyph svg { display: block; width: 100%; height: auto; }
.stepper__btn--up .stepper__glyph { transform: rotate(180deg); }

/* Over a picture the arrows go white and full, like the cues. */
.stepper.is-over-media .stepper__btn { color: #fff; opacity: 1; }
.stepper.is-over-media .stepper__btn.is-end { opacity: 0.35; }

.navcue--filter {
  --rungs: 4;              /* Type, Year, Made for, and the tools */
  top: auto;
  right: auto;
  bottom: var(--gutter);
  left: var(--gutter);
  align-items: flex-start;
  opacity: 0;
  visibility: hidden;
  transition: opacity 200ms var(--ease-out),
              visibility 0s linear 200ms;
}

.navcue--filter.is-available {
  opacity: 1;
  visibility: visible;
  transition: opacity 200ms var(--ease-out),
              visibility 0s linear 0s;
}

.navcue--filter .navcue__list { align-items: flex-start; }

/* The one thing a mirror actually changes: the edge hangs off the
   left of a rung rather than the right. */
.navcue--filter .navcue__item::after {
  right: auto;
  left: 0.5em;
}

/* A rung here is a whole filter group, not a word. */
.navcue--filter .filter-group { margin: 0; }


/* ---------- Filter options, and the two grid tools ----------

   OPTIONS ON HOVER. In the bar a group shows its name and its answer;
   the alternatives arrive when the pointer does. The space they take
   is held open whether they are showing or not, so a group never
   changes width and the row never shifts under the hand - the same
   rule as the outward-link mark on Contact and the resize grip on a
   card.

   Only in the bar. Inside the corner cue the whole branch is already
   hover-revealed, and a second hover inside the first would be a door
   behind a door.

   And only where there is a pointer: on touch there is nothing to
   reveal them with, so they stay out.

   SORT is a glyph and nothing else, per Frank - an arrow that turns
   over. Down is newest first, up is oldest first, and the accessible
   name carries what the arrow cannot say out loud.

   TIDY appears only once something has been moved. ---------- */

@media (hover: hover) and (pointer: fine) {
  .filters .filter-btn[aria-pressed="false"] {
    opacity: 0;
    transition: opacity 160ms var(--ease-out),
                color 0.2s var(--ease-out),
                border-color 0.2s var(--ease-out);
  }

  /* Revealed by a pointer over the group, or by a keyboard standing
     in it. :has(:focus-visible) rather than :focus-within on purpose:
     a mouse click focuses the button it hits, and :focus-within would
     therefore hold the whole group open after every choice - long
     after the pointer had gone somewhere else. */
  .filters .filter-group:hover .filter-btn,
  .filters .filter-group:has(:focus-visible) .filter-btn { opacity: 1; }

  /* Settled: a choice has just been made with the pointer, so the
     alternatives stand down again without waiting for it to move.
     js/works.js clears this when the pointer leaves the group, which
     is what lets a second look bring them back.

     The guard is :not(:has(:focus-visible)), NOT :not(:focus-within).
     Clicking a button focuses it, so :focus-within is true after
     every mouse click - which held the alternatives open and was
     exactly the bug this rule exists to fix. :focus-visible is the
     one that means a keyboard is standing here, and a keyboard user
     must never have the thing under their focus hidden. */
  .filters .filter-group.is-settled:not(:has(:focus-visible)) .filter-btn[aria-pressed="false"] {
    opacity: 0;
  }
}

.filter-tools { gap: 0.9rem; }

.tool-btn {
  display: inline-grid;
  place-items: center;
  width: 1.4rem;
  height: 1.4rem;
  padding: 0;
  background: none;
  border: 0;
  color: var(--ink-soft);
  cursor: pointer;
  transition: color 0.2s var(--ease-out),
              transform 0.14s var(--ease-out);
}

.tool-btn:active { transform: scale(0.94); }
.tool-btn:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

.tool-btn__glyph {
  display: block;
  width: 0.72rem;
  transition: transform 200ms var(--ease-out);
}

.tool-btn__glyph svg { display: block; width: 100%; height: auto; }

/* The whole control is this half-turn, so it is the one thing here
   that must read as movement rather than as a swap. */
.tool-btn[data-order="asc"] .tool-btn__glyph { transform: rotate(180deg); }

@media (hover: hover) and (pointer: fine) {
  .tool-btn:hover { color: var(--ink); }
}

/* Holds its place while hidden, so the end of the row does not jump
   the first time a card is moved. */
.tool-tidy {
  opacity: 0;
  visibility: hidden;
  transition: opacity 160ms var(--ease-out),
              visibility 0s linear 160ms,
              color 0.2s var(--ease-out),
              border-color 0.2s var(--ease-out);
}

.tool-tidy.is-shown {
  opacity: 1;
  visibility: visible;
  transition: opacity 200ms var(--ease-out),
              visibility 0s linear 0s,
              color 0.2s var(--ease-out),
              border-color 0.2s var(--ease-out);
}

/* The way home. --ease-in-out because this is a thing moving across
   the screen from one place to another, not something arriving or
   leaving. Under reduced motion the global rule drops transform from
   the transition list and the card simply lands. */
.work-card.is-homing { transition: transform 320ms var(--ease-in-out); }

/* The tools hang off nothing. Sorting and tidying are things you do
   to the grid, not filters on the work in it, so the branch stops at
   the last filter group and they stand apart above it. */
.navcue__item--tools::after { content: none; }

/* A rung that has nothing to offer is not a rung. display:none rather
   than a fade, so the branch closes up behind it and the edge above
   reaches the trigger instead of a gap. */


/* The CV, now on About. It sits beside the facts list rather than
   under it - same row, other side of the page. */
/* ---------- The CV (About page, Sept 13 2026) ----------
   On the page rather than behind a download, in the house voice: no
   rules, no boxes; small caps for the section names and the dates,
   the serif for what was done and where. Each entry is one row -
   when | what / where - so the eye can run down the left edge for
   dates and the right for substance. Latest to earliest. */
.cv {
  grid-column: 1 / span 7;
  display: grid;
  gap: clamp(1.75rem, 4vw, 3rem);
}

.cv > .label { margin: 0; }

.cv__lead {
  margin: 0;
  font-size: var(--step-2);
  line-height: 1.5;
  display: grid;
  gap: 0.35rem;
  justify-items: start;
}

.cv__download {
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--ink-soft);
  padding-bottom: 1px;
  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .cv__download:hover { color: var(--ink); border-color: var(--ink); }
}

.cv__section { display: grid; gap: 0.9rem; }
.cv__section > .label { margin: 0; }

.cv__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.9rem;
}

.cv__item {
  display: grid;
  grid-template-columns: 9rem minmax(0, 1fr);
  column-gap: 1.25rem;
  align-items: baseline;
  font-size: var(--step-1);
  line-height: 1.5;
}

.cv__when {
  font-size: var(--step-0);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
  padding-top: 0.15em;
}

.cv__note { text-transform: none; letter-spacing: 0.02em; }

.cv__what { grid-column: 2; }
.cv__where { grid-column: 2; color: var(--ink-soft); }
.cv__where em { font-style: italic; }

@media (max-width: 620px) {
  .cv__item { grid-template-columns: 1fr; row-gap: 0.15rem; }
  .cv__what, .cv__where { grid-column: 1; }
}


/* ---------- Embedded player ----------
   A TikTok embed brings its own chrome with it - avatar, caption,
   buttons, the lot - and nothing can be done about that. What this
   does is refuse to add to it: no border, no tint, no container. It
   sits on the paper like any other frame and is sized to the box
   TikTok actually serves rather than to a ratio we would like it to
   have (9:16 is the video; the player is taller, because of the strip
   of TikTok above and below it).

   See js/work-detail.js for why it is the one frame on these pages
   that neither drags nor drifts. ---------- */

/* Six columns for the figure; the player inside is narrower than that
   (see .tiktok-embed) and starts at the figure's left edge. */
.embed {
  grid-column: 2 / span 6;
  margin: 0;
  display: grid;
  gap: 0.85rem;
  justify-items: start;
}

/* The sizes TikTok's generator writes inline, kept here instead -
   every other measurement on this site lives in this file. The script
   replaces the blockquote's contents with its player and reads this
   box for the width to build it at.

   325px, not the 605px the generator suggests. TikTok builds a fixed
   ~325px card whatever width it is given and centres it, painting the
   rest of the iframe white - invisible on white paper, a 605px white
   slab with the player in the middle of it in dark mode. Sized to the
   card, there is nothing left over to paint. The card's own caption
   strip below the video is TikTok's chrome inside a cross-origin
   frame; that part cannot be restyled from here. */
.embed .tiktok-embed {
  max-width: 325px;
  min-width: 325px;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  /* The one radius on the site (Frank's call, Sept 12 2026). TikTok's
     card has rounded corners of its own, and at each one the iframe's
     white body showed through as a little white ear — invisible on
     white paper, plain in dark mode. Clipping the frame to a larger
     radius hides them. 24px is Frank's number. */
  border-radius: 24px;
  overflow: hidden;
}

/* What shows in the seconds before the script answers - and for good
   if it never does, which is what a blocked tracker looks like. Quiet
   enough not to shout during a page load. */
.embed .tiktok-embed section {
  display: block;
  font-size: var(--step-1);
  line-height: 1.5;
  color: var(--ink-soft);
}

.embed .tiktok-embed section a { color: inherit; }

.embed iframe { display: block; border: 0; max-width: 100%; border-radius: 24px; }

/* YouTube: the whole slot, 16:9, no radius. The 24px above is for
   TikTok's rounded card and the white ears its iframe leaves at each
   corner; YouTube's player is a plain rectangle, so the site's square
   corners hold. Wider than TikTok's column too — a landscape player
   in a 325px slot would be a postage stamp. */
/* Instagram: their generator's max/min width, kept here where every
   other measurement lives. Their box-shadow, white fill and 3px radius
   are not carried over — embed.js paints the post inside an iframe
   with its own white card regardless, and we add nothing around it.
   Square corners: no rounded card here to leave ears around.
   In dark mode the post stays a white card; Instagram has no dark
   embed, and the frame is cross-origin. */
.embed--instagram { grid-column: 2 / span 7; }
.embed--instagram .instagram-media {
  width: 100%;
  max-width: 540px;
  min-width: 326px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
}
.embed--instagram .instagram-media > a {
  display: block;
  padding: 1rem 0;
  font-size: var(--step-1);
  color: var(--ink-soft);
  text-decoration: none;
  border-bottom: 1px solid var(--ink);
}
.embed--instagram iframe { border-radius: 0; }

.embed--youtube { grid-column: 2 / span 9; }
.embed--youtube .embed__frame {
  width: 100%;
  aspect-ratio: 16 / 9;
  height: auto;
  border-radius: 0;
  background: var(--paper-dim);
}

.embed__link {
  font-family: var(--font);
  font-size: var(--step-0);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  text-decoration: none;
  display: inline-flex;
  align-items: baseline;
  gap: 0.4em;
  border-bottom: 1px solid transparent;
  transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
}

.embed__out { display: inline-block; flex: none; width: 0.62em; }
.embed__out svg { display: block; width: 100%; height: auto; }

@media (hover: hover) and (pointer: fine) {
  .embed__link:hover { color: var(--ink); border-bottom-color: var(--ink); }
}
