/* jason-huff.com
 *
 * Ported from the prototype. Two grounds: home is white, every interior page is
 * black. The ground is decided server-side (`dark: true` in a page's front
 * matter puts .is-dark on <html>), so a page arrives already the right colour
 * instead of inverting after paint.
 *
 * Sections below, in order: tokens, shell, home, reading pages, upcoming,
 * studio wall, project, sheet, back/nav, edge blur, motion.
 */

:root {
    --ground: #FFFFFF;
    --ink: #0A0A0A;
    --dim: #6E6E72;
    --pill: #DCDCDC;
    --pill-hover: #CECECE;
    --pill-ink: #111111;
    --focus: #0082F3;

    /* Glass. A tint rather than a colour: the fill is translucent and the
       blur behind it samples whatever is passing under the nav, so the pill
       shifts with the page instead of sitting on it. On the white ground the
       tint is darker than the page; on black it is lighter. Both read as the
       same material. */
    --glass:        rgba(206, 206, 212, 0.40);
    --glass-hover:  rgba(186, 186, 192, 0.58);
    --glass-edge:   rgba(0, 0, 0, 0.09);
    --glass-ink:    #111111;
    --glass-on:     rgba(12, 12, 12, 0.86);
    --glass-on-edge: rgba(255, 255, 255, 0.16);
    --glass-on-ink: #FFFFFF;
    --glass-blur:   saturate(200%) blur(20px);
    /* A veil inside the bar, under the tint. Without it a white book plate
       passing behind washes the bar out and the labels drop under 4.5:1; this
       caps how bright the backdrop can push it while leaving the shift itself
       plainly visible. */
    --glass-veil:   transparent;
    /* The island carries more veil than the bar: it opens over the statement,
       and the type under it has to stop reading through. */
    --island-veil:  rgba(255, 255, 255, 0.74);
    /* Corner is ~0.2 of the bar's height, per the reference — a soft rounded
       rectangle, well short of a capsule. */
    --bar-radius:   0.55rem;
    --bar-pad:      0.28rem;

    /* Spacing. Eight steps, each roughly 1.5x the last, chosen to match the
       values already doing most of the work rather than to impose new ones.
       Reach for a step before inventing a number; if none fits, the layout is
       usually asking for a different structure rather than a 33rd value. */
    --s1: 0.35rem;
    --s2: 0.5rem;
    --s3: 0.75rem;
    --s4: 1rem;
    --s5: 1.5rem;
    --s6: 2rem;
    --s7: 3rem;
    --s8: 4.5rem;
    /* The page's outer gutter. One value for the top edge and the bottom edge
       alike, so the island stands off the top by exactly what the nav stands
       off the bottom. Narrow screens get the smaller end: on a phone the bar
       belongs near the edge. */
    --edge: clamp(0.6rem, 2.2vw, 1.6rem);

    --face: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --rise: cubic-bezier(0.16, 1, 0.3, 1);
    --invert: 340ms;
    --leave: 220ms;
  }

  /* Home is the only white page. Every interior inverts, so the flip means
     "you have gone inside" rather than being decoration on one screen. */
  :root.is-dark {
    --ground: #070707;
    --ink: #EDEDED;
    --dim: #85858A;
    --pill: #1E1E20;
    --pill-hover: #2A2A2D;
    --pill-ink: #EDEDED;

    --glass:        rgba(150, 150, 162, 0.20);
    --glass-hover:  rgba(170, 170, 184, 0.32);
    --glass-edge:   rgba(255, 255, 255, 0.13);
    --glass-ink:    #EDEDED;
    --glass-on:     rgba(238, 238, 238, 0.88);
    --glass-on-edge: rgba(0, 0, 0, 0.16);
    --glass-on-ink: #0A0A0A;
    --glass-veil:   rgba(7, 7, 7, 0.34);
    --island-veil:  rgba(7, 7, 7, 0.66);
  }

  /* color-scheme cannot be transitioned — it snaps. Left on .is-dark it flipped
     the moment the ground *started* moving, so the UA repainted the frame's own
     canvas and scrollbars a third of a second before the page caught up, which
     showed as a hard edge at the panel's rounded corners. It rides its own class
     instead, set once the ground has already arrived, so the snap lands where
     there is nothing left to see. */
  :root { color-scheme: light; }
  :root.scheme-dark { color-scheme: dark; }

  html, body {
    /* The UA's own 8px on the body was making every page 16px taller than the
       screen it was on, so a view that fits still offered a scroll. */
    margin: 0;
    background: var(--ground);
    transition: background-color var(--invert) ease;
  }

  .field { position: fixed; inset: 0; z-index: 0; display: block; width: 100%; height: 100%; }
  #night { opacity: 0; transition: opacity var(--invert) ease; }
  :root.is-dark #night { opacity: 1; }
  :root.is-dark #paper { opacity: 0; transition: opacity var(--invert) ease; }

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

  .shell {
    position: relative;
    z-index: 1;
    min-height: 100vh;
    min-height: 100dvh;
    display: grid;
    grid-template-rows: 1fr auto;
    font-family: var(--face);
    color: var(--ink);
    transition: color var(--invert) ease;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  .stage { min-height: 0; }

  /* Home centres in the viewport; the reading views run down the page. */
  /* Symmetric top and bottom: this row centres its content, and unequal
     padding quietly moved the centre off the middle of the row. */
  .stage.is-centred {
    display: grid;
    place-items: center;
    padding: clamp(var(--s6), 6vh, var(--s8)) var(--s5);
  }
  /* The nav takes a row of the shell; the island is fixed and takes none. So
     the row this centres in is short at the bottom and full-height at the top,
     which put the statement above the middle of the space a reader actually
     sees. Adding the island's own footprint to the top padding moves the
     centre down by exactly that much — only when there is an island to clear. */
  body:has(.island) .stage.is-centred {
    /* island's own height and offset, plus the run-up above the bar, which is
       part of the nav's row but is empty air rather than something you see. */
    padding-top: calc(clamp(var(--s6), 6vh, var(--s8)) + var(--edge) + 2.4rem + var(--s7));
  }
  .stage.is-flow { padding: clamp(var(--s8), 10vh, 6.5rem) var(--s5) var(--s6); }

  .col { max-width: 40rem; margin: 0 auto; }

  /* ---- home ------------------------------------------------------------ */

  .statement {
    max-width: 39rem;
    margin: 0;
    text-align: center;
    text-wrap: balance;
    /* The width term is what a phone actually gets — 3.4vw put the statement on
       its floor at 16.8px with a third of the screen empty above it and another
       third below. The height term is the guard: on a laptop, and on a phone
       held sideways, it is the short axis that decides whether the sentence
       still fits without scrolling. */
    font-size: clamp(1.15rem, min(4.4vw, 3.4vh), 1.7rem);
    line-height: 1.25;
    letter-spacing: -0.012em;
    font-weight: 400;
  }
  .statement .w {
    display: inline-block;
    opacity: 0;
    transform: translateY(0.5em);
    animation: rise 0.5s var(--rise) forwards;
    animation-delay: calc(var(--i) * 18ms);
  }

  /* The statement is the scratch coating: drag across it and the picture
     underneath comes through. Nothing announces it but the cursor. */
  .egg { position: relative; display: grid; place-items: center; }
  .egg-canvas {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    z-index: 2;
    /* A grab hand reads as "rub me"; a crosshair reads as "aim". */
    cursor: grab;
    touch-action: none;
    border-radius: 4px;
    transition: opacity 320ms ease, transform 320ms var(--rise);
  }

  /* The credit belongs to the picture, so it only exists once the whole
     picture does. It sits under the plate, centred on it. */
  .egg-credit {
    position: absolute;
    left: 50%;
    top: 50%;
    z-index: 2;
    transform: translate(-50%, 0);
    margin-top: 0.85rem;
    font-size: 0.8rem;
    color: var(--dim);
    text-align: center;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 420ms ease, color var(--invert) ease;
  }
  .egg-credit.on { opacity: 1; }
  .egg-canvas:active { cursor: grabbing; }

  /* Clearing puts the picture back under the coating: it lifts and dissolves,
     rather than blinking out. */
  .egg.is-clearing .egg-canvas { opacity: 0; transform: translate(-50%, -54%) scale(0.985); }
  .egg.is-clearing .egg-credit { opacity: 0; }

  /* No chip, no circle — just the mark. The X is drawn as two crossing lines
     rather than set as a glyph, so it is exactly symmetrical and optically
     centred instead of sitting wherever the font's × happens to fall. A drop
     shadow rather than a plate keeps it legible over sky as well as over
     shadow. */
  .egg-clear {
    position: absolute;
    z-index: 3;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.6rem;
    height: 2.6rem;
    padding: 0;
    border: 0;
    background: none;
    color: #fff;
    cursor: pointer;
    opacity: 0;
    pointer-events: none;
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
    transition: opacity 320ms ease, transform 180ms ease;
  }
  .egg-clear svg { display: block; }
  .egg-clear.on { opacity: 0.92; pointer-events: auto; }
  .egg-clear.on:hover { opacity: 1; transform: scale(1.06); }
  .egg-clear:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

  /* ---- the upcoming island ---------------------------------------------- */
  /* Home's one piece of news. It only exists when something is actually
     coming up, so its presence is the message and the count is the detail;
     tapping it opens the entries in place rather than sending you to the
     Index. Same glass as the nav and the back button, so it reads as another
     piece of the site's furniture rather than a banner laid over it. */

  .island {
    position: fixed;
    top: max(var(--edge), env(safe-area-inset-top, 0px));
    left: 50%;
    z-index: 5;
    /* The open size is CSS's, because CSS can know it: it is the column the
       page gives the capsule. Only the shut size needs measuring — the header
       is as wide as its own label — and it carries a fallback near enough to
       hold the shape if the measurement never lands. Nothing about the layout
       inside waits on a script. */
    width: var(--shut-w, 12rem);
    height: var(--shut-h, 2.4rem);
    max-width: calc(100vw - 2rem);
    border-radius: 999px;
    background-color: var(--glass);
    /* Its own veil, heavier than the nav's: open, the capsule lands over the
       statement, and the blur alone leaves the sentence legible through the
       entries. This holds the ghost back without going opaque. */
    background-image: linear-gradient(var(--island-veil), var(--island-veil));
    box-shadow: inset 0 0 0 1px var(--glass-edge);
    -webkit-backdrop-filter: var(--glass-blur);
            backdrop-filter: var(--glass-blur);
    color: var(--glass-ink);
    font-family: var(--face);
    overflow: hidden;
    opacity: 0;
    transform: translate(-50%, 0);
    /* The delay is set from the statement's own settle time once the words are
       split; 300ms is the fallback for a page with no statement to wait for,
       and for reduced motion, where there is no roll to follow. */
    animation: island-in 520ms var(--rise) var(--island-delay, 300ms) forwards;
    /* One duration and one curve for both axes: run them apart and the corner
       arrives before the edge, which is what made the morph look loose. */
    transition: width 420ms var(--rise), height 420ms var(--rise),
                border-radius 420ms var(--rise),
                background-color var(--invert) ease, color var(--invert) ease,
                box-shadow var(--invert) ease;
    will-change: width, height;
  }
  /* The capsule squares off as it grows, the way the island does. */
  .island.open {
    width: min(28rem, calc(100vw - 2rem));
    height: var(--open-h, auto);
    border-radius: 1.35rem;
  }
  /* More entries than the screen has room for: the capsule stops at the
     viewport and the list scrolls inside it, rather than running off the
     bottom edge with no way to reach the rest. */
  .island.open .island-frame {
    max-height: 100%;
    /* Stated, not left to the shorthand: setting only overflow-y turns
       overflow-x from visible into auto behind your back. */
    overflow-x: hidden;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  /* Re-measuring must not be a second animation. */
  .island.no-anim { transition: background-color var(--invert) ease, color var(--invert) ease; }

  /* The one thing that never moves. It is laid out at the open width whatever
     the capsule is doing, so growing the capsule uncovers type that is already
     set instead of re-wrapping every line on every frame — the reflow was the
     glitch. The capsule is a window; this is the picture behind it. */
  .island-frame { width: min(28rem, calc(100vw - 2rem)); }

  @keyframes island-in {
    from { opacity: 0; transform: translate(-50%, -0.5rem); }
    to   { opacity: 1; transform: translate(-50%, 0); }
  }

  .island-pill {
    display: flex;
    align-items: center;
    gap: var(--s2);
    /* Sized to its own label rather than to the frame, so the shut capsule can
       be measured without shutting anything, and the label never sits in a
       wide box waiting to be clipped. */
    width: max-content;
    max-width: 100%;
    /* Tall enough to be a thumb target, not just a label. */
    padding: 0.6rem 0.95rem;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.85rem;
    line-height: 1.2;
    white-space: nowrap;
    text-align: left;
    cursor: pointer;
  }
  /* The ring belongs around the whole capsule, not around the header inside it
     — on the open island an inset ring cut a line across the middle. Where
     :has is unavailable it falls back to ringing the button. */
  .island-pill:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
  @supports selector(:has(*)) {
    .island:has(.island-pill:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
    .island:has(.island-pill:focus-visible) .island-pill:focus-visible { outline: none; }
  }

  /* A live indicator, not a bullet: it breathes so the pill reads as current. */
  .island-dot {
    flex: 0 0 auto;
    width: 0.4rem;
    height: 0.4rem;
    border-radius: 999px;
    background: currentColor;
    opacity: 0.55;
    animation: island-pulse 2.6s ease-in-out infinite;
  }
  @keyframes island-pulse {
    0%, 100% { opacity: 0.3; transform: scale(0.9); }
    50%      { opacity: 0.75; transform: scale(1.1); }
  }

  /* The entries are always laid out; only the window over them moves. Opening,
     they fade in behind the growing edge; closing, they are gone before the
     edge reaches them, so nothing is caught mid-shrink. */
  .island-panel {
    padding: 0 0.95rem 0.9rem;
    opacity: 0;
    transition: opacity 140ms ease;
  }
  .island.open .island-panel { opacity: 1; transition: opacity 260ms ease 110ms; }

  .island-event { margin: 0; padding: 0.7rem 0; }
  .island-event + .island-event { border-top: 1px solid var(--glass-edge); }
  .island-when {
    margin: 0 0 0.2rem;
    font-size: 0.74rem;
    letter-spacing: 0.01em;
    opacity: 0.62;
  }
  .island-what { margin: 0; font-size: 0.88rem; line-height: 1.5; }
  .island-more {
    display: inline-block;
    margin-top: 0.15rem;
    padding-top: 0.6rem;
    border-top: 1px solid var(--glass-edge);
    width: 100%;
    font-size: 0.78rem;
    color: inherit;
    opacity: 0.72;
    text-decoration: none;
  }
  .island-more:hover { opacity: 1; }

  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .island { background: var(--pill); color: var(--pill-ink); box-shadow: none; }
  }

  /* ---- reading views --------------------------------------------------- */

  /* The nav already says which view you are on, so the heading is redundant
     on screen. It stays in the accessibility tree. */
  .sr-only {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* No orphans. `balance` evens out short headings so a single word never
     drops to its own line; `pretty` does the same job for running prose by
     pulling a word down rather than leaving one behind. Both degrade to normal
     wrapping where unsupported, so nothing breaks without them. */
  h1, h2, h3, .pj-title, .gal-head, .w-title, .up-when, .row-title,
  .is-prose .m-h2, .is-prose .m-h3 { text-wrap: balance; }
  p, li, .row-body, .up-body, .pj-desc, .w-blurb, .entry-body { text-wrap: pretty; }

  .row { margin-bottom: clamp(2.75rem, 7vh, 3.75rem); }
  .row:last-child { margin-bottom: 0; }

  /* Info is one continuous piece of writing, not a list of things. */
  .is-prose .row { margin-bottom: 1.15rem; }

  /* Its one section break. */
  .is-prose em { font-style: italic; }

  /* The opening statement carries at a larger size, the way it does on the
     live site. */
  .is-prose .m-lead {
    margin: 0 0 1.4rem;
    font-size: clamp(1.15rem, 2.6vw, 1.45rem);
    line-height: 1.42;
    letter-spacing: -0.012em;
  }

  .is-prose .m-h2 {
    margin: 3.25rem 0 1.1rem;
    font-size: 1.5rem;
    font-weight: 400;
    letter-spacing: -0.015em;
  }
  /* Sub-heads sit at body size and are set apart by colour, not scale. */
  .is-prose .m-h3 {
    margin: 2.1rem 0 0.55rem;
    font-size: 1.02rem;
    font-weight: 400;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  .is-prose .m-list {
    margin: var(--s1) 0 1.4rem;
    padding-left: 1.35rem;
    font-size: 1.05rem;
    line-height: 1.62;
  }
  .is-prose .m-list li { margin-bottom: 0.7rem; }
  .is-prose .m-list li:last-child { margin-bottom: 0; }
  .is-prose .m-list::marker, .is-prose .m-list li::marker { color: var(--dim); }

  /* Recognition and press: stacked lines, not bullets. A record, read down. */
  .is-prose .m-lines {
    margin: var(--s1) 0 1.4rem;
    padding: 0;
    list-style: none;
    font-size: 0.95rem;
    line-height: 1.5;
  }
  .is-prose .m-lines li { margin-bottom: 0.55rem; }
  .is-prose .m-lines li:last-child { margin-bottom: 0; }

  /* Writing sits on the Info page as a record, not a feed: venue and year
     in the dim voice, the title carrying the link. */
  .w-item { margin: 0 0 1.6rem; }
  .w-item:last-child { margin-bottom: 0; }
  .w-meta { margin: 0 0 0.2rem; font-size: 0.82rem; color: var(--dim);
            transition: color var(--invert) ease; }
  .w-title { margin: 0 0 var(--s1); font-size: 1.05rem; font-weight: 400;
             letter-spacing: -0.005em; }
  .w-blurb { margin: 0; font-size: 0.95rem; line-height: 1.55; color: var(--dim);
             transition: color var(--invert) ease; }

  .is-prose .m-colophon {
    margin: 3.25rem 0 0;
    padding-top: 1.4rem;
    border-top: 1px solid var(--rule);
    font-size: 0.88rem;
    line-height: 1.6;
    color: var(--dim);
    transition: color var(--invert) ease, border-color var(--invert) ease;
  }

  .is-prose .m-sign {
    margin: var(--s7) 0 0;
    font-size: 0.72rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--dim);
    transition: color var(--invert) ease;
  }

  .row-meta {
    margin: 0 0 var(--s2);
    font-size: 0.82rem;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  /* ---- upcoming --------------------------------------------------------- */
  /* What has not happened yet is the one thing on the index that is not an
     archive entry, so it gets the opposite ground. Its colours are literal
     rather than var-driven: the sheet inverts around it and this stays light,
     which is the whole point of it. */
  /* The heading stays over the group; each activity is its own card so two
     unrelated things stop reading as one block of text. Their colours are
     literal rather than var-driven: the sheet inverts around them and these
     stay light, which is the whole point of them. */
  .upcoming { margin: 0 0 clamp(var(--s7), 8vh, var(--s8)); }
  /* The two halves of the index are each announced the same way. */
  .up-label, .past-label {
    margin: 0 0 0.9rem;
    font-size: 0.72rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  /* .row would give it the list's full bottom margin; the label belongs to the
     entries under it, not apart from them. */
  .past-label { margin: 0 0 var(--s5); }

  .up-item {
    margin: 0 0 var(--s3);
    padding: clamp(1.15rem, 2.6vw, 1.6rem) clamp(1.2rem, 2.8vw, 1.75rem);
    border-radius: 0.9rem;
    background: #F7F7F5;
    color: #101010;
  }
  .up-item:last-child { margin-bottom: 0; }
  .up-when {
    margin: 0 0 0.3rem;
    font-size: 0.82rem;
    color: #6E6E72;
  }
  .up-body {
    margin: 0;
    font-size: 1rem;
    line-height: 1.6;
  }
  /* Rendered markdown arrives wrapped in its own paragraphs; they should sit
     flush with the card rather than adding a browser margin on top of the
     padding. */
  .up-body > p, .row-body > p { margin: 0; }
  .up-body > p + p, .row-body > p + p { margin-top: var(--s4); }
  /* Inside a card the ink is dark whichever way the page has gone, so links
     cannot inherit the page's colour. */
  .up-item a { color: #101010; }

  /* ---- the project sheet ------------------------------------------------ */
  /* A project rides over the wall instead of replacing it. The wall keeps its
     scroll position, the nav keeps its meaning, and closing is a gesture rather
     than a navigation. */
  .sheet {
    position: fixed;
    inset: 0;
    z-index: 6;
    visibility: hidden;
  }
  .sheet.open { visibility: visible; }

  .sheet-scrim {
    position: absolute;
    inset: 0;
    background: rgba(4, 4, 5, 0.55);
    -webkit-backdrop-filter: blur(6px);
            backdrop-filter: blur(6px);
    opacity: 0;
    transition: opacity 320ms ease;
  }
  .sheet.open .sheet-scrim { opacity: 1; }

  .sheet-panel {
    position: absolute;
    left: 50%;
    bottom: 0;
    width: min(46rem, 100%);
    height: min(92vh, 100%);
    transform: translate(-50%, 100%);
    background: var(--ground);
    /* The sheet lives outside .shell, so none of the page's typography reaches
       it by inheritance — it has to be stated. Without this the prose fell back
       to the document default, which is a hotter white than the site's ink. */
    font-family: var(--face);
    color: var(--ink);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    border-radius: 0.9rem 0.9rem 0 0;
    /* No hairline along the top. It drew a hard 1px lid directly above a
       header whose whole point is that it dissolves, and being a box-shadow
       it sat outside the border box, so the rounded corners clipped it into a
       line that stopped short of each end. The scrim already separates the
       panel from the page behind it.

       This ring is the opposite of that lid: it is the panel's own ground
       colour, so it is not a line, it is 1px more panel. The panel is centred
       on a half pixel and sized in vh, which puts its top edge on fractional
       device pixels — measured at .094, .250 and .406 across ordinary window
       sizes — and an edge landing there is antialiased, letting the scrim
       bleed through as a pale seam. The ring follows the radius and fills it. */
    box-shadow: 0 0 0 1px var(--ground);
    overflow-y: auto;
    outline: none;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    transition: transform 460ms var(--rise), background-color var(--invert) ease,
                color var(--invert) ease;
    touch-action: pan-y;
  }
  .sheet.open .sheet-panel { transform: translate(-50%, 0); }
  /* While a drag is in progress the panel follows the finger, so it must not
     also be easing toward a target. */
  .sheet-panel.dragging { transition: none; }

  /* The grip reads as "this pulls down". */
  /* The header is a fade, not a lid. A solid bar cut whatever line of text
     happened to be passing under it clean in half; this lets the text dissolve
     into the top edge the way it does at the top of a page. The veil carries
     the gradient, the blur and its own mask, so the handle and the close mark
     stay sharp on top of it. */
  .sheet-grip {
    position: sticky;
    top: 0;
    z-index: 3;
    height: 3.6rem;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding-top: var(--s3);
    cursor: grab;
    touch-action: none;
    /* Sits over the first 3.6rem of content rather than pushing it down. */
    margin-bottom: -3.6rem;
  }
  /* Overscanned past the top of the panel, same reason as .topfade — and here
     the overscan costs nothing at all, because the panel clips it. */
  .sheet-veil {
    position: absolute;
    left: 0; right: 0; top: -2px;
    height: calc(5.2rem + 2px);
    pointer-events: none;
    background: linear-gradient(to bottom, var(--ground) 34%, transparent);
    -webkit-backdrop-filter: blur(7px);
            backdrop-filter: blur(7px);
    -webkit-mask-image: linear-gradient(to bottom, #000 46%, transparent);
            mask-image: linear-gradient(to bottom, #000 46%, transparent);
    transition: background var(--invert) ease;
  }
  .sheet-grip:active { cursor: grabbing; }
  .sheet-grip::before {
    content: '';
    position: relative;
    z-index: 1;
    width: 2.4rem;
    height: 4px;
    border-radius: 999px;
    background: var(--glass);
  }
  /* The same mark as on the home picture: two crossing lines, no chip. */
  .sheet-close {
    position: absolute;
    z-index: 1;
    top: 0.35rem;
    right: 0.6rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.6rem;
    height: 2.6rem;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink);
    font: inherit;
    line-height: 1;
    cursor: pointer;
    opacity: 0.7;
    transition: opacity 180ms ease, transform 180ms ease, color var(--invert) ease;
  }
  .sheet-close svg { display: block; }

  /* The panel is its own scroll container, so its edges need the same courtesy
     the page's do: a plate scrolling under the grip used to stop dead against
     it. Both bands are sticky, so they ride along when the sheet is dragged. */
  .sheet-foot {
    content: '';
    pointer-events: none;
  }
  .sheet-foot {
    content: '';
    pointer-events: none;
  }
  .sheet-grip::after {
    position: absolute;
    left: 0; right: 0; top: 100%;
    height: 2.4rem;
    background: linear-gradient(to bottom, var(--ground) 18%, transparent);
    -webkit-backdrop-filter: blur(3px);
            backdrop-filter: blur(3px);
    -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent);
            mask-image: linear-gradient(to bottom, #000 30%, transparent);
  }
  .sheet-foot {
    position: sticky;
    bottom: 0;
    z-index: 2;
    display: block;
    height: 3rem;
    /* Sticks to the bottom edge without adding to the panel's length. */
    margin-top: -3rem;
    background: linear-gradient(to top, var(--ground) 28%, transparent);
    -webkit-backdrop-filter: blur(3px);
            backdrop-filter: blur(3px);
    -webkit-mask-image: linear-gradient(to top, #000 40%, transparent);
            mask-image: linear-gradient(to top, #000 40%, transparent);
  }
  .sheet-close:hover { opacity: 1; transform: scale(1.06); }
  .sheet-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

  .sheet-body { padding: 4.2rem var(--s5) clamp(var(--s7), 8vh, 5rem); }

  @media (prefers-reduced-motion: reduce) {
    .sheet-panel, .sheet-scrim { transition: none !important; }
  }

  /* ---- a single project ------------------------------------------------- */
  /* Wider than the reading column so the plates can breathe, with the prose
     still held to a readable measure inside it. */
  .col.is-project { max-width: 62rem; }
  .pj-head { max-width: 40rem; margin: 0 auto clamp(2.5rem, 6vh, 4rem); }
  .pj-year { margin: 0 0 0.4rem; font-size: 0.82rem; color: var(--dim);
             transition: color var(--invert) ease; }
  .pj-title { margin: 0 0 var(--s4); font-size: clamp(1.5rem, 4vw, 2.1rem);
              font-weight: 400; letter-spacing: -0.015em; }
  .pj-desc { margin: 0; font-size: 1.05rem; line-height: 1.62; }
  .pj-desc p { margin: 0 0 var(--s4); }
  .pj-desc p:last-child { margin-bottom: 0; }
  .pj-visit { margin: 1.1rem 0 0; font-size: 0.95rem; }

  .pj-shot { margin: 0 0 clamp(var(--s5), 4vh, 2.5rem); text-align: center; }
  .pj-shot:last-child { margin-bottom: 0; }
  .pj-shot img { max-width: 100%; height: auto; border-radius: 2px; }

  /* A piece that turns gets to turn. Same measure and same rhythm as a plate,
     since on this sheet it is one more view of the object. */
  .pj-video { margin: 0 0 clamp(1.5rem, 4vh, 2.5rem); text-align: center; }
  .pj-video:last-child { margin-bottom: 0; }
  .pj-video video {
    display: block;
    max-width: 100%;
    height: auto;
    margin: 0 auto;
    border-radius: 2px;
  }

  /* ---- projects gallery ------------------------------------------------- */
  /* The studio wall hangs as a set rather than reading as a list, so this view
     breaks out of the 40rem reading column. */
  .col.is-gallery { max-width: 96rem; }

  /* The tag under a plate: what it is, and when. Quiet enough to stay a label
     rather than a caption — it names the work, it does not describe it. The
     year is tabular so a column of them lines up down the wall. */
  .gal-tag {
    display: flex;
    justify-content: center;
    gap: 0.5em;
    margin: 0.9rem 0 0;
    font-size: 0.78rem;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  .gal-kind { text-transform: capitalize; }
  .gal-kind::after { content: "\00b7"; margin-left: 0.5em; }
  .gal-year { font-variant-numeric: tabular-nums; }

  .gallery {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: clamp(var(--s6), 4vw, var(--s7)) clamp(var(--s4), 2.2vw, 1.75rem);
    margin: 0;
  }

  /* The grid supplies the rhythm; the cells must not also carry the list's
     bottom margin. */
  /* The cell fills its grid row, and so does the link inside it, so the plate
     can take whatever height is left above the tag. Everything in a row then
     stands on one baseline and the tags line up across it — the shelf, kept,
     but drawn per row instead of once for the whole wall. */
  .gal-cell { display: flex; margin-bottom: 0; }

  /* One band held every plate at a common scale, which was right when the wall
     was sorted into sets and a shelf of books could be compared like for like.
     In date order the neighbours are a screenshot, a print and an installation,
     where relative scale means nothing and a tall band just leaves a hole above
     anything short. Each plate now takes the height its own image needs, capped
     so nothing towers, and still stands on the row's baseline. */
  .plate {
    display: flex;
    flex: 1;
    align-items: flex-end;
    justify-content: center;
    margin-bottom: 0;
  }
  .plate img {
    max-width: 100%;
    max-height: clamp(13rem, 38vh, 24rem);
    width: auto;
    height: auto;
    object-fit: contain;
    border-radius: 2px;
  }
  /* A wall with a hole in it reads as broken, so the one project without a
     picture yet holds its place. */
  .plate.is-empty {
    align-items: center;
    width: 100%;
    max-width: 13rem;
    margin-left: auto;
    margin-right: auto;
    border-radius: 3px;
    box-shadow: inset 0 0 0 1px var(--glass-edge);
    font-size: 0.9rem;
    text-align: center;
    line-height: 1.5;
    color: var(--dim);
    transition: box-shadow var(--invert) ease, color var(--invert) ease;
  }
  .plate.is-empty small { display: block; font-size: 0.78rem; opacity: 0.75; }

  /* A live site is reachable through its own picture, so the wall needs no
     "Visit" line to carry the link. */
  .gal-link {
    display: flex;
    flex-direction: column;
    width: 100%;
    text-decoration: none;
    color: inherit;
    transition: opacity 200ms ease;
  }
  .gal-link:hover { opacity: 0.72; }
  .gal-link:focus-visible { outline: 2px solid var(--focus); outline-offset: 6px; border-radius: 4px; }

  @media (max-width: 62rem) { .gallery { grid-template-columns: repeat(2, 1fr); } }
  /* One up on a phone. Two columns put every plate in a ~9rem box — a
     screenshot at that size is a smudge — and a phone only ever has one
     column's worth of width to give, so the wall gives all of it to one plate
     at a time.
     The shared band goes with the second column. Its job was to hold a set at
     its true relative scale side by side, and there is no side by side here:
     the column sets the width, the screen caps the height, and each plate comes
     up as large as the phone can show it. */
  @media (max-width: 38rem) {
    .gallery {
      grid-template-columns: 1fr;
      gap: clamp(2.5rem, 9vw, 3.5rem);
    }
    .plate { height: auto; margin-bottom: 0; }
    /* A tall picture would otherwise run past the screen it is meant to fit. */
    .plate img { max-height: 62vh; }
    .plate.is-empty { height: 9rem; max-width: none; }
  }

  .row-title {
    margin: 0 0 var(--s1);
    font-size: 1.28rem;
    font-weight: 400;
    letter-spacing: -0.01em;
  }
  .row-body {
    margin: 0;
    font-size: 1.05rem;
    line-height: 1.62;
    color: var(--ink);
    transition: color var(--invert) ease;
  }
  .row-body + .row-body { margin-top: var(--s4); }

  /* The Index is a log: every entry is a solid block of three or four lines
     with a lot of air between entries, where Info is short paragraphs with
     small breaks. The same leading that reads as generous there reads as tight
     here, so the log gets its own. */
  .is-log .row-body { line-height: 1.78; }
  .is-log .row-meta { margin-bottom: 0.65rem; }

  /* Links carry no colour of their own. They sit at the text's exact weight,
     size and colour, and the underline is the whole signal. */
  a {
    color: inherit;
    text-decoration: underline;
    text-underline-offset: 2px;
    text-decoration-thickness: 1px;
    transition: text-decoration-thickness 120ms ease;
  }
  a:hover { text-decoration-thickness: 2px; }
  a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

  /* ---- back arrow ------------------------------------------------------ */

  .back {
    position: fixed;
    top: clamp(1rem, 3vh, 1.75rem);
    left: clamp(1rem, 3vw, 1.75rem);
    z-index: 5;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.4rem;
    height: 2.4rem;
    border: 0;
    /* Same corner family as the bar, so the two read as one set. */
    border-radius: var(--bar-radius);
    background-color: var(--glass);
    background-image: linear-gradient(var(--glass-veil), var(--glass-veil));
    color: var(--glass-ink);
    box-shadow: inset 0 0 0 1px var(--glass-edge);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    font-family: var(--face);
    font-size: 1.05rem;
    line-height: 1;
    text-decoration: none;
    cursor: pointer;
    transition: background-color var(--invert) ease, color var(--invert) ease;
  }
  .back { opacity: 0; visibility: hidden; transition: opacity 260ms ease, visibility 260ms, background-color var(--invert) ease, color var(--invert) ease, box-shadow var(--invert) ease; }
  :root:not(.at-home) .back { opacity: 1; visibility: visible; }
  .back:hover { background: var(--glass-hover); }
  .back:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

  /* Rows dissolve into the edges rather than being cut by them. A flat wash
     could only fade content toward the ground; this blurs it as well, so what
     is leaving frame goes out of focus on its way rather than simply dimming.
     Each layer is a masked backdrop-filter, and because a layer samples what
     the ones beneath it already produced, three of them read as a ramp rather
     than three steps. */
  .fadeband {
    position: absolute;
    inset: 0;
    pointer-events: none;
    /* No overflow clip. The children are inset: 0, so there was never anything
       to clip, and clipping a composited backdrop layer is the fragile part —
       it is what the snapshot is taken against. */
  }
  .fadeband > * { position: absolute; inset: 0; display: block; }
  /* No will-change here. It used to say `backdrop-filter` on all three layers,
     permanently, which promoted three stacked layers over a scrolling backdrop
     for the whole life of the page — and nothing about these layers ever
     animates, so it bought nothing. Safari kept a snapshot of the backdrop for
     a promoted layer and did not always retake it when the page scrolled back
     to the top and down again: the band came back holding a stale slice of
     whatever had been behind it, and grew as the dirty region accumulated. */

  /* The band runs 2px past the top of the screen. A backdrop-filter is
     sampled and composited on device-pixel boundaries, so when the layer's
     own edge lands on a fractional one — which it does at any DPR that is not
     a whole number, and in Safari at 2 as well — the outermost row can come
     back unfiltered. That row read as a 1–2px hairline of sharp, untinted
     content above the fade. Starting the band off-screen puts the row that
     might be dropped where nobody can see it. */
  .topfade {
    position: fixed;
    top: -2px; left: 0; right: 0;
    height: calc(clamp(4.5rem, 11vh, 7rem) + 2px);
    z-index: 2;
    pointer-events: none;
  }

  .topfade .fb1 {
    -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 12%, transparent 34%);
            mask-image: linear-gradient(to bottom, #000 0%, #000 12%, transparent 34%);
  }
  .topfade .fb2 {
    -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 30%, transparent 62%);
            mask-image: linear-gradient(to bottom, #000 0%, #000 30%, transparent 62%);
  }
  .topfade .fb3 {
    -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 100%);
            mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 100%);
  }

  /* The wash sits over the blur and only tints — light enough that the blurred
     content stays visible instead of being painted out. Two of them cross-fade
     on opacity so the scrim never lags the ground. */
  .wash { transition: opacity var(--invert) ease; }
  .topfade .wash-light {
    background: linear-gradient(to bottom,
      rgba(255,255,255,0.90) 0%, rgba(255,255,255,0.42) 46%, rgba(255,255,255,0) 100%);
    opacity: 1;
  }
  .topfade .wash-dark {
    background: linear-gradient(to bottom,
      rgba(7,7,7,0.90) 0%, rgba(7,7,7,0.42) 46%, rgba(7,7,7,0) 100%);
    opacity: 0;
  }
  :root.is-dark .topfade .wash-light { opacity: 0; }
  :root.is-dark .topfade .wash-dark  { opacity: 1; }

  /* ---- nav ------------------------------------------------------------- */

  .navwrap {
    position: sticky;
    bottom: 0;
    z-index: 3;
    display: flex;
    justify-content: center;
    /* The top figure is the run-up the fade band needs, not an edge. The
       bottom is the edge, and it matches the island's at the other end. */
    padding: var(--s7) var(--s4) max(var(--edge), env(safe-area-inset-bottom, 0px));
    pointer-events: none;
  }
  /* Mirror of the top band, overscanned at its own edge for the same reason. */
  .botfade { z-index: -1; bottom: -2px; }
  .botfade .fb1 {
    -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
    -webkit-mask-image: linear-gradient(to top, #000 0%, #000 12%, transparent 34%);
            mask-image: linear-gradient(to top, #000 0%, #000 12%, transparent 34%);
  }
  .botfade .fb2 {
    -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
    -webkit-mask-image: linear-gradient(to top, #000 0%, #000 30%, transparent 62%);
            mask-image: linear-gradient(to top, #000 0%, #000 30%, transparent 62%);
  }
  .botfade .fb3 {
    -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
    -webkit-mask-image: linear-gradient(to top, #000 0%, #000 55%, transparent 100%);
            mask-image: linear-gradient(to top, #000 0%, #000 55%, transparent 100%);
  }
  .botfade .wash-light {
    background: linear-gradient(to top,
      rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.55) 48%, rgba(255,255,255,0) 100%);
    opacity: 1;
  }
  .botfade .wash-dark {
    background: linear-gradient(to top,
      rgba(7,7,7,0.92) 0%, rgba(7,7,7,0.55) 48%, rgba(7,7,7,0) 100%);
    opacity: 0;
  }
  :root.is-dark .botfade .wash-light { opacity: 0; }
  :root.is-dark .botfade .wash-dark  { opacity: 1; }
  /* One continuous bar rather than three separate pills. The corner is a soft
     rounded rectangle, not a capsule: measured off the reference at roughly
     0.2 of the bar's height, which is what keeps it reading as a bar and not
     a lozenge. Items divide it in equal thirds, so it never wraps and needs no
     breakpoint. */
  nav {
    position: relative;
    display: flex;
    align-items: center;
    width: min(34rem, 100%);
    padding: var(--bar-pad);
    border-radius: var(--bar-radius);
    background-color: var(--glass);
    background-image: linear-gradient(var(--glass-veil), var(--glass-veil));
    box-shadow: inset 0 0 0 1px var(--glass-edge);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    pointer-events: auto;
    transition: background-color var(--invert) ease, box-shadow var(--invert) ease;
  }
  nav a {
    position: relative;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 1 1 0;
    min-width: 0;
    padding: 0.55rem 0.4rem;
    /* Nested radius: outer minus the padding between them, so the inner curve
       sits concentric with the bar's rather than fighting it. */
    border-radius: calc(var(--bar-radius) - var(--bar-pad));
    background: transparent;
    color: var(--glass-ink);
    font-size: clamp(0.82rem, 3.4vw, 0.95rem);
    line-height: 1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    text-decoration: none;
    transition: background-color 200ms ease, color var(--invert) ease;
  }
  nav a:hover { background: var(--glass-hover); }
  nav a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
  /* Selected is the inversion of the page it is on. The chip is a single
     element that slides between items rather than a background switching off
     one link and on at another, so moving between areas reads as one object
     travelling instead of two blinking. */
  .nav-thumb {
    position: absolute;
    z-index: 0;
    top: var(--bar-pad);
    left: 0;
    height: calc(100% - var(--bar-pad) * 2);
    width: 0;
    border-radius: calc(var(--bar-radius) - var(--bar-pad));
    background: var(--glass-on);
    opacity: 0;
    pointer-events: none;
    transition: transform 420ms var(--rise), width 420ms var(--rise),
                opacity 200ms ease, background-color var(--invert) ease;
  }
  .nav-thumb.on { opacity: 1; }
  /* Placing it for the first time, or after a resize, must not look like a
     slide from the left edge. */
  .nav-thumb.no-anim { transition: background-color var(--invert) ease; }

  nav a[aria-current="page"] { color: var(--glass-on-ink); }

  /* Wherever the blur is unavailable the bar falls back to a solid fill, so
     the nav is never a near-invisible outline. */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    nav, .back { background: var(--pill); color: var(--pill-ink); box-shadow: none; }
    nav a { color: var(--pill-ink); }
    nav a:hover, .back:hover { background: var(--pill-hover); }
    .nav-thumb { background: var(--ink); }
    nav a[aria-current="page"] { color: var(--ground); }
  }

  /* ---- entering and leaving -------------------------------------------- */

  .rise-in { opacity: 0; animation: rise 0.46s var(--rise) forwards; animation-delay: calc(var(--i, 0) * 34ms); }
  @keyframes rise {
    from { opacity: 0; transform: translateY(15px); }
    to   { opacity: 1; transform: none; }
  }

  .row { opacity: 0; transform: translateY(14px); }
  .row.in { animation: roll 0.48s var(--rise) forwards; animation-delay: calc(var(--i, 0) * 34ms); }
  @keyframes roll { to { opacity: 1; transform: none; } }

  :root.is-leaving .row,
  :root.is-leaving .row.in,
  :root.is-leaving .statement .w,
  :root.is-leaving .rise-in {
    animation: leave var(--leave) ease forwards;
    animation-delay: calc(var(--o, 0) * 12ms);
  }
  @keyframes leave { to { opacity: 0; transform: translateY(-10px); } }

  /* A scratched-open picture has to clear immediately, otherwise it hangs
     over the transition long after the type has gone. */
  :root.is-leaving .egg-canvas {
    animation: fadeOut 160ms ease forwards;
  }
  @keyframes fadeOut { to { opacity: 0; } }

  @media (max-width: 30rem) {
    .navwrap { padding-left: var(--s3); padding-right: var(--s3); }
  }

  @media (prefers-reduced-motion: reduce) {
    html, body, .shell, .navwrap, .field, nav a, .back, .nav-thumb,
    .row, .row-body, .row-meta, a { transition: none !important; }
    .row, .rise-in, .statement .w, .back {
      animation: none !important; opacity: 1 !important; transform: none !important;
    }
    :root.is-leaving .egg-canvas { opacity: 0 !important; animation: none !important; }
    .island { animation: none !important; opacity: 1 !important; transition: none !important; }
    .island-dot { animation: none !important; opacity: 0.55 !important; transform: none !important; }
    .island-panel, .island-frame { transition: none !important; }
    :root.is-leaving .row,
    :root.is-leaving .rise-in,
    :root.is-leaving .statement .w { opacity: 0 !important; }
  }

/* ---- an essay ----------------------------------------------------------- */
/* Writing lives on Info; a single piece gets the same reading measure as
   the prose there, and its images run to the column's full width. */
.post-title { margin-top: 0; }
.post-note { margin: -0.4rem 0 var(--s6); }
.post-body { font-size: 1.05rem; line-height: 1.62; }
.post-body p { margin: 0 0 1.15rem; }
.post-body h2, .post-body h3 { margin: 2.4rem 0 0.8rem; font-weight: 400; letter-spacing: -0.012em; }
.post-body h2 { font-size: 1.4rem; }
.post-body h3 { font-size: 1.1rem; }
.post-body figure { margin: var(--s6) 0; }
.post-body img { max-width: 100%; height: auto; border-radius: 2px; }
.post-body figcaption { margin-top: var(--s2); font-size: 0.82rem; color: var(--dim); }
.post-body blockquote {
  margin: 1.6rem 0;
  padding-left: 1.1rem;
  border-left: 1px solid var(--glass-edge);
  color: var(--dim);
}

/* ---- the bio ------------------------------------------------------------ */
/* Adapted from _bio/src/bio.css. Two things changed on the way in:
   the type is the site's, not Newsreader and IBM Plex Mono; and the palette is
   derived from the page's own tokens rather than the light paper the block was
   drawn on. Its variables are namespaced — the source defined --ink on :root,
   which would have clobbered the site's. */
.bio-block {
  --bio-spent:      rgba(237, 237, 237, 0.42);
  --bio-chip:       rgba(255, 255, 255, 0.10);
  --bio-chip-hover: rgba(255, 255, 255, 0.18);
  --bio-flash:      rgba(255, 255, 255, 0.15);
  --bio-rule:       rgba(255, 255, 255, 0.13);
  --bio-pill-line:  rgba(255, 255, 255, 0.24);
}
.bio-block * { box-sizing: border-box; }
.bio-block .wrap { max-width: 44rem; margin: 0 auto; }

.bio-block .bio {
  font-size: clamp(1.05rem, 2.1vw, 1.35rem);
  line-height: 1.62;
  letter-spacing: -0.008em;
  text-wrap: pretty;
}
.bio-block .bio p { margin: 0 0 0.9em; }
.bio-block .bio p:last-child { margin-bottom: 0; }

/* The bio ships fully open — that is what a crawler and a reader without
   JavaScript get — and enhance() collapses it on load. Between first paint and
   that swap the page was showing all 3,000px of it against the 490px it settles
   at, which is the flicker: a wall of text, then a page rearranging itself.

   Clamped to the collapsed bio's shape, first paint already looks like the
   answer. Same four paragraphs, same opening words, near enough the same
   height — so when the swap lands it changes words rather than the layout.

   Only where scripting is on. With JS off no swap is coming and the whole bio
   is the page, which is the point of shipping it open. */
@media (scripting: enabled) {
  .bio-block:not(.is-ready) .bio p {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 4;
    overflow: hidden;
  }
  /* The collapsed paragraphs run a line shorter once the measure widens. */
  @media (min-width: 38rem) {
    .bio-block:not(.is-ready) .bio p { -webkit-line-clamp: 3; }
  }
}

/* a chip: opens in place, one way */
.bio-block .chip {
  display: inline;
  /* Fragmenting is the point — see bio.js. Clone so each fragment draws its
     own rounded box instead of one long box left open at the line end. */
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  margin: 0;
  padding: 0.1em 0.16em 0.12em 0.26em;
  border: 0;
  border-radius: 3px;
  background: var(--bio-chip);
  color: var(--ink);
  font: inherit;
  font-size: 0.86em;
  cursor: pointer;
  transition: background 160ms ease;
  -webkit-tap-highlight-color: transparent;
}
/* Holds an element and the punctuation that follows it on the same line. It
   must not stop the chip inside it from breaking, so only white-space is set
   — no new box. */
/* Holds a pill and the punctuation after it together. A pill is inline-block
   and cannot fragment anyway, so nowrap costs it nothing. A chip must still be
   able to wrap between its own words, and no longer needs the help: its glyph
   stopped being an atomic box. */
.bio-block .knit, .rec .knit { white-space: nowrap; }
/* nowrap must not reach inside the pill. A long label — LIVING ROOM LIGHT
   EXCHANGE — then cannot wrap, and one run wider than the screen scales a
   phone's whole layout down. The pill sets its own wrapping again; the break
   being closed is the one between the pill's box and the punctuation after
   it, which the knit still governs. */
.bio-block .knit .pill, .rec .knit .pill { white-space: normal; }

.bio-block .chip:hover { background: var(--bio-chip-hover); }
.bio-block .chip:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* a spent chip stays where it was, but stops being a target */
.bio-block .spent {
  font-size: 0.86em;
  color: var(--bio-spent);
  background: transparent;
  padding: 0.1em 0.16em 0.12em 0.26em;
  transition: color var(--invert) ease;
}

/* an outbound link, in the site's face rather than a mono one */
/* A bubble is one object, so it is one box: inline, it fragmented at the line
   end and drew a second bubble around whatever was left over — usually the
   arrow on its own. Inline-block cannot fragment. A pill that does not fit
   moves down whole; a title too long for any line wraps inside its own border
   instead of being cut in half by one, and max-width keeps the longest of them
   inside the column on the narrowest phone. */
.bio-block .pill,
.rec .pill {
  display: inline-block;
  max-width: 100%;
  margin: 0 0.12em;
  padding: 0.34em 0.72em 0.32em;
  border: 1px solid var(--bio-pill-line);
  border-radius: 100px;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  color: var(--dim);
  font-size: 0.66em;
  font-weight: 500;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  text-decoration: none;
  line-height: 1;
  vertical-align: 0.12em;
  transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.bio-block .pill:hover,
.rec .pill:hover { border-color: var(--ink); color: var(--ink); background: var(--bio-chip); }
.bio-block .pill:focus-visible,
.rec .pill:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* The arrow was an inline-block, and a line is allowed to break before one of
   those: at the end of a line the label stayed put and the arrow went to the
   next line inside a second bubble of its own, which is what
   box-decoration-break: clone draws for a fragment. Inline, there is no break
   opportunity in front of it, so it cannot be left behind — in the prose or in
   the lists, whatever the measure. */
.pill .arw { display: inline; margin-left: 0.5em; font-size: 0.9em; }

/* Inline, not inline-block, and the drawing lifted out of the flow behind it.
   An inline-block is atomic, and the layout will happily break a line after an
   atomic box — which is how a sentence ended up with its full stop alone on the
   last line. An inline box with padding reserves exactly the same width and
   opens no break at all, so the punctuation stays put and the chip around it is
   still free to wrap between its own words. */
.bio-block .mark {
  display: inline;
  position: relative;
  padding-left: 1.24em;             /* the glyph's width plus the space either side */
}
.bio-block .mark svg {
  position: absolute;
  left: 0.32em;
  top: 0.09em;
  width: 0.82em;
  height: 0.82em;
}

/* the run that just opened */
.bio-block .fresh {
  display: inline;
  border-radius: 3px;
  background: var(--bio-flash);
  box-shadow: 0 0 0 0.18em var(--bio-flash);
  animation: cool 1.5s ease-out 0.35s forwards;
}
@keyframes cool { to { background: transparent; box-shadow: 0 0 0 0.18em transparent; } }

/* No rule and no counter — the bio does not need to announce how much of
   itself is left. Only the way back, and only once there is one. */
.bio-block .foot {
  display: flex;
  margin: var(--s6) 0 0;
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--dim);
}
.bio-block .reset {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--dim);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms ease, color 160ms ease;
}
.bio-block .reset.on { opacity: 1; pointer-events: auto; }
.bio-block .reset:hover { color: var(--ink); }
.bio-block .reset:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .bio-block .fresh { animation: none; background: transparent; box-shadow: none; }
  .bio-block .chip { transition: none; }
}

/* ---- the record --------------------------------------------------------- */
/* Lists, not prose: the bio above is the readable version, these are the
   crawlable one. Year in its own column, tabular so the digits line up. */
.rec { margin: clamp(var(--s7), 7vh, var(--s8)) 0 0; }
.rec-head {
  margin: 0 0 1.25rem;
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--dim);
  transition: color var(--invert) ease;
}
.rec-list { margin: 0; padding: 0; list-style: none; }
.rec-item {
  padding: 0.9rem 0;
  border-top: 1px solid var(--bio-rule);
  font-size: 0.95rem;
  line-height: 1.5;
}
.rec-item:first-child { border-top: 0; }
/* The year sits over its entry, the way a date sits over an entry on the
   Index — same size, same dim, same drop to the line below. */
.rec-year {
  display: block;
  margin: 0 0 var(--s2);
  font-variant-numeric: tabular-nums;
  font-size: 0.82rem;
  color: var(--dim);
  transition: color var(--invert) ease;
}
/* Linked or not, a title is a title: same size, same ink. Only the underline
   and the arrow say one of them leaves. */
.rec-body > .rec-title { display: inline; color: var(--ink); }
.rec-link {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--bio-pill-line);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-color 160ms ease, color var(--invert) ease;
}
.rec-link:hover { text-decoration-color: var(--ink); }
.rec-link:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 3px; }
.rec-link .arw {
  display: inline-block;
  margin-left: 0.35em;
  font-size: 0.78em;
  text-decoration: none;
  color: var(--dim);
}

/* A pill carries side margin so it can sit inside a sentence. Leading an
   entry it should start on the margin, flush with the note beneath it. */
.rec-body > .pill:first-child { margin-left: 0; }
.rec-note { display: block; margin-top: 0.25rem; font-size: 0.88rem; color: var(--dim); }

  /* ---- not found -------------------------------------------------------- */
  /* One statement, centred like home, with the three places to go next
     underneath. The way back is the same house button every interior page
     carries — a lost visitor should not need a fourth link to find it. */

  .lost { display: grid; gap: 1.4rem; justify-items: center; text-align: center; }
  .lost-nav {
    margin: 0;
    font-size: 1rem;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  .lost-nav a { color: var(--ink); transition: color var(--invert) ease; }

  /* What the work carries — held in, published in, press, scholarship. Short
     lists under the description, set at the Index's meta size so they read as
     facts about the work rather than more of its description. */
  .pj-credits { margin: var(--s5) 0 0; }
  .pj-cred + .pj-cred { margin-top: var(--s4); }
  .pj-cred-head {
    margin: 0 0 var(--s2);
    font-size: 0.72rem;
    font-weight: 500;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--dim);
    transition: color var(--invert) ease;
  }
  .pj-cred ul { margin: 0; padding: 0; list-style: none; }
  .pj-cred li { margin: 0 0 0.35rem; font-size: 0.9rem; line-height: 1.45; }
  .pj-cred li:last-child { margin-bottom: 0; }
  /* The gloss sits on its own line so a wrapped title cannot be mistaken for
     running into the publisher. */
  .pj-cred-note {
    display: block;
    font-size: 0.8rem;
    color: var(--dim);
    transition: color var(--invert) ease;
  }

