/**
 * Site stylesheet — every page loads this and only this.
 *
 * Design system lifted from publicknowledge.co. It replaced the old minima
 * SCSS build entirely, so there is no _sass/ directory and no Sass step: this
 * file is served as authored.
 *
 * Layout: tokens → base → utilities → hero (home only) → page chrome → prose →
 * listings → post-embedded widgets → syntax highlighting → mobile overrides.
 */

/* No @font-face here any more: the site sets its type in the system stack (see
   --font-body below), so nothing is downloaded for text. The four TASA
   declarations that used to sit here went with it — they declared families no
   rule referenced, which is a fetch waiting to happen the moment someone names
   one by mistake. The .woff2 files are still in assets/fonts/ if the faces are
   ever wanted back. */

/* The scroll-driven scalars, declared as TYPED custom properties.
 *
 * animate-am-logo.js rewrites these on :root every scroll frame. Left
 * unregistered they are token streams the engine cannot reason about, so a
 * write to any one of them dirties style for the whole document and every frame
 * of the reveal pays a full recalculation — which is what made the mark stutter
 * once six stickers were riding the same properties.
 *
 * Registered with a syntax they are numbers, so the engine knows exactly which
 * declarations depend on them and can scope the invalidation to those.
 * inherits: true because they are set at the root and read deep inside the
 * hero. The initial values are the at-rest state, so the first paint is correct
 * before any script has run.
 *
 * Progressive: a browser without @property falls back to the old untyped
 * behaviour, which still works — it is only slower.
 */
@property --hero-progress {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

@property --sticker-burst {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

@property --scheme-t {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

@property --mark-shrink {
    syntax: '<number>';
    inherits: true;
    initial-value: 1;
}

@property --plate-settle {
    syntax: '<percentage>';
    inherits: true;
    initial-value: 0%;
}

@property --send {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

/* Registered so the drop animation can INTERPOLATE it. An unregistered custom
   property is a token stream, and a keyframe can only step between those — the
   sticker would snap 1.12 → 0.94 → 1 rather than settle. inherits: false: it
   belongs to the one sticker being dragged, and there is nothing below it that
   should pick it up. */
/* The gradient wash across the hero's changing word. A percentage rather than a
   number because it is a gradient stop; registered so a keyframe can sweep it
   instead of stepping between two token streams. */
@property --wash {
    syntax: '<percentage>';
    inherits: false;
    /* Past the right edge, which is the resting state: the word fully revealed
       and the highlight parked off the end of it. */
    initial-value: 140%;
}

@property --drag-scale {
    syntax: '<number>';
    inherits: false;
    initial-value: 1;
}

:root {
    /* Colours */
    --text-reg: rgb(42, 42, 42);
    --text-secondary: rgba(42, 42, 42, .7);
    --header-background: rgba(255, 255, 255, 0.4);
    --header-shadow: rgba(0, 0, 0, 0.1);
    --background-color: #FFFFFF;
    --slightly-warm-gray: #faf9f9;
    --charcoal: #2A2A2A;
    --black-20: rgba(0, 0, 0, .2);
    --black-10: rgba(0, 0, 0, .1);
    --black-5: rgba(0, 0, 0, .05);
    --black-3: rgba(0, 0, 0, .03);
    --black: #000000;
    --code-background: #21244c;
    --code-color: #e6e6f0;
    --white: #FFFFFF;
    --accent-h: 348;
    --accent-s: 70%;
    --accent-l: 40%;
    --accent-color: hsl(var(--accent-h), var(--accent-s), var(--accent-l));
    --footer-background: rgba(255, 255, 255, 0.92);
    --footer-ground: #F0EFED;
    --glass-edge: rgba(255, 255, 255, 0.1);
    --focus-outline: 2px solid var(--accent-color);
    --focus-outline-offset: 2px;

    /* The hero is pinned to light in every scheme (see .hero-spacer), so in dark
       mode the page has to get from that white to its own background somewhere.
       --scheme-t is driven from scroll by animate-am-logo.js over the tail of the
       reveal, and the page's background walks between the two across it — the
       hero fades into the page instead of meeting it at a seam. In light mode
       both ends are white, so this resolves to a no-op.

       Deliberately not folded into --background-color: that token is what the
       mix is measured against, and pointing it at its own result would be a
       cycle. Anything that follows the shift mixes against --scheme-t itself. */
    /* Dark glass, as two background layers: a fill for the padding box and a lit
       edge for the border box, used together with `border: 1px solid
       transparent` so the second shows through only in that 1px ring. The
       experience cards and the thread's name pill both wear it, which is why it
       is here rather than on either of them.

       Named --pane-*, not --glass-*: --glass-edge is already taken (see above)
       by the flat hairline the nav borders with, and it is redeclared in the
       dark block further down — so a second --glass-edge here parsed fine, then
       lost to that one, and both surfaces rendered their ring layer as `none`
       with a transparent border over it. No border at all, and nothing in the
       declaration to say why.

       The fill is the page's own dark with black worked into it, deepening
       towards the bottom — a step below the background rather than a long way
       below it, since straight black is nearly the value of the text and the
       surface stops reading as glass and starts reading as a hole.

       One figure per stop, and it is a mix ratio rather than an alpha. This
       carried both for a while: a colour mix inside an alpha mix, so the fill
       was a dark tone AND 95% opaque. Two opacities to reason about for one
       surface, and the 5% that came through was not free — where cards overlap
       in the pile you could read the card behind straight through the front
       one. Opaque, and the glass is the edge's job.

       No backdrop-filter, which would be the real thing. It re-rasterises
       against whatever is behind it on every scrolled frame, and this page has
       spent a lot of effort getting per-frame work down. */
    --pane-fill: linear-gradient(180deg,
            color-mix(in srgb, #000000 14%, var(--background-color)),
            color-mix(in srgb, #000000 22%, var(--background-color)));
    /* The ring's stops are OPAQUE, each one white already blended into the
       fill's own colour rather than laid over it as an alpha. Translucent stops
       composite against whatever happens to be behind them, and in the pile
       that is another card rather than the page — so a card's edge lit up
       wherever it overlapped its neighbour, brightest where the two rings
       crossed, and the pile looked lit from inside at the seams.

       --pane-base is the fill's midpoint, and mixing against it gives the same
       colours the alphas used to produce over a card, minus the compositing.
       Which means the ring reads identically whether it lies on the page or on
       another card, which is the point. */
    /* The same fill a step lighter, for a glass surface that can be hovered.
       Written as its own gradient rather than lightened with a filter, since a
       filter on the element would take the ring and the label with it. */
    --pane-fill-hover: linear-gradient(180deg,
            color-mix(in srgb, #000000 6%, var(--background-color)),
            color-mix(in srgb, #000000 14%, var(--background-color)));
    --pane-base: color-mix(in srgb, #000000 18%, var(--background-color));
    --pane-ring: linear-gradient(165deg,
            color-mix(in srgb, #FFFFFF 15%, var(--pane-base)),
            color-mix(in srgb, #FFFFFF 4.3%, var(--pane-base)) 45%,
            color-mix(in srgb, #FFFFFF 9.8%, var(--pane-base)));

    /* The six colours the hero mark's reveal is drawn in, leading the black
       finisher. Duplicated from the stroke attributes in _layouts/home.html —
       those have to ship in the markup so the mark is right on first paint,
       before any stylesheet — so if one set changes, change both. */
    --reveal-1: #61BB46;
    --reveal-2: #FDB827;
    --reveal-3: #F5821F;
    --reveal-4: #E03A3E;
    --reveal-5: #963D97;
    --reveal-6: #009DDC;

    /* 1 means "the page's own colour", 0 means "the hero's white", and the
       crossfade between them is what the home page scrubs on scroll.

       Every other page has no hero, so 1 is its resting value — and it has to be
       the DEFAULT rather than home's 0. body's background is one of the things
       mixed against this, and html has no background of its own, so body's
       propagates to the canvas: at 0 the overscroll area and anything the
       content did not cover stayed white however dark the page was. Invisible
       while the site was dark-only and the footer covered the gap; not
       invisible once a reader can switch schemes. animate-am-logo.js writes this
       inline on :root while scrolling the hero, which outranks both rules. */
    --scheme-t: 1;
    /* The other two figures animate-am-logo.js publishes from scroll position.
       --hero-progress is the reveal's raw 0..1; --sticker-burst is that same
       run eased and squeezed into its head, and is what throws the hero's
       sticker ring outward. Declared here so the page's resting state is
       correct on first paint and stays correct with no JavaScript at all —
       stickers sat on their ring, scroll cue showing. */
    --hero-progress: 0;
    --sticker-burst: 0;

    /* The colours derived from --scheme-t are NOT declared here.
     *
     * They used to be: --page-bg, --page-ink and --hero-bg, three color-mix()
     * expressions on :root. Custom properties inherit, and an unregistered one
     * inherits as an unresolved token stream — so every element in the document
     * carried a copy of those expressions, and rewriting --scheme-t on :root
     * meant re-substituting them tree-wide on the same frames the mark was
     * being scaled. The mark shared an invalidation group with the entire page.
     *
     * There are exactly three consumers, so each now spells its own mix out
     * against --scheme-t directly. --scheme-t is registered (see @property
     * above), which keeps it a number the engine can track rather than another
     * token stream. */

    /* Typography — the system font stack: whatever the reader's OS sets its own interface
       in. Nothing is downloaded for text any more — the self-hosted TASA faces
       and the Nunito fallback are both out of the stacks, so the first paint is
       the final one and there is no swap.

       -apple-system and BlinkMacSystemFont are the same San Francisco by two
       names, one for Safari and one for Chrome; 'Segoe UI' is Windows, Roboto
       is Android and most Linux, and the last three are the long tail. The
       generic at the end is what a browser falls back to having matched none of
       them.

       Both tokens are the same stack. They were different when the site set its
       display type in TASA Explorer; keeping two names is what lets headings be
       given their own face again without touching every rule that reads one. */
    --font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    --line-height-base: 1.4;
    --line-height-tight: 1.1;
    --line-height-sans-base: 1.5;
    /* Loosened by 0.02em across the board — every rule reads these three, so
       the whole site's tracking moves together from here. */
    --letter-spacing-tight: -0.01em;
    --letter-spacing-regular: 0.01em;
    --letter-spacing-wide: 0.05em;
    --font-size-base: 1em;
    /* The home page uses three of these and no more — a run of near-sizes read
       as inconsistency rather than as hierarchy, so what separates one thing
       from another there is colour and case, not another step on the scale:
         xl  (mobile lg)  body — messages, experience rows, work cards
         md  (mobile sm)  labels and chrome — section headings, buttons, footer
         4xl (mobile 2xl) the one display line, the contact headline
       The rest of the site still uses the full scale. */
    --font-size-sm: 14px;
    --font-size-md: 16px;
    --font-size-lg: 20px;
    --font-size-xl: 24px;
    --font-size-2xl: 28px;
    --font-size-3xl: 32px;
    --font-size-4xl: 48px;

    /* The mark above the page. Stroke is in viewBox units, so it holds its
       weight relative to the mark at whatever height the mark is drawn — the
       two are only retuned together if the mark is meant to look heavier. */
    --logo-mark-size: 44px;
    --logo-mark-stroke: 13.44;

    /* Layout & spacing */
    --max-width: 1920px;
    --width-desktop: 90vw;
    /* Narrower column for the home page's own sections (about, work, contact).
       The rest of the site keeps --width-desktop. */
    --width-home: 72vw;
    --max-width-home: 1280px;
    /* The width of the thread, and of the two sections under it. One column
       down the page rather than a narrow thread over a wide grid — the page is
       a phone screen, and everything on it reads at one width.

       In px rather than the 34em this started as: as a shared token an em would
       resolve against whatever element used it, so the sections would only line
       up with the thread while all three happened to inherit the same font
       size. 34em at the body's 16px. */
    --width-thread: 544px;
    --width-mobile: 90vw;
    /* Reading measure for prose pages; the home grid uses --width-desktop. */
    --measure: 760px;
    --spacing-xs: 4px;
    --spacing-sm: 8px;
    --spacing-md: 12px;
    --spacing-lg: 20px;
    --spacing-xl: 24px;
    --spacing-2xl: 30px;
    --spacing-3xl: 40px;
    --spacing-4xl: 45px;
    --spacing-5xl: 80px;
    --spacing-6xl: 100px;
    /* Only the footer's top padding, which has to clear an in-app browser's
       floating toolbar as well as the page's own rounded corner. */
    --spacing-7xl: 160px;

    /* Square thumbnail sizes, still used by post headers (.entry-thumbnail) and
       archive listings (.listing-thumbnail). The source art is 100-144px, so
       these never exceed that — anything larger just upscales. */
    --project-thumbnail-size: 96px;
    --project-thumbnail-size-mobile: 72px;
    /* Work cards are full-bleed, so their thumbnail is capped by height instead.
       NOTE: the 144px source art upscales badly at this width. */
    --project-thumbnail-max-height: 480px;

    /* The work card's frame. Wider than the 1.56 the thumbnails themselves are,
       so the image overflows it vertically at full width — that overflow is
       what the parallax travels through. Widen it for a deeper effect and a
       tighter crop; at the art's own ratio the parallax stops entirely. */
    --project-media-aspect: 16 / 9;

    /* Radii */
    --border-radius-sm: 4px;
    --border-radius-md: 8px;
    --border-radius-lg: 10px;
    --border-radius-card: calc(var(--border-radius-lg) + 4px);
    /* The curl on the page's bottom edge where it lifts off the fixed footer. */
    --border-radius-page: 28px;
    /* Work-card images carry a heavier round than the rest of the UI. */
    --border-radius-thumbnail: 24px;

    /* Motion */
    --transition-fast: 0.2s ease;
    --transition-normal: 0.3s ease;
    --animation-duration-bounce: 3s;
    --animation-delay-base: 400ms;
    --animation-delay-2: 800ms;
    --animation-delay-3: 1200ms;

    /* Z-index scale */
    --z-video: 9;
    --z-content: 10;
    /* Under the content, not over it: the footer is revealed by the page
       sliding off it.
     *
       NEGATIVE, and it has to be. A fixed element is positioned, so at 0 or
       above it paints over every in-flow background on the page — at the top of
       the page the footer covered the hero. At -1 it sits in the one band that
       is above the root background but below all in-flow content, which is
       exactly where a thing being revealed belongs. */
    --z-footer-behind: -1;
    --z-footer: 20;
    --z-button: 25;
    --z-nav: 100;
    --z-controls: 1000;

    /* One unit of the hero mark's 30x32 CSS box, in px. Anything that has to
       line up with the mark should be measured in these rather than in px or vh.
       animate-am-logo.js overwrites this on <html> every resize to the exact
       value it is drawing at — this declaration is the same formula
       (min(vw, vh) * sizeRatio / 32) so the pre-script paint already agrees.
       It must stay on :root: a copy on a consuming element would shadow the
       inline value the script sets, and the two would silently diverge. */
    --mark-unit: calc(min(100vw, 100vh) * 0.0171875);

    --backdrop-blur: blur(20px) contrast(130%) saturate(250%);

    /* The search results panel's own glass, heavier and less blurred than the
       header's above — it is a block of reading matter, and it can open over
       the sticker field. Redeclared for dark mode with the rest of the palette,
       and again on the home page where the bar is pinned light. */
    --search-panel-bg: rgba(255, 255, 255, 0.94);
    --search-panel-blur: blur(8px) saturate(140%);
}

/* Body copy runs four pixels smaller on a desktop. The xl tier is the home
   page's reading size — the messages, the experience rows, the work cards — and
   at 24px it was set for a phone held close rather than a screen at arm's
   length. Scoped to the wide breakpoint so the mobile sizes, which step down a
   tier of their own and are tuned separately below, are left where they are. */
@media (min-width: 801px) {
    :root {
        --font-size-xl: 20px;
    }
}

/* And two pixels off the phone's reading size. On mobile the home page steps
   its body tier down to lg, so lg is what the messages, the experience cards
   and the work cards are all set in there — the counterpart to the rule above,
   scoped the same way so the desktop sizes are left alone. */
@media (max-width: 800px) {
    :root {
        --font-size-lg: 18px;
    }
}

/* ===== The dark palette =====
 *
   The block above is the light one. This one used to sit here unconditional, so
   the site was simply dark; it is now conditional in the two ways a scheme can
   be chosen:

     1. the system asks for dark, and the reader has not overridden it
     2. the reader picked dark, via the switcher in the header

   The home page is case 2 by fiat: _includes/site/head.html stamps
   data-theme="dark" on <html> at build time, because that page is written as a
   light hero crossfading into a dark body and neither half is a preference.
   That is also why it renders no switcher.

   THE TWO BLOCKS BELOW MUST BE KEPT IDENTICAL. CSS has no way to hand one set
   of declarations to both a media query and a plain selector, and the
   alternative — light-dark() on every token — makes every colour on the site
   fail at once on a browser that does not support it. Two copies is the dull,
   safe option: change one, change the other.

   The hero is unaffected either way. It pins its own light tokens and a white
   ground regardless of scheme. See .hero-spacer. */

/* Home opens on the hero's white and crossfades down into the page. */
:root[data-page="home"] {
    --scheme-t: 0;
}

/* color-scheme is what makes the browser's own furniture — scrollbars, form
   controls, the default canvas — follow along. Left as `light dark` it tracks
   the system; the explicit choices below pin it. */
:root {
    color-scheme: light dark;
}

:root[data-theme="light"] {
    color-scheme: light;
}

:root[data-theme="dark"] {
    color-scheme: dark;
}

/* (1) The system asks for dark, and no explicit choice overrides it. */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --text-reg: #E8E8E8;
        --text-secondary: rgba(255, 255, 255, .75);
        --background-color: #1a1a1a;

        --accent-s: 80%;
        --accent-l: 65%;

        --slightly-warm-gray: #2C2C2C;
        --charcoal: #E8E8E8;
        --white: #181717;
        --black: #ffffff;
        --black-20: rgba(255, 255, 255, .2);
        --black-10: rgba(255, 255, 255, 0.1);
        --black-5: rgba(255, 255, 255, 0.05);
        --black-3: rgba(255, 255, 255, 0.03);
        --code-background: #12121c;

        --header-background: rgba(30, 30, 30, 0.4);
        --search-panel-bg: rgba(28, 28, 28, 0.94);
        --header-shadow: rgba(0, 0, 0, 0.3);
        --footer-background: rgba(0, 0, 0, 0.9);
        --footer-ground: #121212;
        --glass-edge: rgba(0, 0, 0, 0.75);
    }
}

/* (2) The reader picked dark — or this is the home page, which is stamped. */
:root[data-theme="dark"] {
    --text-reg: #E8E8E8;
    --text-secondary: rgba(255, 255, 255, .75);
    --background-color: #1a1a1a;

    --accent-s: 80%;
    --accent-l: 65%;

    --slightly-warm-gray: #2C2C2C;
    --charcoal: #E8E8E8;
    --white: #181717;
    --black: #ffffff;
    --black-20: rgba(255, 255, 255, .2);
    --black-10: rgba(255, 255, 255, 0.1);
    --black-5: rgba(255, 255, 255, 0.05);
    --black-3: rgba(255, 255, 255, 0.03);
    --code-background: #12121c;

    --header-background: rgba(30, 30, 30, 0.4);
    --search-panel-bg: rgba(28, 28, 28, 0.94);
    --header-shadow: rgba(0, 0, 0, 0.3);
    --footer-background: rgba(0, 0, 0, 0.9);
    --footer-ground: #121212;
    --glass-edge: rgba(0, 0, 0, 0.75);
}


/* ===== Base ===== */

body {
    font-family: var(--font-body);
    /* Mobile Safari inflates small text inside a wide block unless told not to
       — its own idea of readability, applied to the text but NOT to the box
       around it. The hero's sticky note is sized as 12em of its own font, so
       the words grew while the paper did not and the note came out a narrow
       column of eight ragged lines on a phone and nowhere else. Everything on
       this page is sized deliberately and relative to the viewport already. */
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    font-size: var(--font-size-base);
    letter-spacing: var(--letter-spacing-tight);
    line-height: var(--line-height-base);
    margin: 0;
    /* The runway the fixed footer is revealed through: a fixed element
       contributes no height, so without this there is nothing left to scroll
       once the content ends and the footer never comes out from under it.

       One radius SHORT of the footer's height on purpose. At exactly the full
       height the page's bottom edge lands on the footer's top edge, and the
       rounded corners then bite through to the page background rather than to
       the footer — the curve reads as a notch instead of a curl. Stopping a
       radius early keeps the content overlapping the top of the footer by
       exactly the amount the corners cut away, so they always sit on grey. */
    padding: 0 0 max(0px, calc(var(--footer-height, 0px) - var(--border-radius-page))) 0;
    color: var(--text-reg);
    /* The scheme crossfade, computed here rather than inherited — see the
       note in :root. */
    background-color: color-mix(in srgb,
            var(--background-color) calc(var(--scheme-t) * 100%),
            #FFFFFF);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;

    /* The footer is fixed at z-index -1, which paints it below body's own
       in-flow layer — and the runway that reveals it is body's padding. So the
       body itself sat over the whole footer and ate every click: the links were
       visible, hover did nothing, and even the text could not be selected.
     *
       Body stops hit-testing and its children take it back. Everything on the
       page is inside one of them, and the footer being one of them is what lets
       a press reach it in the strip where nothing else is. */
    pointer-events: none;
}

body > * {
    pointer-events: auto;
}

h1,
h2,
h3,
h4,
h5 {
    font-weight: 600;
    font-family: var(--font-heading);
    line-height: var(--line-height-tight);
    margin: 0;
}

a {
    color: var(--text-reg);
    text-decoration: none;
    transition: color var(--transition-fast);
}

/* Plain black on hover (white in dark mode — --black flips with the scheme). */
a:hover,
a:focus {
    color: var(--black);
}

a:focus-visible,
:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
}

img {
    max-width: 100%;
}

input,
button {
    font-family: inherit;
    font-size: inherit;
    line-height: inherit;
}


/* ===== Utilities ===== */

.sans {
    font-family: var(--font-body);
    letter-spacing: var(--letter-spacing-wide);
    line-height: var(--line-height-sans-base);
}

.uppercase {
    text-transform: uppercase;
}

.secondary {
    color: var(--text-secondary);
}.display-none {
    display: none;
}.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

.container-width {
    max-width: var(--max-width);
    width: var(--width-desktop);
    margin: auto;
}/* ===== Hero ===== */

/* The hero mark. A stroked hero mark pinned to the viewport; animate-am-logo.js
   drives its draw-on and stroke weight from scroll position. */
/* Sticky, not fixed. It pins for the length of the runway and then releases, so
   the finished mark scrolls up and away as the About section arrives instead of
   sitting in the middle of the screen behind it. */
.hero-mark {
    position: sticky;
    top: 0;
    left: 0;
    width: 100%;
    /* vh, deliberately. The mark centres on 50% of its own box, so this height
       IS its vertical anchor, and svh was tried here — a constant that would
       hold the mark still while mobile browser chrome moves. It is reverted
       because it did not obviously help and the reveal wants isolating one
       change at a time. If the drift returns on a phone, this line and the two
       that follow it below are the group to move together. */
    height: 100vh;
    /* One above .container, so the plate overlaps the top of the name pill that
       sits just below it. The spacer around this one has no z-index of its own
       (see there), so this is compared directly against .container rather than
       being trapped inside a stacking context. Only the mark comes forward: the
       spacer's background and the rest of the hero stay behind, which is what
       keeps this from covering the messages. */
    z-index: calc(var(--z-content) + 1);
    pointer-events: none;
    overflow: visible;
    /* Declared here, not on the plate: the name label is a sibling and sizes its
       own offset off the same figure. */
    --plate-inset: 1.28;
    /* Inked in the body text colour, same as the greeting, so the two read as
       one thing. Picked up below through currentColor. */
    color: var(--hero-ink);
}

/**
 * The art itself, sized in mark units and centred.
 *
 * The viewBox does the scaling, so nothing about the mark's geometry is computed
 * in JavaScript any more. That matters during a window drag: the greeting beside
 * it is positioned in pure CSS, and anything script-driven — even coalesced to a
 * single frame — lags it by that frame and the two visibly come apart.
 */
/* The plate behind the mark.

   50%, not a pixel radius interpolated over the shrink: the radius would be in
   the box's own unscaled units while the box's half-side stays put, so partway
   down it dipped under half the side and the plate visibly passed through a
   rounded square. A percentage is half the box by definition, at every frame. */

.hero-mark__plate {
    position: absolute;
    top: 50%;
    left: 50%;
    /* Scales with the mark, about the same centre, over the same last quarter of
       the scroll — --mark-shrink is only off 1 inside that band, so this holds
       still until the mark starts settling and then comes down with it. */
    width: calc(32 * var(--mark-unit) * var(--plate-inset));
    height: calc(32 * var(--mark-unit) * var(--plate-inset));
    transform: translate(-50%, -50%) scale(var(--mark-shrink, 1));
    will-change: transform, opacity;
    border-radius: 50%;
    /* The badge fades in on opacity rather than on a color-mix background — one
       compositor property instead of a repaint per frame. */
    background: #FFFFFF;
    /* 1px ON SCREEN at every size, by dividing out the scale the plate is drawn
       at. A fixed width cannot do it: at 1px it would thin to a fifth of that
       by the end, and at the 5.5px that lands on 1px at the end it rendered a
       chunky ring across the middle of the hero while the plate was still big.

       This is the one thing here that repaints per frame, and only across the
       last quarter of the scroll. It is a single small element and no layout is
       involved, which is the cheap end of the trade — the expensive version was
       the name label positioning itself with `top`, and that is long gone.

       border-box, so the border grows inward and the plate's outer size stays
       exactly what the width says: the mark centres on that box and would drift
       if the border pushed it outward. */
    box-sizing: border-box;
    border: calc(1px / var(--mark-shrink, 1)) solid var(--black-10);
    opacity: var(--plate-settle, 0%);
}

.hero-mark__art {
    position: absolute;
    top: 50%;
    left: 50%;
    width: calc(30 * var(--mark-unit));
    height: calc(32 * var(--mark-unit));
    /* --mark-shrink is driven from scroll by animate-am-logo.js; it scales about
       the box's own centre, so the mark stays centred as it shrinks. */
    transform: translate(-50%, -50%) scale(var(--mark-shrink, 1));
    overflow: visible;
}

/* The path is fully in the SVG. Dashoffset hides it at rest. Colour lives
   on each <use> as stroke, same as the original G. Do not set stroke: none. */
.hero-mark .am-reveal-layer {
    fill: none;
    stroke-width: 13.44;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 2187.96;
}

.hero-mark circle {
    fill: currentColor;
}

/* Sits over the middle of the hero, where the mark's resting dot is. */
/**
 * Absolute, not sticky: unlike the mark this does not pin, so it scrolls up and
 * away as soon as the page moves. Anchored to the top of the runway, .hero-spacer.
 *
 * Everything here is measured in --mark-unit, one unit of the mark's own 30x32
 * coordinate space, so the greeting scales and sits with the mark on every
 * viewport rather than only agreeing at one size. animate-am-logo.js keeps the
 * property exact; the fallback below is the same formula it uses
 * (min(vw, vh) * sizeRatio / 32), so the first paint already lines up.
 *
 * The vertical anchor is the dot, not the screen: the mark centres at 50vh and
 * the dot sits at (23.83, 12.39) in a box whose middle is (15, 16), so the dot
 * is 3.61 units above that centre. The extra 6 units is the gap above it.
 */
.hero-greeting {
    /* The two typed dots, drawn as circles rather than set as period glyphs.
       A period's size and its place in the line box are the font's business, so
       anywhere the stack does not resolve to SF Pro Rounded — a non-Apple
       device, or a bolder text setting — the pair came out a different size and
       off the ball's line. These are geometry and land identically
       everywhere.

       --dot-size is the ball's resting diameter, baseStroke in
       animate-am-logo.js. That is 1.12 mark units, and this used to be written
       as exactly that — 0.1149em — which came out 1.8% too fat: the mark's SVG
       does not render one viewBox unit at --mark-unit, since the art box and
       the 30x32 box it maps to are not in the same proportion. So the figure
       here is measured against the ball as drawn rather than derived from the
       unit, and the ratio is constant across viewport sizes.

       --dot-pitch is centre to centre, the advance the period had in that face.
       The ball sits one further pitch on, which is what makes the row read as
       three dots rather than two and a stray. */
    --dot-size: 0.1129em;
    --dot-pitch: 0.252em;

    /* The bubble's width, and the height that follows from the artwork's own
       511x242 box. --photo-size is deliberately NOT --bubble-height: the two
       were the same until the bubble was grown on its own, so the photo is
       pinned to the width the bubble had then (2.4em) and no longer tracks it. */
    --bubble-width: 1.7em;
    --bubble-height: calc(var(--bubble-width) * 242 / 511);
    --photo-size: 0.85em;
    --bubble-gap: 0.12em;
    /* Space between the photo and the bubble's left edge. Separate from
       --bubble-gap so the two can be tuned independently. */
    --photo-gap: 0.02em;
    /* Named so the caption below can line its left edge up with the photo's. */
    --photo-right: calc(50% + var(--bubble-width) / 2 + var(--photo-gap) - var(--bubble-shift));
    /* How far below the dots' row the bubble and photo sit. This element's box
       IS the row now, so the offset is measured from its centre rather than
       from a line box — and zero centres the two on the dots, which is where
       the glyph version worked out to anyway. */
    --bubble-drop: 0em;
    /* The bubble centres on the dot row, but the dot group runs past it: the
       hero mark's ball sits --bubble-gap beyond the text plus its own radius
       (baseStroke / 2 = 0.56 mark units). Nudging the bubble right by half of
       that overhang puts all three dots on its centre. */
    --bubble-shift: calc((var(--bubble-gap) + 0.56 * var(--mark-unit)) / 2);

    /* The ball has to sit one pitch past the last typed dot, but translate(-100%)
       lands the row's right EDGE on it — and the row ends half a pitch past the
       last dot's centre, so that half comes off here. */
    --greeting-gap: calc(var(--dot-pitch) / 2);

    position: absolute;
    /* Anchored to the ball, not to the screen. The mark centres on the viewport
       and its 30x32 box has its middle at (15, 16), so the ball at (23.83, 12.39)
       sits 3.61 units above that centre — and 8.83 right of it, less the 0.6 the
       mark's viewBox is offset by, which comes to 8.23. Landing the text's end
       on that point makes the ball the third dot of "Hi..." — the trailing GAP
       is the space that would have preceded it.

       That 0.6 is the original `viewBox="0.6 0 30 32"` optical correction. The
       AM art is a 384 square mapped into this same 30x32 box; the lockup stays
       on the original G ball, not on the calligraphy's own path start. */
    left: calc(50% + (8.83 - 0.6) * var(--mark-unit));
    top: calc(50vh - 3.61 * var(--mark-unit));
    /* The box is exactly the dot row: a pitch per dot across, one dot tall.
       Stated rather than inherited from a line box, since there is no longer any
       text here to size it — which is the point, a line box was the thing that
       moved when the font did. */
    width: calc(2 * var(--dot-pitch));
    height: var(--dot-size);
    /* -50% puts the row's centre, and so both dots' centres, on the ball's own
       centre line. Nothing font-derived is left to correct for, so the hand
       nudge that used to ride along here is gone. */
    transform: translate(calc(-100% - var(--greeting-gap)), -50%);
    /* Under the mark. The greeting sits later in the DOM, so at an equal z-index
       it — and the blob behind it — would paint over the hero mark and hide it as
       it draws. The blob cannot be lowered on its own: this element is
       positioned with a z-index, so it forms a stacking context its children
       cannot escape. */
    z-index: calc(var(--z-content) - 1);
    pointer-events: none;
    /* Sets nothing typographic any more — no text is left here — but every
       figure in the lockup, the dots included, is in em, so this one value still
       scales the group. Kept at the size the typed version was set at, so the
       bubble and photo are untouched by the swap. */
    font-size: calc(9.75 * var(--mark-unit));
    color: var(--hero-ink);
}

/* The caption under the lockup, left-aligned to the photo.
 *
 * The greeting's own box is only the two dots wide and sits with its right edge
 * on the hero mark's ball, so everything in this lockup is placed off that edge.
 * The photo hangs at --photo-right from it; its LEFT edge is that plus its own
 * width, and this starts there. All in em like the rest of the group, so the
 * one font-size on .hero-greeting still scales the whole thing.
 *
 * Absolutely positioned, so it cannot widen the greeting — that width is what
 * the translate(-100%) anchoring measures to put the dots on the ball.
 *
 * The type size is set on the line INSIDE rather than here, and that is not
 * cosmetic: the offsets above are in em, and em on this element would resolve
 * against its own font-size. Shrink the text here and the whole caption walks
 * out of position with it. */
.hero-thinking {
    position: absolute;
    left: calc(100% - var(--photo-right) - var(--photo-size));
    /* Tucked close under the photo: the caption belongs to the lockup, and a
       gap the size of the one the bubble already has above it read as a
       separate line of the page. */
    top: calc(50% + var(--bubble-drop) + var(--photo-size) / 2 + 0.01em);
    margin: 0;
    white-space: nowrap;
    /* Zero, and the line inside sets its own. This box inherits the lockup's
       font-size — that is the whole point, the offsets above are in its em —
       and with it an inherited line-height near 100px. The small text would
       then sit on the baseline of a line box that tall, floating well below
       where the box itself is. */
    line-height: 0;
}

.hero-thinking__line {
    display: inline-block;
    font-family: var(--font-body);
    font-size: 0.207em;
    font-weight: 500;
    line-height: 1.2;
    /* The grey the word rests in, before and after the colour passes through. */
    --thinking-ink: rgba(0, 0, 0, 0.32);
    color: var(--thinking-ink);
}

/* The word that changes.
 *
 * It leaves by being backspaced a character at a time — that part is JS, since
 * it is the text itself changing — and the next one washes in behind a soft
 * gradient edge travelling left to right, which is this.
 *
 * The mask is a hard-to-transparent ramp whose position IS --wash: everything
 * left of it shows, everything right of it is hidden, and the 16% between is
 * the soft edge. Registered as a percentage (see @property below) so a keyframe
 * can interpolate it — unregistered it would step, not sweep.
 *
 * --wash rests at 100%, which is the whole word visible. That matters for a
 * reader with no JavaScript: nothing runs, and the word simply sits there. */
.hero-thinking__word {
    display: inline-block;

    /* Two gradients riding the same --wash.
     *
       The MASK is the reveal: opaque to the left of --wash, clear to the right,
       with 28% of soft edge between. The BACKGROUND is the wash itself — the
       text is painted from a gradient clipped to its own glyphs, carrying the
       hero mark's own six colours as a band centred on --wash and falling away to
       the resting grey either side. So the edge does not just uncover the word,
       it colours it as it passes and leaves it settled behind, which is the
       same thing the mark does as it draws.

       color: transparent is what background-clip: text needs to show through;
       the caret below therefore has to name its own colour rather than take
       currentColor, which is now nothing. */
    color: transparent;
    background-image: linear-gradient(90deg,
            var(--thinking-ink) 0%,
            var(--thinking-ink) calc(var(--wash) - 34%),
            var(--reveal-1) calc(var(--wash) - 25%),
            var(--reveal-2) calc(var(--wash) - 15%),
            var(--reveal-3) calc(var(--wash) - 5%),
            var(--reveal-4) calc(var(--wash) + 5%),
            var(--reveal-5) calc(var(--wash) + 15%),
            var(--reveal-6) calc(var(--wash) + 25%),
            var(--thinking-ink) calc(var(--wash) + 34%),
            var(--thinking-ink) 100%);
    -webkit-background-clip: text;
    background-clip: text;

    -webkit-mask-image: linear-gradient(90deg, #000 var(--wash), transparent calc(var(--wash) + 28%));
    mask-image: linear-gradient(90deg, #000 var(--wash), transparent calc(var(--wash) + 28%));
}

.hero-thinking__word.is-washing {
    animation: thinking-wash 0.79s cubic-bezier(0.25, 0.6, 0.3, 1);
}

@keyframes thinking-wash {
    from {
        --wash: -28%;
    }

    to {
        --wash: 140%;
    }
}

@media (prefers-reduced-motion: reduce) {
    .hero-thinking__word.is-washing {
        animation: none;
    }
}

/* Sat behind the greeting and centred on it. Sized in em so it scales with the
   type — and therefore with --mark-unit — instead of needing its own maths. */
.hero-greeting__blob path {
    fill: var(--black-5);
}

.hero-greeting__blob {
    position: absolute;
    top: 50%;
    left: 50%;
    width: var(--bubble-width);
    height: auto;
    transform: translate(calc(-50% + var(--bubble-shift)), calc(-50% + var(--bubble-drop)));
    /* Behind the text, which is lifted by .hero-greeting__text below. */
    z-index: 0;
    pointer-events: none;
}

/* Sat to the left of the bubble, matched to its height. Absolutely positioned
   like the bubble: anything in the flow here would widen .hero-greeting, and its
   width is what the translate(-100%) anchoring measures to line the last dot up
   with the hero mark's ball. */
.hero-greeting__photo {
    position: absolute;
    top: 50%;
    /* Right edge lands on the bubble's left edge, less the gap. The bubble is
       nudged right by --bubble-shift to centre the dots, so that comes off here
       too or the visible gap would grow by the same amount. */
    right: var(--photo-right);
    transform: translateY(calc(-50% + var(--bubble-drop)));
    /* A circle, with the photo cropped to fill it from its centre rather than
       squashed to fit. Sized off --photo-size so growing the bubble leaves it
       alone; aspect-ratio pins it square even if one axis is overridden. */
    height: var(--photo-size);
    width: var(--photo-size);
    min-width: var(--photo-size);
    aspect-ratio: 1 / 1;
    object-fit: cover;
    object-position: center;
    border-radius: 50%;
    z-index: 0;
}

/* Fills the row and lifts the dots over the blob behind them. */
.hero-greeting__text {
    position: absolute;
    inset: 0;
    z-index: 1;
}

/* Each dot centred in its own pitch — where a period sat inside its advance
   width — and vertically filling the row, which is one dot tall. Placed with
   left and a margin rather than a transform, because the bounce below needs the
   transform to itself. */
.hero-greeting__dot {
    position: absolute;
    top: 0;
    width: var(--dot-size);
    height: var(--dot-size);
    margin-left: calc(var(--dot-size) / -2);
    border-radius: 50%;
    background: currentColor;
}

.hero-greeting__dot:nth-child(1) {
    left: calc(var(--dot-pitch) / 2);
}

.hero-greeting__dot:nth-child(2) {
    left: calc(var(--dot-pitch) * 1.5);
}

/* Same keyframes the mark bounces on, so the typed dots and the hero mark's ball
   move identically. Negative delays put these AHEAD of the ball in the cycle, so
   the bounce travels left to right and finishes on the ball. */
.hero-greeting.is-resting .hero-greeting__dot:nth-child(1) {
    animation: dot-bounce 2.6s linear infinite;
    animation-delay: -0.3s;
}

.hero-greeting.is-resting .hero-greeting__dot:nth-child(2) {
    animation: dot-bounce 2.6s linear infinite;
    animation-delay: -0.15s;
}

.hero-greeting.is-settling .hero-greeting__dot {
    transition: transform 0.45s cubic-bezier(0.2, 0.9, 0.3, 1);
}

@media (prefers-reduced-motion: reduce) {
    .hero-greeting.is-resting .hero-greeting__dot {
        animation: none;
    }
}

/* No dark-mode treatment here on purpose: the hero is pinned to light in every
   scheme — see .hero-spacer, which redeclares the tokens its children read. The
   #ios-blue gradient in _layouts/home.html is left in place but unreferenced;
   the About thread below is where the blue bubble lives now. */

/* At rest the mark is a single dot, so nudging the whole layer reads as the dot
   bouncing. animate-am-logo.js drops .is-resting on the first scroll and eases
   whatever transform the bounce was mid-way through back to zero. */
.hero-mark.is-resting {
    animation: dot-bounce 2.6s linear infinite;
}

.hero-mark.is-settling {
    transition: transform 0.45s cubic-bezier(0.2, 0.9, 0.3, 1);
}

/**
 * Impatient, then decaying, then waiting.
 *
 * One hard shove up and a fall, a smaller rebound, a smaller one again, then a
 * long flat hold before it goes round again — the hold is most of the cycle,
 * which is what makes the bounces read as impatient rather than idle. Rises use
 * ease-out and falls ease-in, so gravity looks like gravity.
 */
/* The hop heights are in --mark-unit, like the rest of the hero, NOT in px.
   Hardcoded pixels were the one part of this lockup that did not scale: the
   bubble is sized off min(vw, vh), so on a phone it is less than half its
   desktop size while a 34px hop stayed 34px — and the dots bounced clean out
   through the top of it. These are the old figures divided by the desktop unit
   they were tuned against (34 / 13.75 ≈ 2.47), so desktop is unchanged. */
@keyframes dot-bounce {
    0% {
        transform: translateY(0);
        animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
    }

    11% {
        transform: translateY(calc(-2.47 * var(--mark-unit)));
        animation-timing-function: cubic-bezier(0.7, 0, 0.9, 0.4);
    }

    22% {
        transform: translateY(0);
        animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
    }

    29% {
        transform: translateY(calc(-1.02 * var(--mark-unit)));
        animation-timing-function: cubic-bezier(0.7, 0, 0.9, 0.4);
    }

    36% {
        transform: translateY(0);
        animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
    }

    40% {
        transform: translateY(calc(-0.29 * var(--mark-unit)));
        animation-timing-function: cubic-bezier(0.7, 0, 0.9, 0.4);
    }

    44%,
    100% {
        transform: translateY(0);
    }
}

@media (prefers-reduced-motion: reduce) {
    .hero-mark.is-resting {
        animation: none;
    }
}

/* The mark, in normal flow on every page; the home hero carries its own.
   The row holds the 90vw column so it lines up with the body text; the anchor
   inside is only as wide as the mark, so the hit area is the mark rather than
   the whole width of the page. */
/* The same column as the content below it: back button flush with the left edge
   of the text, search flush with the right.
 *
   --header-gutter is the padding, and the absolutely-placed controls have to be
   inset by it by hand. An absolute box resolves against its ancestor's PADDING
   box, not its content box, so `left: 0` would put the back button hard against
   the row's outer edge and a whole gutter out of line with the prose beneath. */
/* ===== Immersive search =====
 *
   Focusing the field dims and blurs the page behind it and opens the results
   over the top. NOTHING MOVES: the field stays in the header (or in the hero)
   exactly where it was, at the width it was, and the panel opens beneath it the
   way it always did. The mode is a change of ground, not of layout.

   It used to lift the field to the top of a full-window overlay, which meant
   every element in the bar jumped on open and jumped back on dismiss — and then
   needed a morph animation to cover the jump. Leaving it where it is costs
   nothing and needs nothing.

   .is-immersive goes on the .search root; the html class beside it freezes the
   page underneath. Both are set by search.js. */
/* Above the ground below, and above the frozen page. The field and the results
   are this element's children, so one number lifts the lot. */
.search.is-immersive {
    z-index: var(--z-controls);
}

/* The header comes with it: back, the mark and the switcher stay visible and
   stay clickable, rather than being blurred out with the page they sit on. In
   flow and unmoved — only the layer changes. */
:root.is-search-immersive .logo-mark-row {
    z-index: calc(var(--z-controls) + 1);
}

/* The dimmed, blurred ground: a fixed layer behind the field and the results,
   inside .search so it shares a stacking context with them.

   Not quite opaque, so the page reads as still being there. The blur is what
   keeps it from competing: at this radius nothing behind resolves into anything
   legible. Only its opacity is animated — the one thing that can appear without
   shifting a pixel of anything else. */
.search.is-immersive::before {
    content: '';
    position: fixed;
    inset: 0;
    background-color: color-mix(in srgb, var(--background-color) 86%, transparent);
    backdrop-filter: blur(16px) saturate(120%);
    -webkit-backdrop-filter: blur(16px) saturate(120%);
    z-index: -1;
    animation: search-ground-in var(--search-immersive-ms) var(--search-immersive-ease) both;
}

@keyframes search-ground-in {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

@media (prefers-reduced-motion: reduce) {
    .search.is-immersive::before {
        animation: none;
    }
}

/* The close button sits beside the field — the only thing the mode adds to the
   bar, appearing in the room that was already to its right.

   It slides in FROM the right on a plain CSS transition, which means it has to
   exist before it can move: `hidden` would take it out of layout entirely and
   there would be nothing to transition from. search.js sets .is-immersive and
   leaves the attribute alone, and the states below are the whole animation.

   The field's gap opens on the same transition, so the input gives up its width
   at the same rate the button takes it. */
.is-immersive .search__field {
    gap: var(--spacing-sm);
}

.search__field {
    transition: gap var(--search-immersive-ms) var(--search-immersive-ease);
}

.is-immersive .search__input {
    flex: 1 1 auto;
    width: auto;
    min-width: 0;
}

/* Resting: no width, no room taken, parked to the right of where it will sit.
   Kept in the layout rather than hidden so there is something to move. */
.search__close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    flex: none;
    width: 0;
    height: var(--logo-mark-size);
    padding: 0;
    border: 0;
    border-radius: 50%;
    overflow: hidden;
    opacity: 0;
    transform: translateX(var(--logo-mark-size));
    pointer-events: none;
    /* Not just invisible: a zero-width button with opacity 0 is still a tab
       stop, and Tab would land on nothing between the field and the page. */
    visibility: hidden;
    background-color: var(--black-5);
    color: var(--text-reg);
    font-size: var(--font-size-lg);
    line-height: 1;
    cursor: pointer;
    appearance: none;
    transition: width var(--search-immersive-ms) var(--search-immersive-ease),
        opacity var(--search-immersive-ms) var(--search-immersive-ease),
        transform var(--search-immersive-ms) var(--search-immersive-ease),
        visibility var(--search-immersive-ms);
}

.is-immersive .search__close {
    width: var(--logo-mark-size);
    opacity: 1;
    transform: none;
    pointer-events: auto;
    visibility: visible;
}

@media (prefers-reduced-motion: reduce) {

    .search__close,
    .search__field {
        transition: none;
    }
}

.is-immersive .search__close:hover,
.is-immersive .search__close:active {
    background-color: var(--black-10);
}

/* The results run to the bottom of the window instead of stopping at the shared
   cap — the ground behind them is the whole screen, so a 560px box floating in
   the middle of it reads as a leftover panel. --search-panel-room is measured by
   search.js: the window, less wherever the field ended up. It cannot be written
   here, since that depends on how far down the page the bar happens to be.

   Everything else about the panel is left alone. It is absolutely positioned
   under the field at rest and stays exactly there. */
.is-immersive .search__panel {
    max-height: var(--search-panel-room, 70vh);
    background: none;
    border: 0;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow: none;
    /* No padding either. The 12px is the inset from the panel's own glass edge,
       and with the glass gone it is just a step: the rows and the group labels
       came out 12px inboard of the field they hang under, which reads as a
       column that does not line up with anything. At zero the rows share the
       field's edges, so the hover highlight runs the full width of it. */
    padding: 0;
}

/* The page does not scroll while the mode is open, so the hero cannot advance
   underneath it — a thumb dragging down a list of results should not also be
   shrinking the mark behind it.

   Done by taking the body out of flow at a negative offset — search.js sets
   `top: -<scrollY>px` and puts the offset back on the way out — rather than
   with `overflow: hidden` on the root. Overflow was the first attempt and it
   is the reason this comment is long: collapsing the scrollable height makes
   the browser CLAMP the scroll offset to whatever is left, so the page behind
   jumped from 900px down the hero to 76 the moment the mode opened. Invisible
   under an opaque overlay, but not under one that fades in — and the whole
   page lurching behind the fade was the first thing the animation showed up.
   Offsetting the body instead holds every pixel exactly where it was. */
:root.is-search-immersive body {
    position: fixed;
    left: 0;
    right: 0;
    width: 100%;
}

/* The margins it carried as a header row are the header's, not this one's. */
/* Above the search field, though it comes after it in the source — the field is
   emitted first so it is the first tab stop (see site/logo.html), and this puts
   the row back on top. .page-shell is a flex column and both are direct
   children of it, so the two are free to be ordered apart from the source.

   -1 rather than reordering everything: .page-main and the rest keep the
   default 0 and stay in source order behind the field. */
.logo-mark-row {
    order: -1;
    --header-gutter: var(--spacing-xl);
    position: relative;
    width: 100%;
    max-width: var(--measure);
    margin: var(--spacing-4xl) auto var(--spacing-lg) auto;
    padding: 0 var(--header-gutter);
    box-sizing: border-box;
}

/* Back to the listing, on tag pages only. Taken out of flow deliberately: in
   flow it would be a sibling competing for the row's width and the mark would
   centre against what was left of it rather than against the page. */
/* The round controls either side of the mark: back on the left, and on mobile
   the search toggle and its close button on the right.
 *
   Declared together, and that matters. One of these is an <a> and the others
   are <button>s, and a button carries two user-agent defaults an anchor does
   not: box-sizing: border-box, and padding: 1px 6px. Styled apart, the same
   width and the same 55% icon came out 38px holding a 20x20 glyph on the
   anchor and 36px holding a 12x18 one on the buttons — a visibly smaller,
   squashed icon in a slightly smaller circle. Both defaults are reset here so
   the three are the same control wearing different glyphs. */
.logo-back,
.theme-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    padding: 0;
    width: var(--logo-mark-size);
    height: var(--logo-mark-size);
    border-radius: 50%;
    color: var(--text-secondary);
    background-color: var(--black-5);
    border: 1px solid rgba(128, 128, 128, 0);
    transition: var(--transition-normal);
    /* Buttons otherwise take the platform control font, not the page's. */
    font: inherit;
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
}

.logo-back {
    position: absolute;
    left: var(--header-gutter);
    top: 50%;
    transform: translateY(-50%);
}

.logo-back svg {
    /* A fraction of the circle rather than a fixed figure, so glyph and circle
       stay in proportion when --logo-mark-size drops at the mobile breakpoint.
       Square, because the box it is a fraction of is now square on all three. */
    width: 55%;
    height: 55%;
}

.logo-back:hover,
.theme-toggle:hover {
    color: var(--text-reg);
    background-color: var(--background-color);
    border: 1px solid var(--black-5);
    box-shadow: 0 0 0 1px var(--black-5);
}

.logo-back:focus-visible,
.theme-toggle:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
}

.logo-mark {
    display: block;
    width: fit-content;
    /* Centred in the column rather than hung off its left edge. fit-content is
       what makes auto margins bite — a block at full width has nothing to
       centre. */
    margin-inline: auto;
    color: var(--text-reg);
    transition: color var(--transition-fast);
}

.logo-mark:hover {
    color: var(--black);
}

.logo-mark__art {
    display: block;
    /* Height, not width: the mark is read against the line of text beneath it. */
    height: var(--logo-mark-size);
    width: auto;
    overflow: visible;
}

/* Resting mark is the filled ink layer on the AM 384-viewBox mark. The
   archive G (viewBox 0.6 0 30 32) is still an open stroke and is left alone.
   Hover replays lagged colour copies via logo-mark-reveal.js. */
.logo-mark__art[viewBox="0 0 384 384"] .am-reveal-layer {
    fill-rule: nonzero;
    stroke: none;
}

.logo-mark__art:not([viewBox="0 0 384 384"]) .am-reveal-layer {
    fill: none;
    stroke-width: 1.12;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 166.2067;
    stroke-dashoffset: 0;
}

/* At rest only the ink layer shows.
 *
   The six colour layers sit exactly under it, and covering them by painting the
   ink one last is only reliable while the stroke is wide enough for its
   antialiased fringe not to matter. The hero is drawn hundreds of pixels tall
   and gets away with it; here the mark is 44px, the 13.44-unit stroke in the
   384 viewBox lands at about a pixel and a half, and the fringe is a real fraction
   of it — so all six
   colours bled out around the ink as a rainbow halo. Hidden outright instead,
   and shown only while the reveal is actually running. */
.logo-mark__art .am-reveal-layer {
    opacity: 0;
}

.logo-mark__art .am-reveal-layer:last-of-type {
    opacity: 1;
}

.logo-mark.is-revealing .am-reveal-layer {
    opacity: 1;
}

.logo-mark__dot {
    fill: currentColor;
    /* r is in viewBox units and the cap is half the stroke. Kept in step with
       --logo-mark-stroke so the ball is exactly the cap it sits under. */
    r: calc(var(--logo-mark-stroke) / 2);
}

/**
 * Scroll runway for the hero mark reveal.
 *
 * animate-am-logo.js measures this element: the mark inside is sticky, so it pins
 * for this height less one viewport, and the reveal is timed to finish exactly
 * as that pin releases. Changing this height retimes the whole effect. The last
 * 100vh is the mark scrolling up as the About section arrives.
 * Keep it a sized element rather than a margin on .container: margins collapse,
 * and a `margin: 0` in any later breakpoint silently wipes out the whole effect.
 */
.hero-spacer {
    height: 250vh;
    position: relative;

    /* The hero is pinned to light in every scheme: a black mark on white, with
       the grey typing bubble beside it. Done by redeclaring the tokens its
       children read rather than by hardcoding colours on each of them — the
       mark's finisher layer is `stroke="currentColor"`, the bubble is
       --black-5, the plate's ring is --black-10, and all three inherit from
       here. The background has to be set too, or dark mode would leave a black
       mark on a black page.

       Anything added inside .hero-spacer inherits this, which is the intent:
       the whole hero is one light panel and the page goes dark below it.

       The ink stays black rather than fading with the page: cross-fading the
       mark and the ground together would wash both out to the same mid-grey
       halfway through. The mark is scrolling up and out over that stretch
       anyway, so it keeps its contrast for as long as it is worth seeing.

       --hero-ink rather than an override of --text-reg: the name label's colour
       is mixed against --text-reg, and shadowing that token here would resolve
       it to plain black inside the hero — which is exactly what that label must
       not do. */
    --hero-ink: rgb(42, 42, 42);
    --black-5: rgba(0, 0, 0, .05);
    --black-10: rgba(0, 0, 0, .1);
    /* The hero's ground: plain white, crossfading to the page's own background
       so the two meet without a seam. It was a shade off white while the
       stickers were here, to give their white die-cut edges something to read
       against — with the ring hidden on a phone there is nothing left that
       needs the contrast. */
    background: color-mix(in srgb,
            var(--background-color) calc(var(--scheme-t) * 100%),
            #FFFFFF);
    /* NO z-index, deliberately. A z-index here would make the spacer a stacking
       context, and then nothing inside it could ever be ordered against
       anything inside .container — the two would stack whole, one entirely in
       front of the other. Both extremes were tried and both are wrong: below,
       and the name pill covers the mark's plate; above, and this element's own
       250vh background covers the messages.

       Left as auto, the spacer paints in flow and its children order against
       .container individually — which is what lets .hero-mark alone come
       forward. */
    pointer-events: none;
    /* The mark is centred in a viewport-tall sticky box, so once it shrinks the
       bottom half of that box is dead space between it and the About copy. This
       pulls the following content up through it. It does NOT change the runway's
       height, so the reveal keeps its timing — animate-am-logo.js measures the
       height, and a margin is outside that. */
    margin-bottom: -47vh;
    /* The stickers are thrown well past the window on their way out, and a
       transform that lands right of the viewport is scrollable overflow. clip,
       NOT hidden: hidden would make this a scroll container and break the
       sticky pinning of everything inside it, which is the entire hero. clip
       also lets one axis stay visible, which matters — the runway has to keep
       overflowing downward. */
    overflow-x: clip;
}

/**
 * The sticker ring.
 *
 * Six cut-outs from the Zomato iMessage pack, sat around the mark at rest and
 * thrown radially out of frame as the page scrolls. The layer pins to the
 * viewport for the length of the runway exactly as .hero-mark does, so the
 * stickers stay centred on the mark rather than scrolling off with the page.
 *
 * height: 0 is what makes that free. A sticky box holds its place in flow, and
 * a viewport-tall one here would take 100vh out of the runway and leave the
 * mark half the pinned run it is timed against. At zero it costs no layout at
 * all and the stickers hang off it absolutely.
 */
.hero-stickers {
    position: sticky;
    top: 0;
    height: 0;
    /* Under both the mark and the typing bubble, so anything that passes behind
       the lockup on its way out reads as a layer below it rather than clutter
       across it. .hero-spacer is the stacking context; this only orders against
       its siblings inside it. */
    z-index: calc(var(--z-content) - 2);
    pointer-events: none;

    /* The ring the stickers rest on, as an ellipse rather than a circle: vmin
       on both axes would put the top and bottom pair off the edge of a
       landscape window while leaving the sides bunched against the mark.

       The vmin floor under each axis is what keeps a phone workable. The
       lockup is sized off min(vw, vh) but the ring would be sized off the
       narrow axis alone, so on a tall window the two close on each other and
       the side stickers end up against the typing bubble. The floor holds them
       off it; on a desktop the viewport term is the larger of the two anyway
       and the floor never applies. */
    --ring-x: max(27vw, 35vmin);
    --ring-y: max(28svh, 24vmin);

    /* Widened on a desktop — see the media block below. */
    /* How much further out they are thrown by the end of the fade, as a
       multiple of the ring. 1.6 carries them past the corner of the window. */
    --sticker-throw: 1.6;


    /* Sized off the shorter axis, so a sticker is the same size relative to the
       mark on a phone as on a desktop, and clamped at both ends so it stays
       legible on a small screen without turning into wallpaper on a large one.

       This is the size of a sticker whose art is 512px tall, the resolution
       most of the pack is exported at. --sticker-unit turns it into what one
       source pixel is worth, which is what each sticker is actually drawn
       from — so the whole ring still resizes off this one figure. */
    --sticker-size: clamp(6.75rem, 24vmin, 17.25rem);
    --sticker-unit: calc(var(--sticker-size) / 512);
}

/* The sticker ring is dealt WIDER on a desktop. The ring is where each sticker
   is dealt, and the deal is the only thing that ever puts one in a corner —
   relaxation from there pushes neighbours apart locally, it does not go looking
   for empty room. At 27vw the ellipse covers about the middle half of a 1280px
   window, so the top left and top right were never candidates: the solver
   scored them as holes and had no way to fill them, which is why they came up
   empty nearly every deal. Reaching further out gives it somewhere to deal
   into, and the grid cost already in the scorer is what then prefers the
   arrangements that use it.

   Above the breakpoint only. On a phone the vmin floors are already doing this
   work and the field is full either way.

   Written as its own block rather than nested inside .hero-stickers: this
   stylesheet is served as authored with no build step, and CSS nesting is
   younger than the browsers it has to survive. A nested rule that failed to
   parse here would silently leave the desktop ring at its narrow value. */
@media (min-width: 801px) {
    .hero-stickers {
        --ring-x: max(38vw, 35vmin);
        --ring-y: max(34svh, 24vmin);
    }
}


/* On a phone the stickers drop to 20vmin and the ring keeps its vmin floor.
   29vmin + a tighter ring was the opposite: the art grew while the ellipse
   they sit on shrank, so neighbours landed inside each other below 800px.
   20vmin is the original phone size; the 35vmin floor is the same one the
   desktop rule already uses, so a 375-wide window still has somewhere to deal
   into instead of stacking the pack on the lockup. */
@media (max-width: 800px) {
    .hero-stickers {
        --sticker-size: clamp(5.5rem, 20vmin, 11rem);
    }
}

/* The opening deal. layout-hero-stickers.js searches for an arrangement that
   fits, and plays that search rather than hiding it: one attempt per frame,
   with one more sticker turned up each time, so the ring is laid out in front
   of the reader instead of simply being there.

   visibility rather than display, so every sticker keeps its box and the solver
   is placing the same layout the reader ends up looking at. Scoped to the class
   the script adds, so with no JavaScript the ring is simply all there. */
.hero-stickers.is-dealing .hero-sticker:not(.is-dealt) {
    visibility: hidden;
}

/* While a note is showing, the ring comes out from behind the lockup so the
   note is not read through the mark. Only then — the stickers sit UNDER the
   mark the rest of the time, which is the arrangement the hero is built on. The
   same applies while a sticker is being dragged, so it travels over everything
   rather than behind it. */
.hero-stickers:has(.hero-sticker__hit:hover),
.hero-stickers:has(.hero-sticker.is-open),
.hero-stickers:has(.hero-sticker.is-dragging) {
    z-index: calc(var(--z-content) + 1);
}

/* The shadow comes OFF the moment the ring starts moving, and back on when it
   settles. A drop-shadow is a blur pass baked into the layer, and six filtered
   layers being transformed and faded together — which is the whole of the first
   100px of this page — is the most expensive thing the hero does on a phone.
   At rest it is one raster and free; in motion it buys nothing, because they
   are travelling out of frame and fading while it happens. */
.hero-stickers.is-drifting .hero-sticker__art,
.hero-stickers.is-drifting .hero-sticker__note {
    filter: none;
    /* transition: none is the point. The art carries a filter transition for the
       drag lift, and without this the shadows would ANIMATE away over 160ms on
       all six at once — an interpolating drop-shadow on every frame, which is
       worse than the thing being removed, and starting exactly at the first
       scroll. */
    transition: none;
}

/* Once the burst is spent the ring is at opacity 0 but still a live layer: six
   composited boxes and six infinite float animations, all ticking behind the
   messages for the rest of the page. display: none takes them out of the frame
   entirely. Toggled from animate-am-logo.js rather than keyed off the scalar,
   since CSS cannot branch on a number — and removed again on the way back up,
   because the burst is scrubbed and scrolling up has to bring them home. */
.hero-stickers.is-spent {
    display: none;
}

.hero-sticker {
    position: absolute;
    top: 50vh;
    left: 50%;
    /* Each sticker at its OWN pixel height, times one factor shared by all of
       them: --art-h is the file's natural height, straight from the markup, and
       --sticker-unit is what a single source pixel is worth on screen.

       This replaced a per-sticker --sticker-scale, each roughly sqrt(0.8 /
       aspect), which existed to even out a common height — sizing every cut-out
       to the same height meant a 2.6:1 one drew two and a half times the width
       of a square one and swamped the ring. Those figures were a correction for
       the sizing rather than a decision about the artwork, and every new
       sticker needed one worked out for it. Now the art's own resolution is the
       decision: export it larger to have it sit larger. Width is left to
       shrink-wrap, so aspect looks after itself.

       --art-scale is the solver's own small adjustment on top — a little give
       either side of the authored size, which it uses to make a pile fit. See
       layout-hero-stickers.js. */
    height: calc(var(--art-h, 512) * var(--sticker-unit) * var(--art-scale, 1));

    /* The radial move.
     *
     * The throw is the sticker's WHOLE offset from the centre of the ring
     * multiplied out — its resting place plus wherever the reader has since
     * dragged it — so it always leaves along the line it currently sits on
     * rather than the one it was authored on. Drag one across the hero and it
     * flies out from its new position, no recalculation required: --drag-x/y
     * are already in the sum, so the direction falls out of the arithmetic.
     *
     * --throw-t is the eased burst times how far out to carry it, and is 0 at
     * the top of the page, where this reduces to the resting ring.
     *
     * The drag itself stays OUT of this, on the independent translate property:
     * inside the sum it would be multiplied by the throw too, and the sticker
     * would outrun the cursor once the page had started moving.
     *
     * All cheap — this was wrongly suspected of the judder that turned out to
     * be the idle float animation. A transform and an opacity are both
     * compositor properties, so the throw moves a layer that is already
     * rasterised rather than repainting anything. */
    --throw-t: calc(var(--sticker-burst) * var(--sticker-throw));
    transform: translate(-50%, -50%) translate(calc(var(--dx) * var(--ring-x) + var(--throw-t) * (var(--dx) * var(--ring-x) + var(--drag-x, 0px))),
            calc(var(--dy) * var(--ring-y) + var(--throw-t) * (var(--dy) * var(--ring-y) + var(--drag-y, 0px)))) scale(var(--drag-scale, 1));

    /* Where the reader has dragged it to. The independent translate property,
       NOT another term in the transform above: that composes WITH transform
       rather than replacing it, and keeps the drag exactly on the cursor while
       the transform is free to scale the offset for the throw.

       --jitter-x/y ride along here during the opening deal: a few pixels of
       wobble on every sticker already on the table each time another one is
       laid down, which is what makes it read as stop motion rather than as
       things switching on. Kept out of --dx/--dy so the solved layout is not
       disturbed, and zeroed when the deal finishes. */
    translate: calc(var(--drag-x, 0px) + var(--jitter-x, 0px)) calc(var(--drag-y, 0px) + var(--jitter-y, 0px));

    opacity: calc(1 - var(--sticker-burst));

    --sticker-shadow:
        drop-shadow(calc(var(--shadow-x, 0px) * 0.35) calc(var(--shadow-y, 2px) * 0.35) calc(var(--sticker-size) * 0.06) rgba(0, 0, 0, 0.09)) drop-shadow(var(--shadow-x, 0px) var(--shadow-y, 2px) calc(var(--sticker-size) * 0.2) rgba(0, 0, 0, 0.07));

    /* All compositor properties, so say so and the throw runs on its own layer.
       .is-spent drops them entirely once it is done. */
    will-change: transform, translate, opacity;
}

/* Raised above the other stickers whenever it has something to show.
 *
 * This has to be on the STICKER, not the note: .hero-sticker carries a
 * transform, which makes it a stacking context, so the note's own z-index only
 * ever orders it against its own siblings inside that sticker — it cannot climb
 * over a neighbouring sticker no matter how high it goes. Lifting the whole
 * sticker is what actually gets the note clear of the ring. */
.hero-sticker:has(.hero-sticker__hit:hover),
.hero-sticker:has(.hero-sticker__hit:focus-visible),
.hero-sticker.is-open,
.hero-sticker.is-dragging {
    z-index: 9;
}

/* Off the page and into the reader's hand: further, softer and darker, which
   is the whole of what makes it read as picked up. */
.hero-sticker.is-dragging {
    --sticker-shadow:
        drop-shadow(calc(var(--shadow-x, 0px) * 0.6) calc(var(--shadow-y, 2px) * 0.6) calc(var(--sticker-size) * 0.06) rgba(0, 0, 0, 0.16)) drop-shadow(calc(var(--shadow-x, 0px) * 2.2) calc(var(--shadow-y, 2px) * 2.2 + var(--sticker-size) * 0.05) calc(var(--sticker-size) * 0.26) rgba(0, 0, 0, 0.22));
}

/* Lifted while held: bigger, and out from under its neighbours. The scale is a
   variable rather than a literal so the drop animation below can pick up from
   wherever it was. */
.hero-sticker.is-dragging {
    --drag-scale: 1.12;
    cursor: grabbing;
}

/* Snapping down onto the page. Runs on scale alone — the drop position is
   already where the reader left it — so it composes with the drag offset
   without disturbing it. Overshoots small, then settles: a sticker pressed flat
   rather than a ball landing. */
.hero-sticker.is-applied {
    animation: sticker-apply 0.34s cubic-bezier(0.3, 0.9, 0.4, 1);
}

@keyframes sticker-apply {
    0% {
        --drag-scale: 1.12;
    }

    45% {
        --drag-scale: 0.94;
    }

    100% {
        --drag-scale: 1;
    }
}

/* The tilt is a static transform, not an animation. There was an idle float on
   here — a 6s infinite bob — and it was the single most expensive thing the
   stickers did: measured, removing it alone brought a forced style-and-layout
   pass back to what it costs with the ring deleted outright. Six elements
   animating forever, at rest as much as in motion, is simply not worth a 5%
   wobble on a decorative layer. --float-delay is kept in the markup so the
   stagger is still there if it ever comes back. */
.hero-sticker__art {
    display: block;
    height: 100%;
    width: auto;
    transform: rotate(calc(var(--tilt, 0deg) + var(--jitter-r, 0deg)));
    /* An <img> is a native drag source: press and move and the browser starts
       its own image drag, which cancels the pointer stream the sticker drag is
       built on. The markup carries draggable="false" as well — belt and braces,
       since the two are honoured unevenly across browsers. */
    -webkit-user-drag: none;
    user-select: none;
    pointer-events: none;
    /* A die-cut sticker shadow: close and light rather than a soft drop. Two
       passes, because that is what reads as vinyl — a faint contact shadow
       hugging the cut edge, which is what makes it look stuck DOWN, and a
       second, barely-offset blur beneath it for the lift. A single wide blur
       reads as a card hovering well above the page instead. Both are low in
       opacity: the art already carries a white die-cut border doing most of the
       lifting, so a dark shadow under it turns harsh immediately.

       drop-shadow, not box-shadow: the art is transparent around its edges, so
       a box shadow would trace the image's rectangle rather than the shape
       inside it. The blurs are sized off --sticker-size so they hold their
       proportions at every viewport; the OFFSETS are hardcoded per sticker in
       the markup as --shadow-x/--shadow-y, each pointing a little away from the
       ring's centre so the group reads as lit from the middle.

       Held in a variable because the drag state swaps it wholesale, and the HDR
       grade below has to prepend to whichever one is in play. */
    filter: var(--sticker-shadow);
    /* So the shadow grows into the lift rather than snapping to it. */
    transition: filter 0.16s ease-out;

    /* Let the art use headroom above SDR white where the display has it, so the
       stickers read as lit rather than printed. This is the only thing that
       actually unlocks that headroom; the grade below is kept inside a query
       that matches an HDR screen, since applied unconditionally it would just
       wash the art out on an ordinary one. */
    dynamic-range-limit: no-limit;
}

/* Off the page and into the reader's hand: further, softer and darker, which is
   the whole of what makes it read as picked up. */


@media (dynamic-range: high) {
    /* Grade first, shadows after: filters compose in order, and brightening
       after them would lift the shadows too and wash out the contact edge. */
    .hero-sticker__art {
        filter: brightness(1.04) saturate(1.03) var(--sticker-shadow);
    }
}

/* Hover wiggle — desktop only, since it answers a pointer that can hover.
 *
 * On the `rotate` property, not inside `transform`: the two compose, so this
 * can be a plain literal keyframe while --tilt keeps its own static rotation in
 * transform below. That matters — a keyframe containing var() cannot be
 * composited and has to be re-resolved on the main thread every frame, which is
 * exactly what the old idle float was doing to six elements at once. This runs
 * on one element, only while it is under the cursor. */
@keyframes sticker-wiggle {
    0%,
    100% {
        rotate: -2.5deg;
    }

    50% {
        rotate: 2.5deg;
    }
}

@media (hover: hover) and (pointer: fine) {
    .hero-sticker__hit:hover .hero-sticker__art,
    .hero-sticker__note:hover {
        animation: sticker-wiggle 1.4s ease-in-out infinite;
    }

    /* Held still while it is being carried — the wiggle is an invitation to
       pick it up, not something to fight the drag. */
    .hero-sticker.is-dragging .hero-sticker__art,
    .hero-sticker.is-dragging .hero-sticker__note {
        animation: none;
    }
}

/* The welcome note: a sticker made of type rather than artwork.
 *
 * height: auto because the rest of the ring is sized by height and lets the
 * image shrink-wrap the width — a block of text is the other way round, so this
 * one takes a width and lets the words decide how tall it ends up.
 *
 * Everything else it inherits: the ring places it, the burst throws it, the
 * drag moves it, and --tilt sits it slightly askew like the others. */
.hero-sticker--note {
    height: auto;
}

/* Compound selector on purpose: this element carries .hero-sticker__hit too, and
   that rule zeroes background, padding and height for the image stickers. At
   equal specificity it would win on order, and the note would come out as bare
   text on no paper. */
.hero-sticker--note .hero-sticker__note {
    /* Roughly square, and sized in em — off its OWN text rather than off the
       ring's sticker size.
     *
       The height falls out of how deep the words wrap, so the width is the only
       lever and it is solved for rather than chosen. Doing that in em is what
       keeps it square at both sizes: the font is capped on desktop (below), so
       a width tied to --sticker-size would keep growing while the text stopped,
       and the note would stretch into a banner. In em the two move together.

       12em is that solution for this many words. Change the copy and it will
       want re-solving. */
    box-sizing: border-box;
    width: 12em;
    height: auto;
    padding: 1.1em;

    /* The same tilt and the same shadow the artwork gets — this is a sticker,
       it should sit on the page like one. Both live on .hero-sticker__art for
       the image ones; the note has no image, so it wears them itself. */
    transform: rotate(var(--tilt, 0deg));
    filter: var(--sticker-shadow);
    transition: filter 0.16s ease-out;

    /* Paper, not a card: square corners, a warmer yellow at the bottom where a
       real note curls away from the wall, and no radius at all. */
    background: linear-gradient(170deg, #FFF3A8 0%, #FDE96B 100%);
    color: #4A4023;

    font-family: var(--font-body);
    /* Scaled off the ring's own size, but capped: at the desktop sticker size
       a straight proportion runs to 22px, which reads as a paragraph rather
       than a note scribbled on paper. */
    font-size: min(calc(var(--sticker-size) * 0.115), 15px);
    font-weight: 500;
    line-height: 1.35;
    text-align: left;
    text-wrap: pretty;
}

.hero-sticker--note .hero-sticker__note p {
    margin: 0;
}

/* The hit area. A button, so it answers hover, tap and the keyboard alike
   without three separate mechanisms — and .hero-stickers turns pointer events
   off for the layer, so this is where they come back on. Sized by the art
   inside it; everything else is stripped back to nothing. */
.hero-sticker__hit {
    display: block;
    position: relative;
    height: 100%;
    padding: 0;
    border: 0;
    background: none;
    appearance: none;
    -webkit-appearance: none;
    cursor: grab;
    pointer-events: auto;
    /* The pointer handler drives the drag, so the browser's own image-drag must
       not start instead — that is what would otherwise happen on an <img>. */
    touch-action: manipulation;
    -webkit-user-drag: none;
    user-select: none;
    /* The note is on the button as aria-label, so nothing here should be
       announced or selectable in its own right. */
    -webkit-tap-highlight-color: transparent;
}

.hero-sticker__hit:focus-visible {
    outline: var(--focus-outline);
    outline-offset: calc(var(--focus-outline-offset) * 2);
    border-radius: 4px;
}

/* The note itself. Sat under its sticker and free to be wider than it — the
   stickers are small and the notes are a sentence — so it is positioned from
   the sticker's centre line rather than its box.
 *
 * It leans away from the edge it is nearest, using the same --dx the ring is
 * built from: the shift is -50% - dx * 50%, so a sticker out at the left of the
 * ring (dx -1) resolves to 0% and the note opens rightward from its centre, one
 * at the right (dx +1) resolves to -100% and opens leftward, and anything near
 * the top or bottom stays centred. Without it the outermost notes ran off the
 * window — no media query or measurement needed, just the vector already there.
 */
.hero-sticker__tip {
    position: absolute;
    top: calc(100% + 0.6rem);
    left: 50%;
    /* Straight below and centred on the sticker, at every width.
     *
       On a phone this used to lean away from the nearest edge by up to half the
       note's width, off the ring's own --dx, on the reasoning that a centred
       note near the edge of a narrow window would fall off it. It kept the note
       on screen and cost it the one thing a note has to do, which is point at
       the sticker it belongs to: notes drifted a long way from their own
       sticker and the lean had no relation to whether there was actually a
       problem, since --dx is where the sticker was DEALT, not where it ended up
       after the solver moved it.
     *
       hero-sticker-tips.js measures the real thing instead and slides the note
       back only as far as it has to, only when it would genuinely be cut off.
       --tip-y is the direction it slides in from. */
    --tip-x: -50%;
    --tip-y: 4px;
    /* --tip-nudge slides the note back inside the window when centring it under
       its sticker would hang it off an edge; hero-sticker-tips.js measures it.
       Zero for anything with room, which is most of them. */
    --tip-nudge: 0px;
    transform: translate(var(--tip-x), var(--tip-y));
    /* The nudge rides on the independent `translate` property rather than
       inside the transform above. The two compose, so this stays a plain length
       while --tip-x keeps its percentage — folding it in as
       calc(var(--tip-x) + var(--tip-nudge)) simply did not resolve. */
    translate: var(--tip-nudge) 0;
    /* Above its neighbours. .hero-stickers lifts the whole ring while a note is
       up (see there), so between the two nothing in the hero out-stacks it. */
    z-index: 10;

    /* As wide as its text on one line, capped so a long note wraps rather than
       running across the window. 17rem is chosen off the copy: it is the width
       at which six of the ten notes fit on a single line, where the bubble is
       exactly its text. Two of those sat 2px and 20px over the old 15rem and
       wrapped for the sake of a word.

       Past the cap the box stops tracking the text — CSS sizes it before it
       knows where the lines broke, so a wrapped note is the full cap wide
       whatever shape the text ended up. hero-sticker-tips.js measures the
       wrapped lines and pulls the box in to the longest one. (text-wrap:
       balance was tried here first and makes it worse, not better: it evens
       the lines out INSIDE a box that stays at the cap, so the slack grows.) */
    width: max-content;
    max-width: min(17rem, 60vw);
    padding: 0.5rem 0.7rem;
    border-radius: 10px;
    background: #2A2A2A;
    color: #FFFFFF;
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    font-weight: 500;
    line-height: 1.35;
    text-align: center;
    text-wrap: pretty;

    opacity: 0;
    visibility: hidden;
    transition: opacity 0.16s ease-out, transform 0.16s ease-out,
        visibility 0s linear 0.16s;
}

/* A note hangs below its sticker, always — one answer for where a note appears,
   at every width. .hero-sticker--tip-up used to flip the ones around the top of
   the ring, because a note below those dropped across the mark in the middle of
   the hero; the ring has been rearranged by hand since and no longer sits in a
   tidy circle around it.

   The flip lives here instead, applied by hero-sticker-tips.js to whichever
   note would actually run off the bottom of the window at the moment it opens.
   That is a thing only measurement can know: it depends on where the sticker
   has been dragged to, how tall the note wraps, and how big the window is. */
.hero-sticker__tip.is-flipped {
    --tip-y: -4px;
    top: auto;
    bottom: calc(100% + 0.6rem);
}

/* Shown on hover, on keyboard focus, and on .is-open — which is what a tap
   sets. visibility transitions to a step at 0s on the way in so the bubble is
   there for the fade rather than after it. */
.hero-sticker__hit:focus-visible .hero-sticker__tip,
.hero-sticker.is-open .hero-sticker__tip {
    opacity: 1;
    visibility: visible;
    transform: translate(var(--tip-x), 0);
    transition-delay: 0s;
}

/* Hover only where hovering is real. On a touchscreen :hover latches after a
   tap, which would leave a note stuck open with no way to dismiss it — taps go
   through .is-open instead. */
@media (hover: hover) and (pointer: fine) {
    /* The second selector is the welcome note's, whose tip is its SIBLING
       rather than its child — see the markup for why. */
    .hero-sticker__hit:hover .hero-sticker__tip,
    .hero-sticker__note:hover ~ .hero-sticker__tip {
        opacity: 1;
        visibility: visible;
        transform: translate(var(--tip-x), 0);
        transition-delay: 0s;
    }
}

@media (prefers-reduced-motion: reduce) {
    .hero-sticker__tip {
        transition-duration: 0.01ms;
    }
}

/* Each sticker is started at its own negative delay in the markup, so they are
   already spread across the cycle on the first frame rather than rising and
   falling as one. The rotation is written relative to --tilt so the float does
   not flatten out whatever angle the sticker was placed at. */

/**
 * Scroll cue — a circle with a down arrow, centred at the foot of the hero.
 *
 * Zero-height and sticky like the sticker layer, for the same reason, but with
 * a `top` that parks it just above the fold. Sticky only ever pushes an element
 * DOWN to its offset, which is why this sits before .hero-mark in the markup:
 * from a natural position of 0 it gets pushed to the foot of the window and
 * holds there.
 */
.hero-scroll-cue {
    position: sticky;
    top: calc(100vh - 4.25rem);
    top: calc(100svh - 4.25rem);
    height: 0;
    z-index: var(--z-content);
    pointer-events: none;
    /* Off on the raw progress, not the eased burst: this should read as
       answered the moment the page moves, and be gone inside the first few
       percent of the runway. */
    opacity: calc(1 - var(--hero-progress) * 18);
}

/* Once the cue has faded out it has to stop being a control: it is sticky at
   the foot of the window for the whole runway, so an invisible one would sit
   there swallowing clicks and holding a tab stop. visibility does both — it
   takes the button out of hit-testing and out of the tab order, and the fade
   has already finished by the time this lands. animate-am-logo.js sets the class
   in the same render pass that fades it. */
.hero-scroll-cue.is-gone {
    visibility: hidden;
}

.hero-scroll-cue__ring {
    position: absolute;
    left: 50%;
    top: 0;
    transform: translate(-50%, -50%);
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.75rem;
    height: 2.75rem;
    /* A button, so it needs the chrome taken off it and the clicks put back on:
       .hero-spacer sets pointer-events: none for the whole hero. */
    padding: 0;
    border: 0;
    appearance: none;
    cursor: pointer;
    pointer-events: auto;
    border-radius: 50%;
    /* The typing bubble's own grey — .hero-greeting__blob path fills with the
       same token — so the two pieces of hero chrome read as one material. No
       ring and no shadow with it: on a white panel this grey IS the edge, and a
       hairline around it only made the disc look cut out of the page.

       A token rather than a literal, unlike the white plate behind the mark:
       --black-5 is one of the three redeclared on .hero-spacer to keep the hero
       light in every scheme, so it stays the same grey when the page below has
       gone dark. --white is NOT redeclared, which is why the plate has to spell
       its colour out. */
    background: var(--black-5);
    color: var(--hero-ink);
    animation: scroll-cue-bob 2.2s ease-in-out infinite;
}

.hero-scroll-cue__ring svg {
    width: 1.15rem;
    height: 1.15rem;
    transition: transform var(--transition-fast);
}

/* Hover and press land on the fill and the arrow, not on the ring's own
   transform — that one is carrying both the centring and the bob, and a second
   transform on the same box would simply replace them. */
.hero-scroll-cue__ring:hover {
    background: var(--black-10);
}

.hero-scroll-cue__ring:hover svg {
    transform: translateY(2px);
}

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

/* A small, slow drop and return — the gesture it is asking for, at the size of
   a hint. */
@keyframes scroll-cue-bob {
    0%,
    100% {
        transform: translate(-50%, -50%);
    }

    50% {
        transform: translate(-50%, calc(-50% + 5px));
    }
}

/* The radial throw itself is scroll-linked, like the rest of the hero, and is
   left alone: it only ever moves when the reader does. What goes is the motion
   that runs on its own clock with the page sitting still. */
@media (prefers-reduced-motion: reduce) {

    .hero-sticker__art,
    .hero-scroll-cue__ring {
        animation: none;
    }
}

.square-button {
    font-family: var(--font-body);
    padding: 0;
    transition: padding var(--transition-fast);
    z-index: var(--z-button);
}

.square-button::before {
    content: '\00a0[';
}

.square-button::after {
    content: ']\00a0';
}

.square-button:hover::before {
    content: '[\00a0';
}

.square-button:hover::after {
    content: '\00a0]';
}


/* ===== Asterisk controls (unlocked with the * key) ===== */

.controls-panel {
    position: fixed;
    bottom: var(--spacing-lg);
    left: var(--spacing-lg);
    z-index: var(--z-controls);
    background: var(--background-color);
    border: 1px solid var(--black-10);
    border-radius: var(--border-radius-md);
    padding: var(--spacing-lg);
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    box-shadow: 0 4px 24px var(--header-shadow);
}

.controls-panel:not(.display-none) {
    display: block;
}

.controls-panel p {
    margin: 0 0 var(--spacing-md) 0;
}

.control-group {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-sm);
    margin-bottom: var(--spacing-md);
}

.control-group label {
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: var(--font-size-sm);
}

.control-group input[type="range"] {
    width: 200px;
}


/* ===== Page body below the hero ===== */

.container {
    position: relative;
    display: flex;
    flex-direction: column;
    /* Opaque again, and it has to be: the footer is fixed behind the page, so
       anything see-through would let it show through mid-scroll rather than
       only at the end. Safe now — .hero-mark sits a z-index above this, so an
       opaque container no longer clips the plate's ring on the way past.

       The same crossfade body uses, so the two do not seam apart while the
       page is changing scheme. */
    background-color: color-mix(in srgb,
            var(--background-color) calc(var(--scheme-t) * 100%),
            #FFFFFF);
    padding-top: var(--spacing-4xl);
    /* The same end-of-page breathing room .page-main gets. Home has no
       .page-shell, so this element is the one that runs out. */
    padding-bottom: var(--spacing-5xl);
    z-index: var(--z-content);
    gap: var(--spacing-4xl);
}

.section-title {
    text-align: left;
    font-family: var(--font-body);
    text-transform: uppercase;
    font-size: var(--font-size-xl);
    width: var(--width-desktop);
    max-width: var(--max-width);
    margin: 0 auto;
}

/* One child since the "About" label was dropped, so this centres rather than
   splitting label and copy to opposite edges. */
.about-section {
    display: flex;
    /* A column now, because the name pill sits above the thread as its own
       child. align-items centres the pill on the section — and the section is
       centred on the screen, which is where the mark above it is. */
    flex-direction: column;
    align-items: center;
    text-align: center;
    width: var(--width-home);
    max-width: var(--max-width-home);
    margin: 0 auto;
    /* Tighter at the top than the bottom: the g mark settles directly above the
       thread and the name pill belongs to it, so the two want to read as one
       lockup rather than as two sections that happen to meet. */
    padding: var(--spacing-2xl) 0 var(--spacing-6xl) 0;
}

/* ===== The about thread =====
 *
 * The about copy as an iMessage thread, picking up the hero: that lockup is a
 * typing indicator, so these are the messages it was typing and they carry the
 * same colours — grey on light, iMessage blue on dark.
 *
 * --chat-bubble-bg is the one knob both the bubble and its tail read, and the
 * tail's outer cut has to be painted in the colour BEHIND the bubble, which is
 * .container's --background-color.
 */
.chat {
    /* Solid, not --black-5: the tail is built from two boxes that overlap the
       bubble, and at 5% alpha every overlap stacks into a visibly darker patch.
       This is that same grey resolved against the page. */
    --chat-bubble-bg: #F2F2F2;
    --chat-bubble-fg: var(--text-reg);
    --chat-tail-size: 18px;
    --chat-bubble-radius: 20px;

    /* Narrower than the section so the bubbles wrap after a line or two, the way
       a real thread reads — a full-width bubble stops looking like a message.
       See --width-thread: the experience and work sections line up with this. */
    max-width: var(--width-thread);
    /* Not `margin: 0 auto`. The gutter below is padding, so this box is the
       column PLUS a gutter — auto-centring centres the padding along with it
       and lands the bubbles half a gutter right of the cards underneath, which
       is visible the moment the two share a width. Placed by its content edge
       instead: the column's own left, then back out by the whole gutter. */
    margin-left: calc(50% - var(--width-thread) / 2 - var(--chat-tail-gutter));
    margin-right: auto;
    /* Room for the tail, which hangs 1.45 tails left of the bubble's own box and
       would otherwise sit outside the column. Named, because the sender's name
       above has to cancel exactly this much to centre on the column — and the
       mobile rules set it to zero. */
    --chat-tail-gutter: calc(var(--chat-tail-size) * 1.45);
    padding-left: var(--chat-tail-gutter);
    text-align: left;
}

.chat__stack {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    /* Tight, as within one sender's run. */
    gap: 3px;
}

.chat__bubble {
    position: relative;
    margin: 0;
    padding: 10px 16px;
    border-radius: var(--chat-bubble-radius);
    background: var(--chat-bubble-bg);
    color: var(--chat-bubble-fg);
    font-family: var(--font-body);
    /* The home page's body size, shared with the experience rows and the work
       cards — see the scale note above --font-size-sm. */
    font-size: var(--font-size-xl);
    line-height: 1.4;
    /* Cannot exceed the column, and never a lone dangling word. */
    max-width: 100%;
    text-wrap: pretty;
}

/* The tail, the standard two-piece construction:
 *
 *   ::before  a rounded square of bubble colour, bulging past the bottom-left
 *             corner — the nub.
 *   ::after   a box of PAGE colour laid over the nub's outer part, painted on
 *             top. Its own bottom-right corner is rounded away, and that missing
 *             corner is what survives as the tail's concave curl.
 *
 * The mask's right edge has to land exactly on the bubble's left edge (left ==
 * -width): stop it short and it eats a white notch out of the gap between nub
 * and bubble, overshoot and it bites into the bubble itself.
 */
.chat__bubble--tail::before,
.chat__bubble--tail::after {
    content: '';
    position: absolute;
    bottom: 0;
    /* Taller than the bubble's corner, not just taller than the tail.
     *
       At one tail-size these sat entirely INSIDE the 20px corner curve — the
       whole nub lived in the stretch where the bubble's left edge has already
       turned inward, so the hook grew out of a curve instead of out of the
       straight edge above it, and read as stuck on rather than part of the
       shape. Starting them above where the arc begins is what lets the nub
       meet the flat edge. The mask covers the extra height, so nothing of it
       shows except the crescent at the bottom. */
    height: calc(var(--chat-bubble-radius) + var(--chat-tail-size) * 0.5);
}

.chat__bubble--tail::before {
    left: calc(var(--chat-tail-size) * -0.4);
    width: var(--chat-tail-size);
    background: var(--chat-bubble-bg);
    border-bottom-right-radius: calc(var(--chat-tail-size) * 0.85);
    /* Behind the words. A negative z-index puts these above the bubble's own
       background but below its inline content, which is exactly the layer this
       construction wants: it was z-index 0, which makes a stacking context that
       paints OVER the text, and the moment the nub grew tall enough to reach
       the corner it started cutting into the last line. */
    z-index: -2;
}

.chat__bubble--tail::after {
    /* The right edge lands exactly on the bubble's own left edge.
     *
       It used to stop a pixel short, from when both of these painted ABOVE the
       bubble: back then the pixel hid a hairline of page colour between the nub
       and the fill. They sit under the fill now, so that hairline cannot
       happen — and what the pixel did instead was leave a sliver of the nub
       showing outside the bubble for the whole height of the tail, a thin blue
       line running up the left edge that read as the balloon being out of
       alignment with itself. It got worse when the tail grew taller. */
    left: calc(var(--chat-tail-size) * -1.45);
    width: calc(var(--chat-tail-size) * 1.45);
    background: var(--background-color);
    border-bottom-right-radius: calc(var(--chat-tail-size) * 0.55);
    /* Over the nub — the whole construction depends on this one painting last
       of the two. Stated rather than left to source order, and negative for the
       same reason as the nub: both belong under the words. This one stops 1px
       outside the bubble, so it never paints page colour over the fill. */
    z-index: -1;
}

/* The sender's name heading the thread. Centred on the column rather than
   left-aligned with the bubbles, so it lines up under the mark that settles
   above it — the same way a message thread names who is talking. */
.chat__sender {
    /* A pill that hugs the name, centred over the thread — the capsule iMessage
       puts the sender's name in under their avatar. width: max-content is what
       makes the background hug the text rather than span the column. */
    width: max-content;
    max-width: 100%;
    /* Pulled up under the plate so the mark overlaps the top of the pill, the
       way an avatar sits over its name label. The plate's bottom lands a fixed
       34px above this once the mark unpins — measured, and constant from there
       on, because the two scroll together after that — so this is that distance
       plus the overlap. The extra vertical padding is what keeps the name clear
       of the plate covering its top edge. */
    margin: -40px auto var(--spacing-2xl) auto;

    padding: 0.6em 1.1em;

    /* The same dark glass the experience cards wear — see --pane-fill in
       :root. It had a treatment of its own here, built when this was the only
       glass on the page: a light fill under a 145deg gradient that ran from
       near-white to black, over a solid ring, with an inset catch-light on top.
       The ring was needed because that gradient passed through a fully
       transparent stop and the border simply disappeared at the bottom-right;
       the shared edge never reaches zero, so it needs no ring under it. */
    border: 1px solid transparent;
    border-radius: 999px;
    background: var(--pane-fill) padding-box, var(--pane-ring) border-box;
    color: var(--text-reg);

    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    font-weight: 600;
    letter-spacing: var(--letter-spacing-regular);
    line-height: 1.3;
    text-align: center;
}

/* The send animation, scrubbed by scroll rather than transitioned.
   animate-about-chat.js gives each bubble a --send that runs 0 to 1 across its
   own band of scroll distance, and everything here is derived from it — so the
   reader scrolls the thread in, and scrolling back up unsends it message by
   message. Without JS nothing is armed, --send never arrives and the bubbles
   are simply here.

   --send is monotonic, never past 1: an overshoot on a value the reader is
   scrubbing wobbles both ways, so a bubble parked near the end of its band
   would bounce every time the page nudged.

   The iOS send: the bubble scales up from its own bottom edge — growing out of
   the line it arrives on — rather than sliding in.

   No transition on these. The scalar already moves with the page, and a
   transition on top would fight it on every frame and lag the scroll. */
.chat.is-armed .chat__bubble {
    /* Solid almost at once, so the bubble is opaque for the whole of its growth
       — the scale is the animation; the opacity is only there to stop it
       appearing from nothing. Values over 1 are clamped for opacity, so this
       needs no min() of its own.
     *
     * The multiplier was 3, which left the bubble part-faded until it was a
       third of the way up. Small AND translucent is invisible against white but
       reads as a grey ghost against a dark page, so it only ever looked like a
       dark-mode bug. At 10 the fade is done inside the first few pixels of the
       band and the bubble is never seen half-there. */
    opacity: calc(var(--send, 1) * 10);
    transform: translateY(calc((1 - var(--send, 1)) * 28px)) scale(calc(0.55 + 0.45 * var(--send, 1)));
    /* Bottom LEFT, not bottom centre: the bubbles are left-hung and their tail
       is on that corner, so growing out of it reads as the message coming from
       the thread rather than inflating in place. */
    transform-origin: 0 100%;
    /* Promoted to its own layer, because --send is republished every scroll
       frame and the scale is fractional. Unpromoted, the bubble is repainted
       into the page's own tiles each frame and the invalidation rect is
       computed from a sub-pixel box: mobile Safari rounds it inward and leaves
       a sliver of stale blue off the right edge, which only clears when that
       tile is next re-rasterised. The right edge alone because transform-origin
       pins the left and bottom — nothing moves there to be left behind. */
    will-change: transform, opacity;
}

@media (prefers-reduced-motion: reduce) {

    .chat.is-armed .chat__bubble {
        opacity: 1;
        transform: none;
    }
}

/* Dark mode: the same swap the hero bubble makes. Flat blue rather than the
   hero's gradient — that one is graded across a single large blob, and repeating
   it per bubble would band the stack. */
/* Blue bubbles, always: the thread sits on the dark page whatever the system
   is set to. Flat rather than the hero bubble's gradient — that one is graded
   across a single large blob, and repeating it per bubble would band the
   stack. */
.chat {
    --chat-bubble-bg: #0A84FF;
    --chat-bubble-fg: #FFFFFF;
}/* ===== Work grid ===== */

.work-section {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-xl);
    /* Room under the last card before the page ends and the footer is revealed
       behind it — without it the grid runs straight into the fold. */
    padding-bottom: var(--spacing-6xl);
}/**
 * Work cards — the design imported from publicknowledge.co (its _includes/work.html
 * and the .project rules in its assets/css/style.css), retitled onto this site's
 * tokens. Cards are full-bleed: a wide thumbnail across the top, then title,
 * subtitle and tags beneath, two up (three past 1600px).
 */
/* One column, at the thread's width. It was a two-up grid widening to three on
   a large screen, from when the section was a gallery in a wide page — with the
   page now reading as a single phone-width column, a grid beside it was the one
   thing that broke the shape. */
.project-container {
    width: 100%;
    max-width: var(--width-thread);
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--spacing-2xl);
    margin: 0 auto;
}

/* The whole card is one link. It used to be an inert div with a single anchor
   inside it, which meant the only thing worth clicking was a pill the size of a
   thumbnail's corner. */
.project {
    display: block;
    /* Vertical only. The card has no background of its own, so side padding did
       not read as inset — it just pushed the thumbnail and title in off the grid
       column and out of line with everything else on the page. */
    padding: var(--spacing-md) 0;
    position: relative;
    color: var(--text-reg);
    text-decoration: none;
}

.project:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
    border-radius: var(--border-radius-thumbnail);
}

/* The frame the artwork is seen through: it holds the badge in place, and it is
   what crops the taller image sliding behind it. The corner lives here and
   ONLY here — this is the edge doing the cutting, and a radius on the image
   too would show its own rounded corners travelling inside the frame's.

   Without it the badge would resolve to .project and sit against the card's
   whole height, title and all. */
.project-media {
    position: relative;
    display: block;
    overflow: hidden;
    border-radius: var(--border-radius-thumbnail);
}

/* Over the artwork's top right corner. Rides on the .pill-button treatment, so
   it is the same dark glass as everything else, and it is a span rather than an
   anchor — the card around it is already the link.

   The arrow alone, so it is square: a pill that has lost its label is just a
   circle with too much padding. */
/* Compound on purpose: this element also carries .pill-button, and that rule
   sits later in the file — at equal specificity it would win on order and the
   badge would keep the pill's 999px corner and its label-sized padding. */
.project .project-live {
    position: absolute;
    top: var(--spacing-md);
    right: var(--spacing-md);
    width: 2.5rem;
    height: 2.5rem;
    padding: 0;
    justify-content: center;

    /* Concentric with the thumbnail behind it: inset by --spacing-md, so its
       corner has to be exactly that much tighter than the thumbnail's to run
       parallel to it — the same arithmetic the experience cards' logo plates
       use. Overrides the pill's 999px, which would have drawn a circle taking
       no notice of the corner it sits in.

       --card-pad would say this better but is declared on .resume, so it does
       not reach here; the inset above is the same token that one resolves to. */
    border-radius: calc(var(--border-radius-thumbnail) - var(--spacing-md));

    font-size: var(--font-size-xl);
    /* The card is the click target, so this must not eat the press or steal the
       cursor on its way through. */
    pointer-events: none;
}

/* The lift belongs to the whole card now, and the badge goes along for the ride
   — it cannot answer a hover of its own, having no pointer events. */
.project:hover .project-live {
    background: var(--pane-fill-hover) padding-box, var(--pane-ring) border-box;
    transform: translateY(-2px);
}

/* The site's button: a fully rounded grey pill. Used by the work cards' live-site
   shortcut and by the contact CTA. */
/* Used on both <a> and <button>. The button form needs the UA styling taken off
   explicitly — left alone it renders a 2px outset bevel outside the pill's own
   radius and a platform font, so the two forms of the same component did not
   match. `border: 0` is not enough on its own; without appearance:none the
   native look survives it. */
.pill-button {
    display: inline-flex;
    align-items: center;
    gap: var(--spacing-xs);
    padding: var(--spacing-sm) var(--spacing-lg);
    appearance: none;
    -webkit-appearance: none;
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    font-weight: 600;
    line-height: var(--line-height-base);
    text-align: center;
    /* Half the height comfortably exceeds the pill, so the ends stay circular
       whatever the label length. */
    border-radius: 999px;

    /* The same dark glass as the experience cards — see --pane-fill in :root.
       The transparent border is not decoration: it is what the ring layer is
       painted into. It replaces `border: 0`, which was here to kill the UA's
       outset bevel on the <button> form; `appearance: none` above is what
       actually does that, so nothing comes back. */
    border: 1px solid transparent;
    background: var(--pane-fill) padding-box, var(--pane-ring) border-box;
    color: var(--text-reg);
    cursor: pointer;
    /* Not background-color: the fill is two gradient layers now, and gradients
       do not interpolate. The hover swaps them outright and the movement is
       what carries it. */
    transition: color var(--transition-normal), transform var(--transition-normal);
}

/* The native focus ring goes with the native styling, so put one back — keyboard
   users lose the button entirely otherwise. */
.pill-button:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
}

.pill-button:hover {
    background: var(--pane-fill-hover) padding-box, var(--pane-ring) border-box;
    color: var(--text-reg);
    transform: translateY(-2px);
}

/* The frame is what has a height; the image inside is deliberately taller than
   it by --parallax-overflow and slides within it as the card crosses the
   viewport. Radius lives on the frame alone (.project-media clips), never on
   the image — a radius on a child that is being translated shows its own
   rounded corners drifting inside the frame's.

   --parallax is published per card by animate-parallax.js: -1 when the card is
   entering at the bottom of the window, +1 when it is leaving at the top. The
   :root default of 0 is the centred crop, so this resolves correctly on first
   paint and with JS off. */
/* The frame is deliberately WIDER than the artwork's own ratio, which is what
   makes it shorter than the image at full width — that difference is the
   parallax travel, and without it there is nothing to slide. The thumbnails are
   all 1500x960 (1.56); at 16/9 the frame keeps their full width and crops a
   band off the height.

   Capped, so the frame cannot outgrow the old fixed height on a wide column. */
.project-media {
    aspect-ratio: var(--project-media-aspect);
    max-height: var(--project-thumbnail-max-height);

    /* Both scalars live on the FRAME and are inherited by the image, never
       declared on the image itself: the scroll-driven animation below runs on
       this element, and a local --parallax on the image would shadow the value
       coming down from it — the animation would run and nothing would move.
       0 is the centred crop, so the resting state is right with no JS and no
       animation support. */
    --parallax: 0;
    --parallax-overflow: 0px;
}

.project-thumbnail {
    /* Full width at natural proportions, so the clipping is only ever vertical.
       object-fit: cover would scale the image to fill a box taller than itself
       and crop the sides, which is what we do not want here. */
    width: 100%;
    height: auto;
    display: block;
    background-color: var(--black-5);

    /* The image is top-aligned in flow, so its whole overflow hangs off the
       bottom and the travel is 0 (top flush) to -overflow (bottom flush) —
       never positive, which would drop the image's top edge into the frame and
       show a sliver of background behind it. Mapping [-1, +1] onto that:

           --parallax = +1  ->  0            (top flush)
           --parallax =  0  ->  -overflow/2  (centred, the JS-off default)
           --parallax = -1  ->  -overflow    (bottom flush)

       translate, not `top`/`margin`: this is a composited property, so the
       browser moves an already-painted layer instead of relaying out the card
       on every frame. */
    translate: 0 calc((var(--parallax) - 1) * (var(--parallax-overflow) / 2));
}

/* The parallax is the effect; with reduced motion the frame just holds a still,
   centred crop. The scalar is ignored rather than the script being stopped, so
   there is nothing to re-enable if the preference changes. */
@media (prefers-reduced-motion: reduce) {
    .project-thumbnail {
        translate: 0 calc(var(--parallax-overflow) / -2);
    }
}

/* Where the browser has scroll-driven animations, the whole effect runs off the
   main thread: no scroll listener, no per-frame JS, nothing to jank. It drives
   exactly the same --parallax the script would, so the geometry above is the
   single definition of the movement either way, and animate-parallax.js checks
   for this support and skips its listener so only one driver is ever live.
 *
 * The animation goes on the FRAME, not on the image. `view()` resolves against
 * the subject's nearest scroll container, and .project-media is one — it is
 * `overflow: hidden` — so an animation on the image inside it measures that
 * clipped box, where the image never moves, and sits frozen at one progress
 * value. The frame itself has the viewport as its scroller, which is what the
 * effect is supposed to track.
 *
 * --parallax has to be registered for this: an unregistered custom property is
 * a token stream, animatable only by discrete swap, and the image would jump
 * between the two endpoints instead of sweeping. Registered as a number, it
 * interpolates, and `inherits: true` is what carries it down to the image. */
@supports (animation-timeline: view()) {
    @property --parallax {
        syntax: '<number>';
        initial-value: 0;
        inherits: true;
    }

    @media (prefers-reduced-motion: no-preference) {
        .project-media {
            animation-name: project-parallax;
            animation-timing-function: linear;
            animation-fill-mode: both;
            /* Longhands, NOT the `animation` shorthand: the shorthand resets
               duration to its initial 0s, and a timeline-driven animation with
               a zero duration never progresses — it sits on its first keyframe.
               `auto` is what hands pacing to the timeline. */
            animation-duration: auto;
            animation-timeline: view();
        }

        @keyframes project-parallax {
            from {
                --parallax: -1;
            }

            to {
                --parallax: 1;
            }
        }
    }
}

/* The title now carries weight and a step of size, where it used to match its
   description exactly and be told apart by colour alone — the rule the rest of
   this page still follows. A card is a different case: it is a list of things
   being scanned for a name, so the name should be findable without reading the
   sentence under it. */
.project-title {
    margin-top: var(--spacing-md);
    font-family: var(--font-body);
    font-size: var(--font-size-2xl);
    font-weight: 600;
    letter-spacing: var(--letter-spacing-tight);
}

.project-description {
    font-family: var(--font-body);
    font-size: var(--font-size-xl);
    /* Further back than --text-secondary, which against this black is still
       bright enough to read as a second title rather than as the note under
       one. Mixed toward the page rather than set with opacity, so it stays one
       flat colour instead of letting the background through the letterforms. */
    color: color-mix(in srgb, var(--text-secondary) 68%, var(--background-color));
    padding: var(--spacing-xs) 0 var(--spacing-md) 0;
}.tag {
    text-transform: uppercase;
    font-size: var(--font-size-sm);
    padding: var(--spacing-xs) var(--spacing-md);
    display: inline-block;
    background-color: var(--black-5);
    font-family: var(--font-body);
    border-radius: var(--border-radius-md);
    color: var(--text-reg);
    border: 1px solid rgba(128, 128, 128, 0);
    transition: var(--transition-normal);
}

button.tag {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    cursor: pointer;
    line-height: inherit;
}

/* ===== Search, in the header bar =====

   Desktop is the plain case: the field sits at the right of the bar, opposite
   the back button, with the mark centred between them. The mobile rules further
   down collapse it to a circle and expand it across the whole bar. */

/* Opposite the back button, where the search field used to sit. */
.theme-toggle {
    position: absolute;
    right: var(--header-gutter);
    top: 50%;
    transform: translateY(-50%);
}

/* A row of its own under the bar, in the site's one column — same max-width and
   same gutter as .measure, the listing and the header above it. Positioned so
   the results panel has something to hang off. */
/* --search-gutter is the field's inset from this element's edge, and the panel
   below reads the same value so the two always line up. Written as a token
   rather than repeated, because home zeroes it: there the field is already the
   full width of its own box, and a panel insetting itself by another 24px came
   out visibly narrower than the field it hangs under. */
.search {
    --search-gutter: var(--spacing-xl);
    position: relative;
    width: 100%;
    max-width: var(--measure);
    margin: 0 auto var(--spacing-2xl) auto;
    padding: 0 var(--search-gutter);
    box-sizing: border-box;
}

/* The icon is a font glyph, not an SVG, so it is sized by font-size rather than
   by the shared svg rule. 55% of the circle, to match the drawn icons either
   side of it. */
.theme-toggle__icon {
    font-size: calc(var(--logo-mark-size) * 0.55);
    line-height: 1;
}

/* An inert box is worse than none: the tag index and the full listing both work
   without a line of JavaScript, and the header still has its mark. */
.no-js .search {
    display: none;
}

/* The magnifier sits inside the field rather than beside it, so the row is one
   object. Positioned rather than laid out, because the input is what fills the
   row and a flex sibling would take width from it. */
.search__field {
    position: relative;
    display: flex;
    align-items: center;
}

.search__icon {
    position: absolute;
    left: var(--spacing-lg);
    font-size: var(--font-size-xl);
    line-height: 1;
    color: var(--text-secondary);
    pointer-events: none;
}

/* The visible label is the placeholder; this is for anyone who cannot see it. */
.search__label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.search__input {
    box-sizing: border-box;
    width: 100%;
    height: var(--logo-mark-size);
    /* Left padding clears the magnifier. */
    padding: 0 var(--spacing-lg) 0 calc(var(--spacing-lg) + var(--font-size-xl) + var(--spacing-sm));
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    color: var(--text-reg);
    background-color: var(--black-5);
    border: 1px solid rgba(128, 128, 128, 0);
    /* A pill, to sit with the round back button across the bar from it. */
    border-radius: calc(var(--logo-mark-size) / 2);
    transition: var(--transition-normal);
    /* Safari draws its own rounded, inset box for type=search and ignores most
       of the above until this is off. */
    -webkit-appearance: none;
    appearance: none;
}

.search__input::placeholder {
    color: var(--text-secondary);
}

/* Neutral on focus, not the accent. The accent is the site's one loud colour
   and it is used to mark things worth noticing — a search box that lights up
   red merely because the caret is in it reads as an error, and this is the one
   control a reader is in most of the time. A tonal ring is still a perfectly
   visible focus indicator; both states are named so the plain :focus a click
   gives does not fall through to the accent rule. */
.search__input:focus,
.search__input:focus-visible {
    outline: none;
    background-color: var(--background-color);
    border-color: var(--black-20);
    box-shadow: 0 0 0 3px var(--black-5);
}

/* The results hang off the bar rather than pushing the page down — this sits at
   the top of every page, and a list that shoved the article downward on every
   keystroke would be unusable. */
.search__panel {
    position: absolute;
    top: calc(100% + var(--spacing-sm));
    /* Inset to match the field, whatever that inset happens to be. */
    left: var(--search-gutter);
    right: var(--search-gutter);
    max-height: min(70vh, 560px);
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: var(--spacing-md);
    box-sizing: border-box;
    /* Its own fill and blur rather than the header's --header-background and
       --backdrop-blur. That pair is tuned for a bar sitting over a reading
       column, where there is little behind it and 40% glass reads as a pane;
       this panel is a block of text that can come up over anything — the
       sticker field on the home page especially — and at that opacity the
       artwork behind it competed with the result titles. Mostly opaque, with
       the blur eased off to match: the heavier the fill, the less there is for
       a strong blur to do, and 20px of it over a nearly solid ground is spent
       on an effect nobody can see. */
    background: var(--search-panel-bg);
    border: 0.5px solid var(--black-10);
    border-radius: var(--border-radius-md);
    backdrop-filter: var(--search-panel-blur);
    -webkit-backdrop-filter: var(--search-panel-blur);
    box-shadow: 0 8px 32px var(--header-shadow);
    z-index: var(--z-nav);
}

.search__panel[hidden] {
    display: none;
}

/* The match count, on the field rather than above the results. Absolute like
   the magnifying glass at the other end, so it does not take part in the
   field's flex row and the input keeps the full width — the input's right
   padding is what actually holds the text off it. */
.search__count {
    position: absolute;
    right: var(--spacing-md);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    /* min-width, not width: "12" and "99+" are wider than a single digit, and a
       fixed circle would either clip them or be sized for the widest case all
       the time. Padding opens it into a stadium as the number grows. */
    min-width: 24px;
    height: 24px;
    padding: 0 7px;
    border-radius: 999px;
    background-color: var(--black-10);
    color: var(--text-reg);
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    font-variant-numeric: tabular-nums;
    line-height: 1;
    pointer-events: none;
}

.search__count[hidden] {
    display: none;
}

/* The resting state: the key that focuses the field, not a number. Lighter than
   a count — it is an offer, and the counts it alternates with are the answer to
   something the reader asked for. */
.search__count--hint {
    background-color: var(--black-5);
    color: var(--text-secondary);
    font-size: var(--font-size-sm);
    text-transform: lowercase;
    letter-spacing: var(--letter-spacing-wide);
}

/* No Tab key to press on a touch device, and nothing there to hover a hint out
   of either — so the disc simply does not appear until it has a count to give.
   `hover: none` rather than a width breakpoint: a small window on a laptop
   still has the keyboard the hint is about. */
@media (hover: none) {
    .search__count--hint {
        display: none;
    }
}

/* The disc travels with the input's right edge. In the mode the close button
   takes the end of the row and the input gives up that much width, so an inset
   measured from the field alone would leave the count sitting on top of the
   button. Transitioned on the same curve as the button and the field's gap, so
   the three move as one thing. */
.search__count {
    transition: right var(--search-immersive-ms) var(--search-immersive-ease);
}

.is-immersive .search__count {
    right: calc(var(--logo-mark-size) + var(--spacing-sm) + var(--spacing-md));
}

@media (prefers-reduced-motion: reduce) {
    .search__count {
        transition: none;
    }
}

/* Room for it, so a long query does not run underneath the disc. Matched on the
   left by the icon's own inset, which the base rule already allows for. */
.search__input {
    padding-right: calc(var(--spacing-md) + 24px + var(--spacing-sm));
}

.search__status {
    margin: 0 0 var(--spacing-sm) 0;
    padding: 0 var(--spacing-sm);
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--text-secondary);
}

/* When the disc is carrying the number, the panel does not repeat it in words.
   Taken out of sight rather than emptied: this is the live region, and a screen
   reader should still hear "12 matches" even though nothing draws it. The other
   things it says — "No matches.", "Loading search…", a load failure — have no
   disc to move to and stay visible. */
.search__status--quiet {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.search-group+.search-group {
    margin-top: var(--spacing-md);
}

.search-group__label {
    margin: 0 0 var(--spacing-xs) 0;
    padding: 0 var(--spacing-sm);
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    text-transform: uppercase;
    letter-spacing: var(--letter-spacing-wide);
    color: var(--text-secondary);
}

.search-hit {
    display: flex;
    align-items: center;
    gap: var(--spacing-sm);
    padding: var(--spacing-sm);
    border-radius: var(--border-radius-sm);
    text-decoration: none;
    color: var(--text-reg);
    transition: background-color var(--transition-fast);
}

/* What kind of thing this is, at a glance — in the same container the header's
   own controls wear, at the same size, so the two read as one family. A rounded
   square rather than the header's circle: those are buttons and this is not,
   and the difference in corner is what says so without a second colour.

   flex: none so a long title cannot squeeze it. */
.search-hit__icon {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: var(--logo-mark-size);
    height: var(--logo-mark-size);
    border-radius: var(--border-radius-md);
    background-color: var(--black-5);
    /* The same 55% of the container the drawn icons in the header use. */
    font-size: calc(var(--logo-mark-size) * 0.55);
    line-height: 1;
    color: var(--text-secondary);
    transition: color var(--transition-fast), background-color var(--transition-fast);
}

.search-hit:hover .search-hit__icon,
.search-hit:focus-visible .search-hit__icon {
    color: var(--text-reg);
    background-color: var(--black-10);
}

.search-hit:hover,
.search-hit:focus-visible {
    background-color: var(--black-5);
}

.search-hit__body {
    display: flex;
    flex-direction: column;
    gap: 2px;
    /* A flex item will not shrink below its content by default, so without this
       the detail line never ellipsises — it just widens the row instead. */
    min-width: 0;
}

.search-hit__title {
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    color: var(--text-reg);
}

/* Unlisted: findable here, deliberately absent from every listing. A quiet
   chip rather than another icon — the row already carries one saying what kind
   of thing this is, and a second glyph would compete with it. */
.search-hit__flag {
    display: inline-block;
    vertical-align: baseline;
    padding: 1px var(--spacing-xs);
    border-radius: var(--border-radius-sm);
    background-color: var(--black-5);
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    text-transform: uppercase;
    letter-spacing: var(--letter-spacing-wide);
    color: var(--text-secondary);
}

/* Colour, not another size, is what separates the two lines — the same rule the
   listing rows and the work cards follow. */
.search-hit__detail {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--text-secondary);
    /* One line: a long subtitle should not make a result three rows tall in a
       dropdown that is trying to show three groups at once. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The tag index above the two full listings. Wraps freely — it is as long as
   the site has tags, and there is no sensible fixed count to plan for. */
.tag-index {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-sm);
    margin-bottom: var(--spacing-2xl);
}

/* The count rides inside the chip rather than beside it, so the two never wrap
   apart from each other. */
.tag-index__count {
    margin-left: var(--spacing-sm);
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}

a.tag:hover,
.tag-index .tag:hover {
    background-color: var(--background-color);
    border: 1px solid var(--black-5);
    box-shadow: 0 0 0 1px var(--black-5);
    transform: translateY(-2px);
}

.tag-index .tag.is-active {
    background-color: var(--text-reg);
    color: var(--background-color);
    border-color: transparent;
    box-shadow: none;
    transform: none;
}

.listing-item[hidden] {
    display: none;
}

.listing-empty {
    max-width: var(--measure);
    margin: 0 auto;
    padding: 0 var(--spacing-xl) var(--spacing-2xl);
    color: var(--text-secondary);
    font-family: var(--font-body);
}

.listing-meta .tag {
    cursor: pointer;
}


/* ===== Mini-resume ===== */

/* Two columns: a fixed date rail and the role beside it. Content is hardcoded
   in _includes/home/resume.html. */
.resume {
    padding: var(--spacing-6xl) 0 0 0;

    /* Declared on the section, not on the list: the heading pins off these too,
       and it is the list's sibling rather than its child.

       --stack-bottom is where the front card of the waiting pile rests, above
       the bottom edge of the window. --stack-peek is how much further down each
       card behind it sits, which is the sliver of it you can see. --stack-sink
       is how far a card is scaled down while it is still in the pile. */
    /* In px, not rem: stack-resume.js reads --stack-bottom to work out where a
       card is pinned, and parseFloat on "3.5rem" is 3.5. These are chrome
       offsets rather than type, so they have no reason to follow the root size
       anyway. */
    --stack-bottom: 56px;
    --stack-peek: 8px;
    --stack-sink: 0.86;

    /* The card's corner and the inset everything sits at inside it. The logo
       plate's own corner is derived from the pair (see .resume__plate) so the
       two curves stay concentric: a rounded box inset by N inside another one
       is only concentric if its radius is exactly N less, and eyeballing the
       inner figure is what makes a corner look slightly wrong without it being
       obvious why. Changing either of these keeps that true. */
    --card-radius: 1.375rem;
    --card-pad: var(--spacing-md);
}

.resume__list {
    width: 100%;
    max-width: var(--width-thread);
    margin: 0 auto;
}

/* Shared heading for the home page's own sections (work, experience). Sits in
   the same narrow column as the content beneath it — which is the thread's
   column, so the whole page reads down one width. */
.section-heading {
    width: 100%;
    max-width: var(--width-thread);
    margin: 0 auto var(--spacing-2xl) auto;
    font-family: var(--font-body);
    /* A title, set large and in sentence case. It was a small tracked-out
       uppercase label until the experience section was rebuilt as a stack with
       a heading that stays on screen while you read it — at label size that
       heading had nothing to hold the section together with, and once one of
       them was a title the other could not stay a label.

       In the page's full ink and set bold, with the lede under it carrying the
       knocked-back colour instead. It used to be secondary itself, from when it
       was the only thing there — which left a section opening on two greys and
       nothing to look at first. */
    font-size: var(--font-size-3xl);
    font-weight: 600;
    letter-spacing: var(--letter-spacing-tight);
}

/* A line of plain copy under a section heading, set exactly as a project's
   description is — same size, same knocked-back colour. It is the same kind of
   thing: a sentence explaining the titled thing above it. */
.section-lede {
    width: 100%;
    max-width: var(--width-thread);
    /* Tucked up under its heading. Three things sit between the two otherwise,
       and the negative margin has to cancel all of them: the heading's own
       margin-bottom, which is sized for a section that has no lede under it,
       and the flex gap .work-section puts between every child. What is left is
       the small figure added back here — a line and its heading, not two
       separate things. */
    margin: calc(var(--spacing-sm) - var(--spacing-2xl) - var(--spacing-xl)) auto var(--spacing-2xl) auto;
    font-family: var(--font-body);
    font-size: var(--font-size-xl);
    color: color-mix(in srgb, var(--text-secondary) 68%, var(--background-color));
}

.resume__list {
    list-style: none;
    /* Runway. A sticky card is released the moment its container's bottom edge
       reaches it, and the six cards together are shorter than a laptop window —
       so with the list ending at the last card, the whole thing is on screen at
       once and not one of them ever pins. */
    padding: 0;

    /* A stack, not a table. See .resume__row for the mechanic.

       The cards sit next to each other, at the spacing a list of notifications
       has. The gap was briefly a large vh figure, on the theory that it was
       scroll distance rather than white space — it is not: a card that has been
       released scrolls normally, so the distance to the next one is space you
       actually look at, and at 26vh the section read as three cards adrift in a
       column of black. The pile builds as the section comes UP into the window,
       which needs no gap at all.

       No overflow or height on this element. A sticky child pins against its
       nearest SCROLLING ancestor, so giving the list a scroll container of its
       own would trap all six inside it and nothing would pin against the page. */
    display: flex;
    flex-direction: column;
    gap: var(--spacing-xs);
}

/* Each job is a notification: app icon, then the two lines, with the time on
   the right. That is the same furniture as the thread above it, which is the
   point — the page is a phone screen, so the resume arrives the way anything
   else on a phone does.

   Laid out in source order with the date pulled to the end rather than written
   last in the markup: the date is the row's heading, and a screen reader should
   hear "Jan 2025 to Aug 2026, Founding Designer" in that order. */
.resume__row {
    display: flex;
    /* Top, not centre. The timestamp hangs off the first line the way a
       notification's does, and stays there when a long org name wraps. */
    align-items: flex-start;
    gap: var(--spacing-md);
    /* Even on all four sides, which is what lets the plate inside be concentric
       with the card — see --card-pad below. */
    padding: var(--card-pad);

    /* Not a token: notification corners are much rounder than the site's cards,
       and rounder than the logo inside them, which is what reads as iOS. */
    border-radius: var(--card-radius);

    /* Dark glass — see --pane-fill in :root. The card is DARKER than the page
       it sits on, which is the opposite of how the rest of the site lifts a
       surface: one lifted off the background reads as a panel with a light
       behind it, and glass in a dark room is darker than what is around it. The
       lit edge is what makes it a surface rather than a hole. */
    background: var(--pane-fill) padding-box, var(--pane-ring) border-box;
    border: 1px solid transparent;

    /* The pile is at the FOOT of the window, not the head of it. A card that
       has not been reached yet is held just above the bottom edge; the ones
       behind it sit a step lower each, so you see a sliver of every one that is
       still to come. Scrolling on releases the front card, it rises and reads
       normally, and the pile behind it comes up a place.

       Sticky to `bottom`, then, not to `top`: a card pins while its natural
       position is still BELOW the line, and lets go once the page has brought
       it up to it. Cards that have been read simply scroll away off the top —
       nothing collects up there. */
    position: sticky;
}

/* Each card's place in the waiting pile. --i is its index and --rows the number
   of them, both published by stack-resume.js — which is already walking the
   list to work out the depths, and is the only place that can count.

   These were six hand-written nth-child blocks until the list grew to ten. CSS
   has no counter either `bottom` or `z-index` can read, so every added job
   meant another block, and a job added without one would have quietly sat at
   the front of the pile with the wrong offset.

   The z-index runs the opposite way to document order, which is the whole
   reason it has to be set at all: left alone a later card paints over an
   earlier one and the pile comes out inside-out, with the card you are about to
   read buried behind the ones after it.

   The defaults are the no-JavaScript case — every card level with the last, so
   the section is a plain list of cards rather than a broken pile. */
.resume__row {
    /* Stepped by --depth, which is the card's place in the PILE, not --i, which
       is its place in the list. That distinction is the whole of it: only three
       or four cards are ever stacked at once, so keying the offset to the list
       index meant every card past the fourth resolved to the same offset and
       the pile lost its y stagger — ten cards, and seven of them at one
       position. Capping it differently only moved which cards coincided.

       Off --depth the stagger always describes the pile that is actually there:
       the front card rests at --stack-bottom and each one behind it a step
       lower, whichever cards those happen to be. */
    bottom: calc(var(--stack-bottom) - var(--stack-peek) * var(--depth, 0));
    z-index: calc(var(--rows, 1) - var(--i, 0));
}

/* The taper. A card waiting in the pile is smaller than one that has come up
   out of it, so the pile recedes instead of reading as six cards at the same
   size that happen to overlap. --depth is how far back in the pile a card is —
   0 for the front one and for any card that has been released — and is set by
   assets/js/stack-resume.js.

   This was first built on a scroll-driven animation, `animation-timeline:
   view()`, to keep the section free of JavaScript. It cannot work: a view
   timeline reads the element's box AS POSITIONED, and a pinned card's box is
   sitting fully on screen at the bottom edge, so its `entry` range is already
   finished the whole time it is in the pile. Every card measured at scale 1.
   What decides the taper is where a card WOULD be, which is the one thing the
   rendered box no longer says.

   `scale` rather than `transform: scale()` so it composes with a transform
   instead of replacing it, and the origin is the bottom edge so a card shrinks
   INTO the pile rather than away from it. */
.resume__row {
    transform-origin: bottom center;
    /* The 3 is MAX_DEPTH in stack-resume.js: --depth counts 1, 2, 3 going back
       through the pile and is 0 once a card is released, so a card reaches
       --stack-sink at the back of the pile and returns to full size the moment
       it comes out of it. */
    scale: calc(1 - (1 - var(--stack-sink)) * var(--depth, 0) / 3);
    /* --depth moves a whole step at a time, so without this the card jumps
       between sizes. Short enough to keep up with a fast scroll. */
    transition: scale 0.18s ease-out;
}

/* The pile still reads without the taper — the offsets and the lit edges do
   that on their own. This is the part that moves under you, so this is the part
   that goes. */
@media (prefers-reduced-motion: reduce) {
    .resume__row {
        scale: 1;
        transition: none;
    }
}

/* Logo, then the two lines of text beside it — the icon of the app doing the
   notifying. */
.resume__detail {
    display: flex;
    align-items: center;
    gap: var(--spacing-md);
    /* Takes the room the date does not, so the date sits hard against the right
       edge of every card and the rail lines up down the stack. */
    flex: 1;
    min-width: 0;
}

/* The plate the logo sits on, standing in for a notification's app icon. The
   artwork fills it edge to edge and the PLATE is what carries the corner, so
   every logo comes out the same shape whatever its own art does — which is why
   the images are square and full-bleed rather than marks floating in a box.

   overflow: hidden is what does the clipping. border-radius alone does not cut
   a child out; it only rounds the element's own background and border, and the
   image would sit square over the corners.

   The row is two lines tall and this is one square, so it must not be squeezed
   when the org name wraps. */
.resume__plate {
    display: block;
    overflow: hidden;
    flex: none;
    width: 3.25rem;
    height: 3.25rem;
    /* Concentric with the card: the plate is inset by --card-pad, so its corner
       has to be exactly that much tighter than the card's to run parallel to
       it. The extra 1px is the card's border — border-radius is measured on the
       border box, so the padding alone undershoots the real inset by its
       width. */
    border-radius: calc(var(--card-radius) - var(--card-pad) - 1px);

    /* The same lit edge as the card around it — see --pane-ring in :root. The
       fill under it is only ever seen through artwork that does not cover, so
       it is the ring that has to be right. */
    border: 1px solid transparent;
    background: var(--pane-fill) padding-box, var(--pane-ring) border-box;
}

.resume__logo {
    display: block;
    width: 100%;
    height: 100%;
    /* Cover, not contain: the art is meant to fill the plate and be cut by its
       corners. Anything that does not fill it is centred and cropped rather
       than letterboxed. */
    object-fit: cover;
}

.resume__logo--contain {
    object-fit: contain;
}

/* All three lines of a row are set identically — same family, same size, same
   weight as the messages above them. Only the colour tells them apart: the role
   in the page's ink, the date and the org secondary. */
.resume__period,
.resume__role,
.resume__org {
    font-family: var(--font-body);
    font-size: var(--font-size-xl);
}

.resume__period,
.resume__org {
    color: var(--text-secondary);
}

/* Takes the whole card beside the logo. Without this it is a flex item sized to
   its content, so the head line is only as wide as the role and the date sits
   against the role rather than out at the card's right edge — space-between has
   no space to put between them. min-width: 0 lets a long organisation name wrap
   inside it rather than forcing the card wider. */
.resume__text {
    flex: 1;
    min-width: 0;
}

/* The card's first line: the role, with the date out at the right end of it.
   The two share a line rather than the date floating at the top of the card,
   so what you read across is "this job, these dates" and the organisation gets
   the line under it to itself. */
.resume__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-md);
}

.resume__period {
    /* Knocked back further than the organisation beside it: on a dark card
       --text-secondary is still bright enough to compete with the role, and the
       date is the least of the three things a row says. */
    color: color-mix(in srgb, var(--text-secondary) 62%, var(--background-color));
    /* Reads as a timestamp rather than a column: smaller than the line it sits
       on, and never broken across two. */
    font-size: var(--font-size-md);
    white-space: nowrap;
    /* The role can wrap on a narrow card; the date must not be pushed off the
       end by it. */
    flex: none;
}


/* Sits directly above the footer. The warm fill is on the inner box, not the
   section, so it reads as a card in the content column rather than a full-bleed
   band across the page. */


/* ===== Writing list ===== */

.writing-list {
    width: var(--width-desktop);
    max-width: var(--max-width);
    margin: 0 auto;
    display: flex;
    flex-direction: column;
}

.writing-item {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--spacing-lg);
    padding: var(--spacing-md) var(--spacing-sm);
    border-bottom: 1px solid var(--black-5);
    font-size: var(--font-size-lg);
    transition: var(--transition-fast);
}

.writing-item:hover {
    background-color: var(--slightly-warm-gray);
}

.writing-item-date {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--text-secondary);
    white-space: nowrap;
}


/* ===== Footer ===== */

/* Fixed to the foot of the window, with the page scrolling OVER it — the
   content is opaque and sits a layer above, so the footer is only uncovered
   once the page has run out. body's padding-bottom is what makes that possible:
   a fixed element contributes no height, so without it there is nothing left to
   scroll and the footer never appears. --footer-height is measured by the
   script in site/footer.html, since this box is sized by its own contents. */
.footer {
    z-index: var(--z-footer-behind);
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    text-align: left;
    /* Weighted to the top: the page's rounded edge lifts away from here, and
       the gap under it is what makes that read as depth. Below the socials
       there is nothing left to separate them from.
     *
       Except a browser's own chrome. An in-app browser — Twitter's, Instagram's
       — floats a toolbar over the bottom of the page rather than reserving
       space for it, and `position: fixed; bottom: 0` anchors underneath it. The
       socials are the last thing in here and were sitting 30px off the bottom,
       which is squarely behind that bar. env() is what the browser reports for
       exactly this, and max() means the ordinary case is unaffected.
     *
       The top figure comes in on a short window for the same reason: 80px of
       padding is a fifth of an in-app browser's visible height, and it pushes
       everything below it down into the toolbar. */
    padding: clamp(var(--spacing-2xl), 12vh, var(--spacing-6xl)) 0
        max(var(--spacing-3xl), env(safe-area-inset-bottom)) 0;
    /* A flat light grey, not the old translucent glass: nothing scrolls behind
       this any more — the page slides OFF it — so there is nothing to see
       through to, and a solid ground makes the reveal read as a second surface
       under the page rather than a band of the same one. */
    background-color: var(--footer-ground);
    /* No backdrop-filter: the footer is the last band on the page and nothing
       scrolls behind it, so the blur cost a compositor layer to filter the page
       background against itself. The nav, which content does pass under, keeps
       its own. */
    border-top: 1px solid var(--glass-edge);
}

/* Centred stack: mark, line, socials. The footer is short enough now that a
   column reads better than the two it used to be. */
.footer-inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--spacing-lg);
    text-align: center;
}

.footer-mark {
    width: 2.75rem;
    height: 2.75rem;
    color: var(--text-reg);
    overflow: visible;
}

.footer-mark path {
    fill: currentColor;
    fill-rule: nonzero;
    stroke: none;
}

/* The footer mark draws itself the first time a reader reaches the foot of the
   page — the hero's reveal, once more and much smaller, as a sign-off.
 *
   The dash figures are set by the script in site/footer.html rather than here,
   because the length is measured off the path. Without JavaScript nothing is
   set, the path has no dash at all, and the mark is simply drawn — which is the
   right fallback. */
.footer-mark.is-drawing path {
    animation: footer-mark-draw 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

@keyframes footer-mark-draw {
    from {
        stroke-dashoffset: var(--mark-length);
    }

    to {
        stroke-dashoffset: 0;
    }
}

@media (prefers-reduced-motion: reduce) {
    .footer-mark.is-drawing path {
        animation: none;
        stroke-dashoffset: 0;
    }
}

.footer-line {
    margin: 0;
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    color: var(--text-secondary);
}

/* The site's navigation, and the only copy of it. Wraps rather than scrolls:
   four short words fit one line on a phone, but the footer is centred and a
   fifth would otherwise push off the edge rather than dropping to a new row. */
.footer-nav {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--spacing-lg);
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    text-transform: uppercase;
    letter-spacing: var(--letter-spacing-wide);
}

.footer-nav a {
    color: var(--text-secondary);
    text-decoration: none;
    transition: color var(--transition-normal);
}

.footer-nav a:hover {
    color: var(--text-reg);
}

.footer-nav a:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
    border-radius: var(--border-radius-sm);
}

.footer-socials {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--spacing-lg);
    font-size: var(--font-size-xl);
    line-height: 1;
}

.footer-socials a {
    display: inline-flex;
    color: var(--text-secondary);
    text-decoration: none;
    transition: color var(--transition-normal), transform var(--transition-normal);
}

.footer-socials a:hover {
    color: var(--text-reg);
    transform: translateY(-2px);
}

.footer-socials a:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
    border-radius: var(--border-radius-sm);
}


@keyframes fadeIn {
    0% {
        opacity: 0;
    }

    100% {
        opacity: 1;
    }
}

@keyframes fadeInUp {
    0% {
        opacity: 0;
        transform: translateY(20px);
    }

    100% {
        opacity: 1;
        transform: translateY(0);
    }
}

@media (prefers-reduced-motion: reduce) {.project {
        opacity: 1;
        transform: none;
    }

    * {
        transition-duration: 0.01ms !important;
    }
}


/* ===== Mobile ===== */

@media (max-width: 800px) {
    body {
        font-size: var(--font-size-sm);
        line-height: 1.5;
    }.container-width {
        width: var(--width-mobile);
    }

    /* Set on the row rather than :root so it cascades to the mark and its dot
       without a second breakpoint-scoped :root block. */
    .logo-mark-row {
        --logo-mark-size: 36px;
        margin-top: var(--spacing-2xl);
    }

    /* Search is a full-width row of its own at every width now, so it needs no
       mobile treatment beyond the smaller control height it inherits from
       --logo-mark-size. */


    .about-section {
        padding: var(--spacing-lg) 0 var(--spacing-3xl) 0;
    }

    /* Left-aligned rather than centred, and against the column's own edge — a
       thread hung off the left is what a phone actually shows. */
    .about-section {
        /* stretch, not centre: the thread has to fill the column so its bubbles
           stay hard against the left edge. The pill still centres itself, on
           its own margins. */
        align-items: stretch;
        text-align: left;
        /* The same column the rest of the page uses, so the thread's left edge
           lines up with EXPERIENCE and everything below it. --width-home is
           narrower and centred, which left the bubbles inset on their own. */
        width: var(--width-mobile);
    }

    .chat {
        --chat-tail-size: 14px;
        /* Flush to the column, tail and all: the bubbles start on the page's own
           left edge and the tail hangs out into the gutter beside it. */
        margin: 0;
        --chat-tail-gutter: 0px;
        padding-left: 0;
    }

    .chat__bubble {
        font-size: var(--font-size-lg);
        padding: 9px 14px;
        max-width: 75vw;
    }.project-container {
        width: var(--width-mobile);
        gap: var(--spacing-2xl);
        margin: 0 auto;
    }


    /* One up. The outline and the thumbnail's rounding are inherited from the
       desktop rules — no flush-to-the-edge variant, since the card is square
       cornered and the image is the rounded thing. */
    .project-container {
        width: var(--width-mobile);
        grid-template-columns: 1fr;
        gap: var(--spacing-2xl);
    }

    /* One step down from desktop, and still one size for the pair. */
    /* The lede is set as a project description and has to step down with one.
       Both are xl above the breakpoint, where xl is itself reduced — so they
       matched on a desktop and came apart on a phone, the lede at the full 24px
       against the descriptions at 18. */
    .project-description,
    .section-lede {
        font-size: var(--font-size-lg);
    }

    /* One step up from the description here too, so the pairing survives the
       drop to mobile sizes rather than collapsing back to a single size. */
    .project-title {
        font-size: var(--font-size-xl);
    }

    .resume {
        padding-top: var(--spacing-3xl);
    }

    .resume__list,
    .section-heading,
    .section-lede {
        width: var(--width-mobile);
    }

    /* The card keeps its shape on a phone — this is the arrangement a phone
       actually uses. Only the padding comes in, so the text is not left with a
       column too narrow to hold a role on one line. */
    .resume__row {
        gap: var(--spacing-sm);
    }

    .resume__role,
    .resume__org {
        font-size: var(--font-size-lg);
    }

    .resume__period {
        font-size: var(--font-size-sm);
    }


    .pill-button {
        font-size: var(--font-size-md);
        padding: var(--spacing-md) var(--spacing-xl);
    }

    .section-title {
        width: var(--width-mobile);
        font-size: var(--font-size-lg);
    }

    .writing-list {
        width: var(--width-mobile);
    }

    .writing-item {
        flex-direction: column;
        gap: var(--spacing-xs);
        font-size: var(--font-size-md);
    }

    /* The bottom padding is clearing two different things, and only one of them
       is a device inset.
     *
       env(safe-area-inset-bottom) covers the iOS home indicator, and it is what
       that value is for. It does NOT cover an in-app browser's toolbar: Twitter
       and Instagram float a bar over the bottom of the page rather than
       reserving space for it, the page is never told, and env() reports zero.
       The socials sat 30px off the bottom, which is squarely underneath it.
     *
       So there is a floor as well — enough that the last row in here clears a
       floating bar — and the two are added, since a notched phone in an in-app
       browser has both.
     *
       The top figure is generous, and has to be. The page is meant to cover the
       footer's first --border-radius-page so the sheet's rounded corners land
       ON it — but in an in-app browser it covers a great deal more than that.
       The footer is fixed to the bottom of the LAYOUT viewport, which runs on
       behind the floating toolbar, so the whole thing sits lower than the part
       of the screen you can see while the page's own end is unchanged. The
       overlap becomes the radius plus the height of that bar, and the first
       thing in the footer — the mark — was being cut in half by it.
     *
       So the mark starts far enough down to clear a toolbar as well as the
       corners. It costs nothing anywhere else: the footer is the last thing on
       the page and this is empty space above a centred column. */
    .footer {
        width: 100%;
        margin: 0;
        padding: clamp(var(--spacing-5xl), 22vh, var(--spacing-7xl)) 0
            calc(var(--spacing-5xl) + env(safe-area-inset-bottom, 0px)) 0;
        /* Held at the chrome size rather than following the mobile body down to
           sm, so the footer is the same tier as the section labels and buttons
           above it and the page keeps to three sizes on a phone too. */
        font-size: var(--font-size-md);
    }

    .footer-inner {
        width: var(--width-mobile);
    }

    .controls-panel {
        left: var(--spacing-md);
        right: var(--spacing-md);
        bottom: var(--spacing-md);
    }

    .control-group input[type="range"] {
        width: 100%;
    }
}


/* ============================================================================
   Page chrome shared by every non-home page
   ========================================================================== */

/* Every page that is not the home page hangs off this, so it is what covers the
   fixed footer as those pages scroll. Opaque and a layer above it, same as
   .container on the home page. */
.page-shell {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    position: relative;
    z-index: var(--z-content);
    background-color: var(--background-color);
    border-bottom-left-radius: var(--border-radius-page);
    border-bottom-right-radius: var(--border-radius-page);
}

/* The page's own end. The footer is fixed BEHIND the sheet and revealed as the
   sheet scrolls off it, so the last line of a post used to sit hard against the
   sheet's rounded bottom edge with nothing under it — the text ran out and the
   page ended in the same pixel.

   Bottom is larger than top on purpose: the top gap is measured against the
   header above it, and this one against nothing at all. */
.page-main {
    flex: 1 0 auto;
    padding-top: var(--spacing-4xl);
    padding-bottom: var(--spacing-5xl);
}

/* Reading column. Prose wants a measure, not the full 90vw the home grid uses. */
.measure {
    width: 100%;
    max-width: var(--measure);
    margin: 0 auto;
    /* This padding is also the gutter the prose lists hang their bullets and
       numbers into — see .prose ul/.prose ol. It is sized to clear a
       single-digit marker, which is what the writing here actually uses; an
       ordered list that runs past 9 will have the tens digit tight against the
       edge on a viewport no wider than the measure. */
    padding: 0 var(--spacing-xl);
    box-sizing: border-box;
}


/* ===== Post / page header ===== */

/* Vertical only — .measure owns the horizontal padding, and a shorthand here
   would reset it and pull the header out of line with the prose below. */
.entry-header {
    padding-top: var(--spacing-4xl);
    padding-bottom: var(--spacing-2xl);
}

.entry-thumbnail {
    width: var(--project-thumbnail-size);
    aspect-ratio: 1 / 1;
    border-radius: var(--border-radius-lg);
    object-fit: cover;
    display: block;
    margin-bottom: var(--spacing-lg);
    background-color: var(--black-5);
}

.entry-title {
    font-family: var(--font-heading);
    font-size: var(--font-size-4xl);
    font-weight: 600;
    letter-spacing: var(--letter-spacing-tight);
    line-height: var(--line-height-tight);
    margin: 0;
}

/* No italic here or on blockquotes: TASA Explorer has no italic cut, and the
   synthesised oblique reads as a mistake. Size and colour carry the shift. */
.entry-subtitle {
    font-family: var(--font-heading);
    font-size: var(--font-size-xl);
    color: var(--text-secondary);
    margin-top: var(--spacing-md);
}

.entry-meta {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    text-transform: uppercase;
    letter-spacing: var(--letter-spacing-wide);
    color: var(--text-secondary);
    margin-top: var(--spacing-lg);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--spacing-sm);
}

.entry-meta .tag {
    text-transform: uppercase;
}


/* ===== Prose ===== */

.prose {
    font-size: var(--font-size-lg);
    line-height: 1.55;
    padding-bottom: var(--spacing-6xl);
}

.prose> :first-child {
    margin-top: 0;
}

.prose p,
.prose ul,
.prose ol,
.prose dl,
.prose pre,
.prose figure,
.prose table,
.prose blockquote {
    margin: 0 0 var(--spacing-lg) 0;
}

.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5 {
    font-family: var(--font-heading);
    letter-spacing: var(--letter-spacing-regular);
    margin: var(--spacing-3xl) 0 var(--spacing-md) 0;
}

.prose h1 {
    font-size: var(--font-size-3xl);
}

.prose h2 {
    font-size: var(--font-size-2xl);
}

.prose h3 {
    font-size: var(--font-size-xl);
}

.prose h4 {
    font-size: var(--font-size-lg);
}

.prose h5 {
    font-size: var(--font-size-md);
    text-transform: uppercase;
    letter-spacing: var(--letter-spacing-wide);
    color: var(--text-secondary);
}

/* Kept from the old theme: h6 is used as a pull-quote / aside, not a heading. */
.prose h6 {
    font-family: var(--font-heading);
    font-size: var(--font-size-md);
    font-weight: normal;
    border-left: 3px solid var(--accent-color);
    padding-left: var(--spacing-md);
    color: var(--accent-color);
    margin: var(--spacing-lg) 0 var(--spacing-2xl) 0;
    line-height: 1.6;
}

.prose a {
    color: var(--text-reg);
    border-bottom: 1px solid var(--black-20);
    transition: var(--transition-fast);
}

.prose a:hover {
    color: var(--black);
    border-bottom-color: var(--black);
}

.prose img,
.prose video,
.prose iframe {
    max-width: 100%;
    height: auto;
    border-radius: var(--border-radius-lg);
    display: block;
    margin: var(--spacing-xl) 0;
}

.prose iframe {
    width: 100%;
    aspect-ratio: 16 / 9;
    border: 0;
}

.prose figure {
    margin-left: 0;
    margin-right: 0;
}

.prose figcaption {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--text-secondary);
    margin-top: calc(var(--spacing-sm) * -1);
}

/* Hanging markers.
 *
   The browser's default is padding-inline-start: 40px on the list, which indents
   the list TEXT and tucks the markers into that indent — so every list steps in
   from the measure and breaks the vertical line the paragraphs hold. Zeroing the
   padding puts the list text flush with the paragraphs and, with markers
   positioned outside, hangs the bullets and numbers out in the gutter beside it.

   The gutter they hang into is .measure's own horizontal padding, which is why
   that padding cannot be dropped: without it the markers would hang off the
   edge of the viewport rather than into a margin. It is sized for a single
   digit, which is what the lists here run to. */
.prose ul,
.prose ol {
    padding-left: 0;
    list-style-position: outside;
}

/* Nested lists keep an indent — flush is for the top level, which is what sits
   against the paragraphs. A nested list read flush against its parent would not
   show as nested at all. */
.prose li ul,
.prose li ol {
    padding-left: var(--spacing-xl);
    margin-bottom: 0;
}

.prose li {
    margin-bottom: var(--spacing-sm);
}

.prose li::marker {
    color: var(--accent-color);
}

.prose blockquote {
    border-left: 3px solid var(--accent-color);
    padding: var(--spacing-sm) var(--spacing-lg);
    background-color: var(--black-3, var(--black-5));
    border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
    font-family: var(--font-heading);
    font-size: var(--font-size-xl);
}

.prose blockquote> :last-child {
    margin-bottom: 0;
}

.prose code,
.prose pre {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.85em;
    letter-spacing: 0;
}

.prose code {
    background-color: var(--black-5);
    border-radius: var(--border-radius-sm);
    padding: 2px 6px;
}

.prose pre {
    background-color: var(--code-background);
    color: var(--code-color);
    border-radius: var(--border-radius-md);
    padding: var(--spacing-md) var(--spacing-lg);
    overflow-x: auto;
}

.prose pre code {
    background: none;
    padding: 0;
    color: inherit;
}

.prose table {
    width: 100%;
    border-collapse: collapse;
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    display: block;
    overflow-x: auto;
}

.prose th,
.prose td {
    padding: var(--spacing-sm) var(--spacing-md);
    border: 1px solid var(--black-10);
    text-align: left;
}

.prose th {
    background-color: var(--black-5);
    font-weight: 500;
}

.prose tr:nth-child(even) td {
    background-color: var(--black-3, var(--black-5));
}

.prose hr {
    border: 0;
    border-top: 1px solid var(--black-10);
    margin: var(--spacing-3xl) 0;
}

/* Footnote-ish superscript links, carried over from the old theme. */
.prose sup {
    padding: 0 4px;
    margin-left: 2px;
    background-color: var(--black-5);
    border-radius: var(--border-radius-sm);
    font-weight: 500;
    display: inline-block;
    vertical-align: baseline;
    transform: translateY(-2px);
    font-size: var(--font-size-sm);
}

.prose sup a {
    border-bottom: none;
}


/* ===== Archive / tag listings ===== */

.listing {
    width: 100%;
    max-width: var(--measure);
    margin: 0 auto;
    /* The same gutter as .measure and the header row — these three are the
       site's one column and any of them differing shows as a step. */
    padding: 0 var(--spacing-xl);
    box-sizing: border-box;
}

.listing-item {
    display: flex;
    flex-direction: row;
    gap: var(--spacing-lg);
    align-items: flex-start;
    padding: var(--spacing-md);
    margin: 0 calc(var(--spacing-md) * -1);
    border-radius: var(--border-radius-card);
    transition: background-color var(--transition-fast);
}

.listing-item:hover {
    background-color: var(--slightly-warm-gray);
}

.listing-thumbnail {
    flex: 0 0 var(--project-thumbnail-size-mobile);
    width: var(--project-thumbnail-size-mobile);
    aspect-ratio: 1 / 1;
    border-radius: var(--border-radius-md);
    object-fit: cover;
    display: block;
    background-color: var(--black-5);
}

.listing-body {
    flex: 1 1 auto;
    min-width: 0;
}

.listing-title {
    font-family: var(--font-heading);
    font-size: var(--font-size-lg);
    font-weight: 600;
    margin: 0;
}

/* A post whose page is a standalone repo (`is_repo`). The listing treatment is
   deliberately quieter than search's: there the arrow REPLACES the type icon in
   a column that is already there, so it can be full size; here it is an extra
   mark inside a line of text, and a full-size one would read as punctuation the
   title did not ask for.

   Sized in em so it tracks the title, and set on the baseline-ish middle rather
   than after a space — the margin is the gap, so the title never wraps away
   from its own marker. */
.listing-title__repo {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: 1.5em;
    height: 1.5em;
    margin-left: 0.4em;
    border-radius: 50%;
    /* --black-10, not --black-5: at this size the disc is only a few pixels of
       fill and the lighter wash never resolves as a circle — it just looks like
       a bare glyph sitting after the title. */
    background-color: var(--black-10);
    color: var(--text-secondary);
    font-size: 0.6em;
    line-height: 1;
    vertical-align: middle;
    /* The circle is a chip on the text baseline; without this it rides high on
       the cap height and sits above the title's optical centre. */
    position: relative;
    top: -0.08em;
}

.listing-item:hover .listing-title__repo {
    background-color: var(--black-10);
    color: var(--text-reg);
}

.listing-meta {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--text-secondary);
    margin-top: var(--spacing-xs);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--spacing-sm);
}


/* ===== Slideshow (used inside posts) =====

   No JavaScript. Author it as a paragraph of images with a Kramdown IAL:

       {: .slideshow tabindex="0" role="group" aria-label="Photos from Goa" }

       ![First](…)![Second](…)![Third](…)

   The paragraph becomes a full-bleed scroll-snap strip. Swipe, trackpad,
   scrollbar drag and arrow keys are all the browser's native scrolling, so
   there is nothing to keep in sync. Any number of images, any filenames, and
   the alt text you wrote is the alt text that ships. */

.prose .slideshow {
    /* The only knob. Slide size and the centring padding both derive from it. */
    --slide-width: min(720px, 86vw);

    /* Full-bleed out of .measure. The site has no global border-box reset, so
       set it here or the inline padding below widens the element past 100vw. */
    box-sizing: border-box;
    width: 100vw;
    margin-inline: calc(50% - 50vw);
    margin-block: var(--spacing-xl);

    display: flex;
    /* Not the default `stretch`: flex would force every slide to the tallest
       one's height and distort any image with a different aspect ratio. */
    align-items: flex-start;
    gap: var(--spacing-md);
    /* Enough inline padding that the first and last slide can reach the centre.
       In vw, not %, because percentage padding resolves against the parent's
       width — and the parent here is the narrow .measure column. */
    padding-inline: max(var(--spacing-md), calc(50vw - var(--slide-width) / 2));

    overflow-x: auto;
    /* Don't hand a horizontal overscroll to the page or to browser back. */
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;

    /* The peeking neighbours are the affordance; the scrollbar is noise. */
    scrollbar-width: none;
}

.prose .slideshow::-webkit-scrollbar {
    display: none;
}

/* Overrides .prose img, which is display:block with vertical margins. */
.prose .slideshow>img {
    flex: none;
    width: var(--slide-width);
    max-width: none;
    height: auto;
    margin: 0;
    border-radius: var(--border-radius-lg);
    scroll-snap-align: center;
}

@media (prefers-reduced-motion: no-preference) {
    .prose .slideshow {
        scroll-behavior: smooth;
    }
}

/* Dots, generated from the slides themselves. Supporting browsers get one
   focusable group that arrow-keys between slides; the rest just scroll. */
@supports selector(::scroll-marker) {
    .prose .slideshow {
        scroll-marker-group: after;
    }

    /* Room for the dots, taken from whatever follows the strip rather than from
       the strip itself. The group takes no part in flow and is painted at the
       END of the strip's margin box — which is exactly where the next element
       begins — so a bigger margin on the strip does not open a gap, it just
       carries the dots further down the page with it. That was the first
       attempt, and the dots stayed exactly as overlapped as before.

       The figure is the group's own height, its padding-block-start plus a dot,
       and then a gap under it. A strip that ends a post has no sibling to push
       and needs none: the page's own end padding is already there. */
    .prose .slideshow+* {
        margin-block-start: calc(var(--spacing-md) + 8px + var(--spacing-md));
    }

    .prose .slideshow::scroll-marker-group {
        display: flex;
        justify-content: center;
        gap: var(--spacing-sm);
        padding-block-start: var(--spacing-md);
    }

    .prose .slideshow>img::scroll-marker {
        content: "";
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background-color: var(--black-20);
        cursor: pointer;
        transition: background-color .2s;
    }

    .prose .slideshow>img::scroll-marker:hover,
    .prose .slideshow>img::scroll-marker:target-current {
        background-color: var(--accent-color);
    }

    .prose .slideshow>img::scroll-marker:focus-visible {
        outline: var(--focus-outline);
        outline-offset: var(--focus-outline-offset);
    }
}


/* ===== Image grids (used inside posts) =====

   Same authoring shape as .slideshow — a Kramdown IAL on a paragraph of
   images:

       {: .img-2col }
       ![First](…)![Second](…)

   .img-2col  two-up, stacking to one column on narrow screens.
              Add .no-stack to keep two columns everywhere, which is what
              portrait plates want.
   .img-6col  six-up, dropping to two columns on narrow screens. For sets of
              tall, narrow objects where the point is the set, not the detail.

   Scoped under .prose so they beat `.prose p`, which would otherwise reset
   the margins. */

.prose .img-2col,
.prose .img-6col {
    display: grid;
    gap: var(--spacing-md);
    margin: var(--spacing-xl) 0;
}

.prose .img-2col {
    grid-template-columns: repeat(2, 1fr);
}

.prose .img-6col {
    grid-template-columns: repeat(6, 1fr);
    gap: var(--spacing-sm);
}

.prose .img-2col>img,
.prose .img-6col>img {
    width: 100%;
    height: auto;
    margin: 0;
}

@media (max-width: 800px) {
    .prose .img-2col {
        grid-template-columns: 1fr;
    }

    /* Tall portrait plates read fine side by side even on a phone, and look
       absurd stacked at full width. */
    .prose .img-2col.no-stack {
        grid-template-columns: repeat(2, 1fr);
        gap: var(--spacing-sm);
    }

    .prose .img-6col {
        grid-template-columns: repeat(2, 1fr);
    }
}


/* ===== Editable notes page (pell) ===== */

.pell-button {
    width: 36px;
    height: 20px;
    border: 0;
    background: none;
    border-right: 1px solid var(--black-10);
    padding: 0 6px;
    color: var(--text-reg);
    font-size: 1.15em;
}

.pell-button:last-of-type {
    border-right: none;
}

.pell-actionbar {
    border: 1px solid var(--black-10);
    width: fit-content;
    border-radius: var(--border-radius-sm);
    padding: 5px 4px 4px 4px;
    box-shadow: 0 1px 2px 1px var(--black-5);
}

.hidden {
    opacity: 0;
}

@counter-style red-circle {
    system: cyclic;
    symbols: "\2B55";
    suffix: " ";
}

@counter-style green-circle {
    system: cyclic;
    symbols: "\1F7E2";
    suffix: " ";
}

li.todo {
    list-style-type: red-circle;
}

li.todo.done {
    list-style-type: green-circle;
    text-decoration: line-through;
    opacity: .75;
}


/* ===== 404 sign-in widget ===== */

.loginbutton {
    font-family: var(--font-body);
    font-size: var(--font-size-md);
    text-align: center;
    padding: 10px 20px;
    border-radius: 40px;
    background-color: var(--header-background);
    border: 1px solid var(--black-10);
    color: var(--text-reg);
    opacity: .7;
    transition: var(--transition-fast);
    cursor: pointer;
}

.loginbutton:hover {
    opacity: 1;
    transform: translateY(-2px);
}

.usercontainer {
    display: flex;
    width: fit-content;
    flex-direction: row;
    align-items: center;
    gap: var(--spacing-sm);
    border: 1px solid var(--black-10);
    border-radius: 36px;
    padding: 6px 20px 6px 6px;
}

.displaynone {
    display: none;
}

.username {
    color: var(--text-reg);
}

.userstatus {
    font-family: var(--font-body);
    font-size: var(--font-size-sm);
    color: var(--accent-color);
}

.userphoto {
    width: 36px;
    height: 36px;
    margin: 0;
    border-radius: 50%;
}

.loader {
    border: 16px solid var(--black-5);
    border-top: 16px solid var(--accent-color);
    border-radius: 50%;
    width: 60px;
    height: 60px;
    animation: spin 2s linear infinite;
    position: absolute;
    top: 50%;
    right: 50%;
    transform: translate(50%, -50%);
}

@keyframes spin {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(360deg);
    }
}

.animated {
    animation-duration: .3s;
    animation-fill-mode: both;
}

.fadeInUp {
    animation-name: fadeInUp;
}


/* ===== Syntax highlighting (Rouge) ===== */

.highlight .c,
.highlight .cm,
.highlight .c1 {
    color: #8b949e;
    font-style: italic;
}

.highlight .cp,
.highlight .cs {
    color: #8b949e;
    font-weight: bold;
}

.highlight .err {
    color: #ff7b72;
}

.highlight .k,
.highlight .kc,
.highlight .kd,
.highlight .kp,
.highlight .kr,
.highlight .o,
.highlight .ow,
.highlight .gs {
    color: #ff7b72;
    font-weight: bold;
}

.highlight .kt,
.highlight .nc {
    color: #79c0ff;
    font-weight: bold;
}

.highlight .s,
.highlight .sb,
.highlight .sc,
.highlight .sd,
.highlight .s2,
.highlight .se,
.highlight .sh,
.highlight .si,
.highlight .sx,
.highlight .s1 {
    color: #a5d6ff;
}

.highlight .sr {
    color: #7ee787;
}

.highlight .ss,
.highlight .ni {
    color: #d2a8ff;
}

.highlight .m,
.highlight .mf,
.highlight .mh,
.highlight .mi,
.highlight .mo,
.highlight .il {
    color: #79c0ff;
}

.highlight .na,
.highlight .no,
.highlight .nv,
.highlight .vc,
.highlight .vg,
.highlight .vi {
    color: #7ee787;
}

.highlight .nb,
.highlight .bp {
    color: #79c0ff;
}

.highlight .nf,
.highlight .ne {
    color: #d2a8ff;
    font-weight: bold;
}

.highlight .nn,
.highlight .gp {
    color: #c9d1d9;
}

.highlight .nt {
    color: #7ee787;
}

.highlight .ge {
    font-style: italic;
}

.highlight .gd {
    color: #ffdcd7;
    background-color: #67060c;
}

.highlight .gi {
    color: #aff5b4;
    background-color: #033a16;
}

.highlight .gh,
.highlight .gu {
    color: #8b949e;
}

.highlight .gr,
.highlight .gt {
    color: #ff7b72;
}

.highlight .go {
    color: #8b949e;
}

.highlight .w {
    color: #484f58;
}


@media (max-width: 800px) {
    .page-main {
        padding-top: var(--spacing-lg);
    }

    .entry-header {
        padding-top: var(--spacing-2xl);
        padding-bottom: var(--spacing-lg);
    }

    .entry-title {
        font-size: var(--font-size-2xl);
    }

    .entry-subtitle {
        font-size: var(--font-size-lg);
    }

    .prose {
        font-size: var(--font-size-md);
    }
}

/* Click-to-play YouTube frames on home Recent work. Additive; parallax skipped. */
.project-media--yt {
    cursor: pointer;
}

.project-media--yt.is-playing {
    cursor: default;
}

.project-media--yt:focus-visible {
    outline: var(--focus-outline);
    outline-offset: var(--focus-outline-offset);
}

.project-media--yt iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

.project-media--yt .project-thumbnail {
    height: 100%;
    object-fit: cover;
    translate: none;
}

@supports (animation-timeline: view()) {
    .project-media--yt {
        animation: none;
    }
}
