/* Security BSides Dehradun 0x03
   components.css: the reusable component system.

   Every class page authors are allowed to use lives here or in base.css.
   Structure comes from 1px hairline rules and whitespace, never from fills,
   never from shadows, never from radii. If something looks like a Bootstrap
   card, it is wrong.

   Markup contract: the header and footer markup is fixed in
   _fragments/SHELL.html. The selectors below style exactly that markup. */

@layer components {

  /* Header height is a component concern, but the nav overlay, the scroll
     offset and the sticky CTA all need to agree on it, so it is promoted to
     the root scope rather than duplicated as a magic number. */
  :root {
    --header-h: 4.25rem;
  }

  @media (min-width: 62.5625rem) {
    :root { --header-h: 5rem; }
  }

  /* SKIP LINK */

  .skip-link {
    position: fixed;
    inset-block-start: 0;
    inset-inline-start: 0;
    z-index: var(--z-toast);
    display: inline-block;
    padding: var(--s-3) var(--s-5);
    background: var(--accent);
    color: var(--accent-ink);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    background-image: none;
    transform: translateY(-110%);
    transition: transform var(--dur) var(--ease);
  }

  .skip-link:focus-visible {
    transform: translateY(0);
    outline-offset: -4px;
  }

  /* SITE HEADER */

  .site-header {
    position: sticky;
    inset-block-start: 0;
    z-index: var(--z-header);
    border-block-end: var(--bw) solid transparent;
    transition: border-color var(--dur) var(--ease);
  }

  /* The frosted panel is a pseudo-element, NOT the header itself. This looks
     like indirection for its own sake; it is not. An element with a
     `backdrop-filter` (or a filter, or a transform) becomes the containing
     block for its own fixed-position descendants. The mobile nav overlay is a
     fixed child of this header. Put the blur on .site-header and the overlay
     is trapped inside a 68px-tall box instead of covering the viewport.
     Moving the blur down one level keeps the effect and frees the overlay. */
  .site-header::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: color-mix(in oklab, var(--paper) 88%, transparent);
    transition: background-color var(--dur) var(--ease);
  }

  @supports (backdrop-filter: blur(1px)) {
    .site-header::before {
      backdrop-filter: blur(14px) saturate(140%);
    }
  }

  /* The rule appears only once the page has scrolled, so the header sits
     silently on the hero and asserts itself afterwards. */
  .site-header[data-scrolled] {
    border-block-end-color: var(--rule);
  }

  .site-header[data-scrolled]::before {
    background: color-mix(in oklab, var(--paper) 94%, transparent);
  }

  /* With the nav overlay open the header must be fully opaque, the overlay
     below it is opaque, and a translucent strip above an opaque panel reads
     as a rendering fault. */
  [data-nav-open] .site-header::before {
    background: var(--paper);
  }

  /* reading progress hairline

     A 2px accent line along the bottom edge of the header that fills as the
     document scrolls. It is the only piece of ambient motion that runs on
     every one of the fourteen pages, including the reading pages, because it
     is not decoration: on a 700-line code of conduct it answers "how much of
     this is left", which is a question a reader actually has.

     Three things worth knowing about how it is built.

     ::after and not ::before. The frosted panel above has already claimed
     ::before, and it cannot move. See the note on it. This is the header's
     other free pseudo-element and there is no third.

     It is driven by animation-timeline: scroll(root block), not by a scroll
     listener writing a width from JavaScript. That means the browser runs it
     off the main thread, so it stays glued to the scroll position under any
     load, and it costs nothing per frame. It also means it works with
     JavaScript disabled, which a progress bar arguably ought to.

     It animates `scale`, not `width`. Scaling is a compositor operation;
     animating width is a layout operation, on a sticky element, on every
     frame of every scroll. The base state is `scale: 0 1` (invisible), so if
     the timeline is ever inactive (a page too short to scroll, which is the
     404 page at most desktop heights) the line simply is not there. The
     failure mode of the alternative is a permanently full progress bar, which
     looks like a bug. */
  @supports (animation-timeline: scroll(root block)) {
    .site-header::after {
      content: "";
      position: absolute;
      /* -1px pulls the bar down over the header's own hairline border, so the
         two share a baseline instead of stacking into a 3px stripe. */
      inset-block-end: calc(var(--bw) * -1);
      inset-inline-start: 0;
      inline-size: 100%;
      block-size: var(--bw-strong);
      background: var(--accent);
      transform-origin: 0 50%;
      scale: 0 1;
      pointer-events: none;
      animation: bsd-scroll-progress linear both;
      animation-timeline: scroll(root block);
    }

    @keyframes bsd-scroll-progress {
      from { scale: 0 1; }
      to   { scale: 1 1; }
    }
  }

  .site-header__inner {
    width: min(100% - (var(--page-margin) * 2), var(--container));
    margin-inline: auto;
    min-height: var(--header-h);
    display: flex;
    align-items: center;
    gap: var(--s-5);
  }

  /* WORDMARK */

  /* The real Security BSides Dehradun lockup, and nothing else. No edition
     number, no type-set name beside it: the artwork is the identity and it is
     left to speak on its own. Only ever one visible child, hence no gap, the
     logo and the fallback below are mutually exclusive, never side by side.

     The mark is a background-image rather than an <img> so the themed
     --logo-url token does the light/dark swap: the browser fetches only the
     variant in use, and the manual theme toggle is handled without JS. The
     cost of that choice is that backgrounds do not print and are dropped
     entirely under forced-colors, which is what .wordmark__fallback exists
     for. See the note on it below. */
  .wordmark {
    display: flex;
    align-items: center;
    margin-inline-end: auto;
    background-image: none;
    padding-block-end: 0;
    color: var(--ink);
    flex-shrink: 0;
  }

  /* Height is the only dimension worth choosing: the width follows from the
     artwork's own ink box, which is why --logo-h drives both. The viewBox is
     cropped to that box, so there is no export padding to compensate for.
     Deriving the width with calc() rather than aspect-ratio keeps it exact in
     a flex row, where an aspect-ratio-only box has to negotiate its main size
     with its siblings before it has one. */
  .wordmark__logo {
    --logo-h: 2.25rem;
    display: block;
    flex-shrink: 0;
    block-size: var(--logo-h);
    inline-size: calc(var(--logo-h) * 175.76 / 76);
    background: var(--logo-url) center / contain no-repeat;
    transition: opacity var(--dur) var(--ease);
  }

  @media (min-width: 1000px) {
    .wordmark__logo { --logo-h: 2.625rem; }
  }

  .wordmark--footer .wordmark__logo { --logo-h: 3.25rem; }

  /* Pointer-guarded. The rationale lives once, at a:hover in base.css. */
  @media (hover: hover) and (pointer: fine) {
    a.wordmark:hover .wordmark__logo {
      opacity: 0.72;
    }

    a.wordmark:hover {
      color: var(--ink);
    }
  }

  a.wordmark:active .wordmark__logo {
    opacity: 0.6;
  }

  /* Print drops background images, and forced-colors mode drops them too, so
     in both the header would otherwise have no identity at all. The original
     type-set wordmark stays in the DOM and takes over in exactly those cases:
     the site degrades to its previous design rather than to a gap. It is
     aria-hidden in the markup, so it never doubles the link's accessible name
     regardless of which of the two is visible. */
  .wordmark__fallback {
    display: none;
    align-items: center;
    gap: var(--s-3);
  }

  @media print, (forced-colors: active) {
    .wordmark__logo { display: none; }
    .wordmark__fallback { display: flex; }
  }

  .wordmark__mark {
    inline-size: 0.75rem;
    block-size: 0.75rem;
    background: var(--accent);
    flex-shrink: 0;
  }

  .wordmark__text {
    display: flex;
    flex-direction: column;
    line-height: 1;
  }

  .wordmark__name,
  .wordmark__place {
    font-size: var(--fs-sm);
    font-weight: var(--fw-medium);
    letter-spacing: -0.015em;
    white-space: nowrap;
  }

  .wordmark__place {
    color: var(--ink-2);
  }

  .wordmark--footer .wordmark__name,
  .wordmark--footer .wordmark__place {
    font-size: var(--fs-base);
  }

  /* PRIMARY NAV */

  .site-nav__list {
    display: flex;
    align-items: center;
    gap: clamp(var(--s-4), 1.6vw, var(--s-6));
    padding: 0;
    list-style: none;
  }

  .site-nav__list li + li { margin-block-start: 0; }

  .site-nav__link {
    font-size: var(--fs-sm);
    font-weight: var(--fw-medium);
    letter-spacing: -0.008em;
    color: var(--ink-2);
    background-size: 0% 1px;
    background-position: 0 100%;
    padding-block-end: 0.2em;
    white-space: nowrap;
  }

  /* Split, not wrapped as a pair. Gating :focus-visible behind a pointer query
     would take the focus ring away from a tablet with a keyboard. See base.css.
     The two declarations are worth duplicating to keep that from happening. */
  @media (hover: hover) and (pointer: fine) {
    .site-nav__link:hover {
      color: var(--ink);
      background-size: 100% 1px;
    }
  }

  .site-nav__link:focus-visible {
    color: var(--ink);
    background-size: 100% 1px;
  }

  .site-nav__link[aria-current] {
    color: var(--ink);
    background-image: linear-gradient(var(--accent), var(--accent));
    background-size: 100% var(--bw-strong);
  }

  /* the sliding indicator

     One accent bar that glides between nav items instead of nine underlines
     appearing and disappearing. On the home page it follows the scrollspy, so
     it tracks the section you are reading; on every other page it parks under
     that page's own link and never moves. Same object, both jobs.

     It exists only when main.js has measured a link and set the two custom
     properties, which is what [data-nav-indicator] on the list signals, so
     with JavaScript off, or before the first measurement, the per-link
     underline above is what you see. That underline is switched off only while
     the bar is genuinely active, and never before: the fallback has to be the
     state the page paints in, not the state it ends up in.

     --nav-ind-w is a unitless multiplier on a 1px-wide bar rather than a
     width, so the size change is a compositor scale rather than a layout
     change on the sticky header. Same reasoning as the progress hairline
     above. transform-origin is the inline start, so the bar grows rightwards
     from wherever it has been translated to.

     Desktop only. Below 62.5rem the list becomes the vertical overlay, where
     an absolutely positioned horizontal bar means nothing, the min-width here
     is the exact complement of that breakpoint. */
  @media (min-width: 62.5625rem) {
    .site-nav__list {
      position: relative;
    }

    .site-nav__list::after {
      content: "";
      position: absolute;
      inset-block-end: 0;
      inset-inline-start: 0;
      inline-size: 1px;
      block-size: var(--bw-strong);
      background: var(--accent);
      transform-origin: 0 50%;
      translate: var(--nav-ind-x, 0) 0;
      scale: var(--nav-ind-w, 0) 1;
      opacity: 0;
      pointer-events: none;
      transition:
        translate var(--dur) var(--ease),
        scale var(--dur) var(--ease),
        opacity var(--dur-fast) var(--ease);
    }

    .site-nav__list[data-nav-indicator]::after {
      opacity: 1;
    }

    .site-nav__list[data-nav-indicator] .site-nav__link[aria-current] {
      background-image: none;
    }

    /* The first placement must not slide in from the left edge of the list.
       That reads as a loading artefact. main.js sets the position, forces a
       reflow, and only then adds the attribute; this kills the transition for
       that one frame so the bar simply appears where it belongs. */
    .site-nav__list[data-nav-indicator-instant]::after {
      transition: none;
    }
  }

  /* nav groups

     Nine top-level links became six by folding the related ones into three
     disclosures: About, Programme, Attend. The trigger is a <summary> and the
     panel is what the <details> reveals, and that is not a stylistic choice.
     It is the only dropdown that still opens with JavaScript off, that keeps
     its expanded state in the accessibility tree without an author writing
     aria-expanded by hand, and that closes its siblings for free, the `name`
     attribute makes the three groups mutually exclusive the way radio buttons
     are. main.js adds only what a disclosure cannot know on its own: hover
     intent, Escape, and clicks landing outside it.

     The trigger keeps the .site-nav__link class, so everything already keyed to
     that class reaches it with no second selector, the current-page pass in
     localiseNavLinks, the mobile stagger, the hover underline. The panel links
     keep it too, which is why the scrollspy still finds `#programme` after it
     moved inside a panel. */

  .site-nav__group {
    position: relative;
  }

  .nav-group__summary {
    display: inline-flex;
    align-items: center;
    gap: 0.42em;
    cursor: pointer;
  }

  /* A caret built from two borders on a rotated square. No icon font, no SVG,
     and it takes its colour from the trigger, so it dims and brightens with
     it instead of needing a second hover rule. */
  .nav-group__chevron {
    inline-size: 0.36em;
    block-size: 0.36em;
    border-inline-end: 1.5px solid currentColor;
    border-block-end: 1.5px solid currentColor;
    rotate: 45deg;
    translate: 0 -0.1em;
    transition:
      rotate var(--dur) var(--ease),
      translate var(--dur) var(--ease);
  }

  [open] > .nav-group__summary .nav-group__chevron {
    rotate: 225deg;
    translate: 0 0.06em;
  }

  /* With JavaScript off there is no bar to slide, so a group that holds the
     current page has to mark its own trigger. :has() is what makes that
     possible at all, the state lives on a descendant and the styling belongs
     to an ancestor, and nothing else in CSS reads in that direction. */
  .site-nav__group:has(.site-nav__link[aria-current]) .nav-group__summary {
    color: var(--ink);
  }

  .nav-group__list {
    padding: 0;
    list-style: none;
  }

  .nav-group__list li + li { margin-block-start: 0; }

  /* The shared underline idiom is wrong inside a panel: eight links each
     growing their own hairline is noise, and the panel already has an edge.
     A background tint carries the hover instead, which is why the gradient
     every <a> inherits from base.css is switched off here rather than resized. */
  .site-nav__link--sub {
    display: block;
    font-size: var(--fs-sm);
    font-weight: var(--fw-regular);
    letter-spacing: -0.006em;
    color: var(--ink-2);
    background-image: none;
    padding-block-end: 0;
  }

  @media (hover: hover) and (pointer: fine) {
    .site-nav__link--sub:hover {
      color: var(--ink);
      background-size: 0% 1px;
    }
  }

  .site-nav__link--sub:focus-visible {
    color: var(--ink);
    background-size: 0% 1px;
  }

  /* the panel, as a desktop dropdown

     --paper-raised rather than --paper: the panel floats over body copy and has
     to be opaque, and on the light theme it is the one surface that is pure
     white, which reads as "above" without asking a second shadow to say so.

     The vertical offset is the one line here worth reading twice. Two things
     make it a measurement rather than a guess:

       · The panel's containing block is NOT the group <li>. The `translate` on
         ::details-content below is a transform-like property, so that generated
         box becomes the containing block for anything absolute inside it, and
         its own height is zero, because its only in-flow child is this panel.
         So `inset-block-start` counts from the summary's bottom edge, and a
         percentage here resolves against 0 and is useless.
       · A short trigger centred in a tall bar leaves a gap below it. At this
         breakpoint --header-h is 5rem and the trigger box is 29px, so the
         trigger's bottom edge sits 26.5px above the header's hairline.

     Hence 2rem: the smallest step on the scale that clears those 26.5px, which
     lands the panel 5.5px below the hairline. Anything smaller and the panel,
     which outranks the header in z-order, paints over the header's own bottom
     rule, and the header's 88%-opaque backdrop means it cannot be hidden behind
     it either. The 5.5px is slack, not decoration: it absorbs the few px of
     drift a font swap can put into that 29px.

     There is deliberately no invisible bridge across the gap. The obvious one,
     a ::before spanning up from the panel, sits over the trigger and swallows
     the click that would close it. The 220ms close delay in main.js does the
     same job with none of that: a pointer crosses 32px in about 30ms. */
  @media (min-width: 62.5625rem) {
    .nav-group__panel {
      position: absolute;
      inset-block-start: var(--s-6);
      inset-inline-start: calc(var(--s-4) * -1);
      z-index: var(--z-overlay);
      min-inline-size: 11.75rem;
      padding-block: var(--s-2);
      background: var(--paper-raised);
      border: var(--bw) solid var(--rule);
      box-shadow: var(--shadow-overlay);
    }

    .site-nav__link--sub {
      padding: var(--s-2) var(--s-4);
      white-space: nowrap;
    }

    @media (hover: hover) and (pointer: fine) {
      .site-nav__link--sub:hover {
        background-color: var(--paper-sunk);
      }
    }

    .site-nav__link--sub:focus-visible,
    .site-nav__link--sub:active {
      background-color: var(--paper-sunk);
    }

    /* The sliding bar cannot reach into a panel, it lives on the list and
       tracks top-level items only. Inside the panel the current page is marked
       by an accent edge, which is the same accent doing the same job at a
       different scale. */
    .site-nav__link--sub[aria-current] {
      color: var(--ink);
      box-shadow: inset var(--bw-strong) 0 0 0 var(--accent);
    }

    /* The panel drops in rather than appearing. Same three-part @supports as
       the FAQ accordion further down this file, minus interpolate-size: an
       absolutely positioned panel is out of flow, so there is no height to
       animate and nothing below it to push. Fade plus a short rise is the
       whole gesture. A browser without ::details-content keeps the instant
       open, which is a working dropdown. */
    @supports (transition-behavior: allow-discrete) and
              selector(details::details-content) {
      .nav-group::details-content {
        opacity: 0;
        translate: 0 -0.4rem;
        transition:
          opacity var(--dur-fast) var(--ease),
          translate var(--dur) var(--ease-out),
          content-visibility var(--dur) var(--ease) allow-discrete;
      }

      .nav-group[open]::details-content {
        opacity: 1;
        translate: 0 0;
      }
    }
  }

  /* Present in the markup on every page, shown only inside the mobile
     overlay. Two selectors, deliberately: `.btn { display: inline-flex }` is
     declared later in this file at the same (0,1,0) specificity, so a
     single-class rule here loses on source order and the button appears in
     the desktop header. Same trap as .site-header__cta below. */
  .site-nav .site-nav__cta { display: none; }

  /* mobile nav overlay */

  @media (max-width: 62.5rem) {
    .site-nav {
      position: fixed;
      inset: var(--header-h) 0 0 0;
      z-index: var(--z-overlay);
      background: var(--paper);
      border-block-start: var(--bw) solid var(--rule);

      /* Long nav on a short landscape viewport must scroll, never clip. */
      overflow-y: auto;
      overscroll-behavior: contain;
      padding: var(--s-6) var(--page-margin) var(--s-9);

      /* Hidden by default. visibility (not display) so the transition runs
         and so focus cannot land inside while closed. */
      opacity: 0;
      visibility: hidden;
      transform: translateY(-0.5rem);
      transition:
        opacity var(--dur) var(--ease),
        transform var(--dur) var(--ease),
        visibility 0s linear var(--dur);
    }

    [data-nav-open] .site-nav {
      opacity: 1;
      visibility: visible;
      transform: none;
      transition-delay: 0s;
    }

    .site-nav__list {
      flex-direction: column;
      align-items: stretch;
      gap: 0;
    }

    /* Direct children only. The panel inside a group has its own <li>s, and a
       descendant selector would rule every one of them, turning a three-link
       dropdown into a stack of boxes inside a box. */
    .site-nav__list > li {
      border-block-end: var(--bw) solid var(--rule);
    }

    .site-nav__link {
      display: block;
      padding-block: var(--s-4);
      font-size: var(--fs-2xl);
      letter-spacing: var(--track-2xl);
      color: var(--ink);
      background-image: none;

      /* Staggered entrance, keyed off the open state. --dur-entrance, not
         --dur-slow: this is a menu item arriving in place, and it was sitting
         on a 620ms transition for a 0.5rem move. */
      opacity: 0;
      transform: translateY(0.5rem);
      transition:
        opacity var(--dur-entrance) var(--ease-out),
        transform var(--dur-entrance) var(--ease-out);
    }

    [data-nav-open] .site-nav__link {
      opacity: 1;
      transform: none;
    }

    /* Enumerated rather than computed: `sibling-index()` would express this in
       one rule but is far above the Safari 16.4 floor this site targets. The
       list must therefore cover every top-level nav item. Add one without
       adding a step here and the last flies in with no delay, out of step with
       the rest. There are six.

       `> li` and a descendant `.site-nav__link`, deliberately: the delay is
       counted per top-level item, and the links inside a group's panel pick up
       their group's step. A dropdown should arrive as one object, not as four
       links each on its own clock. */
    .site-nav__list > li:nth-child(1) .site-nav__link { transition-delay: 40ms; }
    .site-nav__list > li:nth-child(2) .site-nav__link { transition-delay: 80ms; }
    .site-nav__list > li:nth-child(3) .site-nav__link { transition-delay: 120ms; }
    .site-nav__list > li:nth-child(4) .site-nav__link { transition-delay: 160ms; }
    .site-nav__list > li:nth-child(5) .site-nav__link { transition-delay: 200ms; }
    .site-nav__list > li:nth-child(6) .site-nav__link { transition-delay: 240ms; }

    /* This is the rule the pointer guard was really written for. Inside the
       mobile overlay a hovered link and the current page are both --accent, and
       the overlay is only ever reached by touch, so before the guard every link
       a finger landed on stayed marked as current until the next tap. */
    @media (hover: hover) and (pointer: fine) {
      .site-nav__link:hover {
        color: var(--accent);
      }
    }

    .site-nav__link[aria-current] {
      color: var(--accent);
      background-image: none;
    }

    /* nav groups, as an in-flow accordion

       No dropdown on a phone: the panel is part of the list and pushes what
       follows, which is what the whole viewport is for. `position: static`
       undoes the desktop containing block so nothing tries to hang off it.

       display: flex re-asserted because the mobile `.site-nav__link` rule
       above sets `display: block` at the same specificity and later in the
       file, which would flatten the trigger and strand the chevron mid-line. */
    .site-nav__group { position: static; }

    .nav-group__summary {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .nav-group__chevron {
      inline-size: 0.3em;
      block-size: 0.3em;
      border-inline-end-width: 2px;
      border-block-end-width: 2px;
      color: var(--ink-3);
    }

    .nav-group__panel {
      padding-block-end: var(--s-4);
    }

    /* Indented one step and set at --fs-lg against the trigger's --fs-2xl, so
       the hierarchy is legible without a second rule or a bullet. */
    .site-nav__link--sub {
      padding-block: var(--s-2);
      padding-inline-start: var(--s-4);
      font-size: var(--fs-lg);
      letter-spacing: var(--track-body);
      color: var(--ink-2);
    }

    .site-nav__link--sub[aria-current] {
      color: var(--accent);
    }

    /* A height animation here, not the desktop fade: this panel is in flow, so
       the links below it move, and they should be pushed rather than jumped.
       Same three-part @supports as the FAQ accordion, and the same fallback,
       an instant open, which is a working accordion. */
    @supports (interpolate-size: allow-keywords) and
              (transition-behavior: allow-discrete) and
              selector(details::details-content) {
      .nav-group::details-content {
        block-size: 0;
        overflow: hidden;
        opacity: 0;
        translate: 0 0;
        transition:
          block-size var(--dur) var(--ease),
          opacity var(--dur) var(--ease),
          content-visibility var(--dur) var(--ease) allow-discrete;
      }

      .nav-group[open]::details-content {
        block-size: auto;
        opacity: 1;
      }
    }

    /* The nav overlay leads with the primary action. Below this breakpoint the
       header CTA is hidden (see below) so exactly one ticket call to action is
       ever on screen, the header stays uncluttered on a phone, and the action
       is the first thing in the panel rather than the last. */
    .site-nav .site-nav__cta {
      display: flex;
      margin-block-start: var(--s-6);
    }

    /* Two selectors, deliberately. `.btn { display: inline-flex }` is declared
       later in this file at the same specificity, so a single-class rule here
       loses on source order and the button stays visible. This is the whole
       reason the header CTA was overflowing a 390px viewport. */
    .site-header .site-header__cta {
      display: none;
    }
  }

  @media (max-width: 62.5rem) and (prefers-reduced-motion: reduce) {
    .site-nav__link {
      opacity: 1;
      transform: none;
      transition-delay: 0s !important;
    }
  }

  /* HEADER ACTIONS · theme toggle · nav toggle */

  .site-header__actions {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    flex-shrink: 0;
  }

  /* --- theme toggle: a circle that fills and empties. No icon font. --- */

  .theme-toggle {
    display: grid;
    place-items: center;
    inline-size: 2.75rem;
    block-size: 2.75rem;
    color: var(--ink-2);
    transition: color var(--dur-fast) var(--ease);
  }

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

  .theme-toggle:active { color: var(--ink); translate: 0 1px; }

  /* --- theme toggle: a sun and a moon, one spinning out as the other spins in
     ---

     Both marks are always in the DOM and always painted; only opacity, rotation
     and scale change. That is deliberate. Swapping one mark for another reads as
     a substitution, a thing that was replaced between two frames. Rotating one
     out while the other rotates in reads as a movement, and a movement is what
     tells the eye that IT did that, which is the whole point of a control.

     The rotation directions are opposite so the two marks never look like one
     object turning. 420ms is longer than --dur because this is the one control
     on the page whose result is a full repaint of the document: the glyph should
     still be moving when the paper finishes changing colour, or the toggle
     looks like it happened before the click. */
  .theme-toggle__glyph {
    --icon-size: 1.25rem;
    --icon-stroke: 1.7;
    fill: none;
    stroke: currentColor;
    stroke-width: var(--icon-stroke);
    stroke-linecap: round;
    stroke-linejoin: round;
    overflow: visible;
  }

  .theme-toggle__sun,
  .theme-toggle__moon {
    transform-box: view-box;
    transform-origin: 50% 50%;
    transition:
      opacity var(--dur) var(--ease),
      rotate 420ms var(--ease-spring, var(--ease-out)),
      scale 420ms var(--ease-spring, var(--ease-out));
  }

  .theme-toggle__sun  { opacity: 1; rotate: 0deg;    scale: 1; }
  .theme-toggle__moon { opacity: 0; rotate: -80deg;  scale: 0.4; }

  :root[data-theme="dark"] .theme-toggle__sun  { opacity: 0; rotate: 80deg;  scale: 0.4; }
  :root[data-theme="dark"] .theme-toggle__moon { opacity: 1; rotate: 0deg;   scale: 1; }

  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .theme-toggle__sun  { opacity: 0; rotate: 80deg; scale: 0.4; }
    :root:not([data-theme="light"]) .theme-toggle__moon { opacity: 1; rotate: 0deg;  scale: 1; }
  }

  /* The rays are the part that carries the motion at small sizes, so they get a
     second beat of their own: they retract into the disc before the sun leaves,
     rather than fading as a block. */
  .theme-toggle__sun path {
    transition: opacity var(--dur-fast) var(--ease) 80ms;
  }

  :root[data-theme="dark"] .theme-toggle__sun path { opacity: 0; transition-delay: 0ms; }

  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .theme-toggle__sun path { opacity: 0; transition-delay: 0ms; }
  }

  /* --- nav toggle: two bars morphing to an X, driven by aria-expanded --- */

  .nav-toggle {
    display: none;
    align-items: center;
    gap: var(--s-2);
    min-inline-size: 2.75rem;
    min-block-size: 2.75rem;
    padding-inline: var(--s-2);
    color: var(--ink);
  }

  @media (max-width: 62.5rem) {
    .nav-toggle { display: inline-flex; }
  }

  .nav-toggle__bars {
    position: relative;
    inline-size: 1.125rem;
    block-size: 0.6875rem;
    flex-shrink: 0;
  }

  .nav-toggle__bars::before,
  .nav-toggle__bars::after {
    content: "";
    position: absolute;
    inset-inline: 0;
    block-size: var(--bw-strong);
    background: currentColor;
    transition: transform var(--dur) var(--ease);
  }

  .nav-toggle__bars::before { inset-block-start: 0; }
  .nav-toggle__bars::after  { inset-block-end: 0; }

  .nav-toggle[aria-expanded="true"] .nav-toggle__bars::before {
    transform: translateY(0.3438rem) rotate(45deg);
  }

  .nav-toggle[aria-expanded="true"] .nav-toggle__bars::after {
    transform: translateY(-0.3438rem) rotate(-45deg);
  }

  .nav-toggle__label {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
  }

  /* BUTTONS */

  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--s-2);
    min-block-size: 2.75rem;
    padding: var(--s-3) var(--s-5);
    border: var(--bw) solid transparent;
    border-radius: var(--r-0);
    font-family: var(--font-sans);
    font-size: var(--fs-sm);
    font-weight: var(--fw-medium);
    letter-spacing: 0.005em;
    text-align: center;
    background-image: none;
    padding-block-end: var(--s-3);
    cursor: pointer;
    transition:
      background-color var(--dur-fast) var(--ease),
      border-color var(--dur-fast) var(--ease),
      color var(--dur-fast) var(--ease);
  }

  /* The pressed state, added on 2 September 2026. Before this the stylesheet
     contained no :active rule anywhere: every control went rest, hover, and
     then had nothing left to say when it was actually pressed. A click produced
     no response until the page changed underneath you, and on a phone, where
     hover barely exists, the button was inert from first touch to navigation.

     The 1px push is deliberately NOT added to the transition list above. A tap
     is over in roughly 100ms, so easing a 1px move in over --dur-fast would
     deliver about two thirds of one pixel and then take it back, which is a
     slower way of showing nothing. Instant down and instant up is how a real
     button behaves. The colour steps beside it stay transitioned, because a
     third of the way to a darker fill is visible where a third of a pixel is
     not.

     1px of translate, and not scale: this design is built out of hairlines, and
     scaling a box whose border is one device pixel puts that border between
     pixels for the length of the press. An integer translate keeps every rule
     on the grid. */
  .btn:active {
    translate: 0 1px;
  }

  .btn--sm {
    min-block-size: 2.25rem;
    padding: var(--s-2) var(--s-4);
    font-size: var(--fs-xs);
  }

  .btn--lg {
    min-block-size: 3.25rem;
    padding: var(--s-4) var(--s-6);
    font-size: var(--fs-base);
  }

  .btn--primary {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-ink);
  }

  @media (hover: hover) and (pointer: fine) {
    .btn--primary:hover {
      background: var(--accent-hover);
      border-color: var(--accent-hover);
      color: var(--accent-ink);
    }
  }

  .btn--primary:active {
    background: var(--accent-active);
    border-color: var(--accent-active);
  }

  .btn--secondary {
    border-color: var(--rule-strong);
    color: var(--ink);
  }

  @media (hover: hover) and (pointer: fine) {
    .btn--secondary:hover {
      border-color: var(--ink);
      background: var(--ink-wash);
      color: var(--ink);
    }
  }

  /* --accent-tint on press rather than a stronger --ink-wash, and the reason is
     the site's own colour grammar: accent means actionable, and a press is the
     action. --ink on --accent-tint is near-black on pale blue in light and
     near-white on deep blue in dark, so both themes stay far clear of AA. */
  .btn--secondary:active {
    border-color: var(--ink);
    background: var(--accent-tint);
    color: var(--ink);
  }

  .btn--ghost {
    color: var(--ink-2);
    padding-inline: var(--s-2);
  }

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

  .btn--ghost:active {
    color: var(--accent-active);
  }

  /* For use on an accent-filled band. */
  .btn--on-accent {
    background: var(--accent-ink);
    border-color: var(--accent-ink);
    color: var(--accent);
  }

  @media (hover: hover) and (pointer: fine) {
    .btn--on-accent:hover {
      background: transparent;
      border-color: var(--accent-ink);
      color: var(--accent-ink);
    }
  }

  /* Rest is a solid --accent-ink fill and hover empties it, so the press cannot
     simply go further in either direction without landing back on one of them.
     A faint wash of the same ink is the third distinct reading. */
  .btn--on-accent:active {
    background: color-mix(in oklab, var(--accent-ink) 16%, transparent);
    border-color: var(--accent-ink);
    color: var(--accent-ink);
  }

  .btn--full { inline-size: 100%; }

  /* Disabled is expressed with aria-disabled rather than the disabled
     attribute so the control stays focusable and screen-reader-discoverable.
     No page uses this state today: the tickets CTA held it until the prices
     were staged, and it is now a live link to KonfHub beside a "Not open yet"
     chip. The specimen in styleguide.html block 03 is the only place it is
     exercised, which is the reason that row exists.

     --ink-2, not --ink-3: this button sits on --accent-wash over --paper-sunk,
     and --ink-3 on that compound background measures 4.21:1, under AA for
     14px text. Greying out text nobody can read is not a state, it is a bug.
     The dashed hairline and the absent fill already say "not yet". */
  .btn[aria-disabled="true"] {
    background: transparent;
    border-color: var(--rule);
    border-style: dashed;
    color: var(--ink-2);
    pointer-events: none;
  }

  .btn__arrow {
    display: inline-block;
    transition: transform var(--dur) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .btn:hover .btn__arrow {
      transform: translateX(3px);
    }
  }

  /* the pending state */
  /* Three dots in the arrow's place while a form is being posted. Both forms on
     the site are Netlify forms, which means a real POST and a redirect, so the
     gap between the press and the next page is network latency and nothing the
     page can shorten. Before this, the only feedback in that gap was the browser's
     own loading indicator, which on a fast connection is the tab spinner and
     nothing else.

     WHAT DECIDES WHEN THIS APPEARS IS NOT HERE. main.js sets the attribute on a
     400ms timer, not on the submit event, and the timer is in JavaScript rather
     than an animation-delay because the reduced-motion backstop in base.css sets
     `animation-delay: 0ms !important` and would erase a CSS delay outright. A
     submission that completes inside 400ms navigates away before the dots ever
     paint, which is the intended behaviour: a flash of loader on a fast
     connection is worse than no loader at all.

     The dots replace the arrow rather than joining it, and they are sized to the
     arrow's own advance so that neither the label nor the button's right edge moves
     when one becomes the other. The figures are under .btn__dots below. They are
     aria-hidden and the button's accessible name is untouched,
     because the state resolves in a navigation and announcing a transient
     "sending" that is immediately replaced by a new page is noise. The button is
     never given the `disabled` attribute either: that would pull it out of the
     accessibility tree mid-submission and, in some browsers, cancel the
     submission it was meant to report. Re-entry is guarded in JavaScript instead.

     Reduced motion needs nothing here and gets it for free. The dots' resting
     opacity is 1 and the keyframe dips below it, so when the backstop clamps the
     animation to one 1ms iteration they simply sit there, three solid dots,
     which is still the whole message. Written this way round on purpose: a
     keyframe that rose from 0 to 1 would leave three invisible dots instead. */
  .btn[data-pending] .btn__arrow {
    display: none;
  }

  /* The one place on this site where a length is em-relative rather than a spacing
     token, and the reason is that these three dots have to occupy the advance of a
     glyph. `→` measures 13.656px in the 17px of .btn--lg, which is 0.803em, and it
     will be 0.803em in any button at any size because it is the same character in
     the same face. Three dots of 0.2em with two gaps of 0.1em resolve to 3.398px and
     1.7px, so the group measures 13.586px against the arrow's 13.656px and the
     button goes from 236.695px to 236.625px when one is swapped for the other. Seven
     hundredths of a pixel, and it returns to 236.695px exactly when the dots come
     off. --s-1 was the first attempt and it is 4px flat: three of those and two 4px
     gaps make 20px, which grew the button by 6.34px on every slow submission and
     pushed its right edge out from under the note beneath it. A spacing token cannot
     express "as wide as an arrow", so this is measured in the unit the arrow is. */
  .btn__dots {
    display: inline-flex;
    align-items: center;
    gap: 0.1em;
  }

  .btn__dot {
    inline-size: 0.2em;
    aspect-ratio: 1;
    border-radius: var(--r-pill);
    background: currentColor;
    animation: bsd-dot-pulse calc(var(--dur) * 2) var(--ease) infinite;
  }

  /* Three --stagger steps between dots, which at 40ms puts the third dot 240ms
     into a 600ms cycle: far enough behind to read as a wave travelling along the
     row rather than three dots breathing in unison. */
  .btn__dot:nth-child(2) { animation-delay: calc(var(--stagger) * 3); }
  .btn__dot:nth-child(3) { animation-delay: calc(var(--stagger) * 6); }

  @keyframes bsd-dot-pulse {
    0%, 100% { opacity: 0.3; }
    50%      { opacity: 1; }
  }

  /* TEXT LINKS */

  .link-arrow {
    display: inline-flex;
    align-items: baseline;
    gap: var(--s-2);
    font-weight: var(--fw-medium);
    color: var(--accent);
    background-size: 0% 1px;
  }

  @media (hover: hover) and (pointer: fine) {
    .link-arrow:hover {
      color: var(--accent-hover);
      background-size: 100% 1px;
    }
  }

  .link-arrow:active {
    color: var(--accent-active);
    background-size: 100% 1px;
  }

  .link-arrow::after {
    content: "\2192";
    display: inline-block;
    transition: transform var(--dur) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .link-arrow:hover::after {
      transform: translateX(3px);
    }
  }

  .link-arrow--lg { font-size: var(--fs-lg); }

  .link-mono {
    font-family: var(--font-mono);
    font-size: var(--fs-sm);
    letter-spacing: var(--track-mono);
    color: var(--ink-2);
    background-size: 100% 1px;
  }

  @media (hover: hover) and (pointer: fine) {
    .link-mono:hover { color: var(--accent); }
  }

  .link-mono:active { color: var(--accent-active); }

  /* EDITORIAL PRIMITIVES */

  /* --- eyebrow: the mono label that sits above almost every heading --- */

  .eyebrow {
    display: flex;
    align-items: baseline;
    gap: var(--s-3);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    font-weight: var(--fw-regular);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  /* The number is the brand red, not the accent blue, and the eyebrow's own text
     stays --ink-3. Red here rather than blue because a section number labels
     something; it is not a thing to click. See the colour comment at the top of
     tokens.css for the split, which is the reason this line changed. */
  .eyebrow__num {
    color: var(--brand);
  }

  .eyebrow--brand { color: var(--brand); }

  /* --- the coordinates / elevation stamp: the tagline made literal --- */

  .meta-stamp {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--s-2) var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-2);
    font-variant-numeric: tabular-nums;
  }

  /* The stamp is --ink-2 and the separator is --ink-3, one step apart, and the
     order matters: the separator used to be --rule-strong, which measures
     2.06:1 and is effectively invisible at 11px. That is defensible for a "·"
     and indefensible for the "→" the CFP hero puts between two dates, an
     arrow that means "through" is information, not ornament, so the decoration
     exemption does not apply to it. Keeping the hierarchy while clearing AA
     meant moving both values up rather than just this one: --ink-3 text with an
     even lighter separator has nowhere left to go above the 4.5:1 floor.

     Worth noting what this cost: nothing. The stamp is the smallest type on the
     site and it was set in the lightest ink, which was backwards. */
  .meta-stamp__sep {
    color: var(--ink-3);
  }

  /* --- section: the vertical rhythm of the whole site --- */

  .section {
    padding-block: var(--section-y);
  }

  .section--sunk    { background: var(--paper-sunk); }
  .section--ruled   { border-block-start: var(--bw) solid var(--rule); }
  .section--tight   { padding-block: calc(var(--section-y) * 0.55); }

  .section--accent {
    background: var(--accent);
    color: var(--accent-ink);
  }

  .section--accent :is(.display, .h1, .h2, .h3, .h4, .h5) { color: var(--accent-ink); }

  /* 82% is the floor for secondary text on the accent surface, not a taste
     call: 72% measured 4.32:1 against --accent, which fails AA for the 11px
     mono an .eyebrow is set in. 78% clears it at 4.78:1 and 82% at 5.07:1, so
     82% is the one value used for every muted thing on this background. See
     the matching mix in the accent-surface corrections block below. */
  .section--accent .lead    { color: color-mix(in oklab, var(--accent-ink) 82%, var(--accent)); }
  .section--accent .eyebrow { color: color-mix(in oklab, var(--accent-ink) 82%, var(--accent)); }

  /* The markers are the brand red by default, and on this background red on blue
     measures about 1:1, which is a genuinely invisible character. No band
     currently pairs either of these with .section--accent; this is here so that
     the first one that does cannot ship broken. */
  .section--accent :is(.eyebrow__num, .ruled-list__index) { color: var(--accent-ink); }

  /* --- section-head: the asymmetric heading block --- */

  /* Label and number sit in the left columns, title and lead offset to the
     right. This is the single device that makes the layout read as Swiss
     rather than as a centred landing page. */
  .section-head {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-5) var(--gutter);
    align-items: start;
    margin-block-end: var(--head-y);
  }

  .section-head__eyebrow {
    grid-column: 1 / span 3;
    padding-block-start: 0.45em;
  }

  /* the label parallax

     The mono label in the left columns drifts about 1.8rem against the page as
     its section crosses the viewport, so the asymmetric grid pulls apart very
     slightly as you read down it. It is the quietest effect on the site and
     the one that does the most: a static two-column head reads as a layout,
     and a head whose label lags behind its heading reads as a printed page
     being turned. Nobody will name it. They will notice it is missing.

     `view(block)` is a view-progress timeline: 0% when the element first
     touches the viewport, 100% when it has fully left. animation-range pins it
     to that whole span so the label is mid-drift for exactly as long as the
     section is on screen, the default range would finish the drift while the
     head is still being read.

     Travel comes from --label-drift so that one line in base.css can stop it,
     and so reduced motion stops it by zeroing a length rather than by hoping a
     duration applies. It does not: a scroll-driven animation has no duration.

     Only the label moves. The heading, the lead and the aside stay exactly
     where the grid puts them, parallax on more than one element in a head
     stops being a texture and starts being a carousel. */
  @supports (animation-timeline: view()) {
    .section-head__eyebrow {
      animation: bsd-label-drift linear both;
      animation-timeline: view(block);
      animation-range: entry 0% exit 100%;
    }

    @keyframes bsd-label-drift {
      from { translate: 0 var(--label-drift); }
      to   { translate: 0 calc(var(--label-drift) * -1); }
    }
  }

  .section-head__title {
    grid-column: 4 / span 7;
  }

  /* the heading wipe

     The home hero's display lines do not fade in; they rise into view from
     under the clip edge of their own line box, because a fade on type that size
     reads as a slow image load and a wipe reads as letterpress. That was the
     best-looking thing on the site and it was happening on exactly two lines,
     above the fold, once per visit. Every other heading on all eleven pages,
     sixty-one of them, arrived by fading up 14px with its parent. This gives
     them the hero's gesture.

     IT IS THE CLIP THAT MOVES, NOT THE TEXT, and that is the whole reason this
     needs no markup change. The hero needs two spans because one element cannot
     both clip and be clipped: .hero__line is the window, .hero__line-in is what
     travels. But a window whose edge slides up and a body that rises behind a
     fixed edge are the same picture, and the second one can be done by
     animating clip-path on the heading itself. Sixty-one headings kept their
     markup, which also means nothing had to be propagated into eleven files and
     no heading id moved.

     THE COST, because there is one. translate is composited and clip-path is
     not, so each frame of this repaints the heading. That is affordable here for
     a reason specific to the effect: a section head is one text block, at most
     two of them are ever mid-wipe at once, and the wipe is one-shot. It would
     not be affordable on the 148 .reveal elements, which is why those still
     move with transform.

     WHY IT IS NOT SCROLL-DRIVEN, unlike the label drift directly above. A
     view() timeline is bidirectional: scroll back up and the animation runs
     backwards. For the label that is the point, the label drifts either way and
     nothing becomes unreadable. A wipe HIDES TEXT, so scrubbing it in reverse
     takes the heading away from a reader who scrolled up to read it again. So
     this one hangs off .is-inview, which main.js adds once and never removes.

     Polarity: --title-wipe is the size of the part still covered, so the token
     counts DOWN to nothing rather than up. The -0.14em on the other three sides
     is the hero's own value and it is there so ascenders, descenders and the
     letter-spaced final glyph are outside the clip instead of shaved by it.
     Carrying that same -0.14em into the animated side is what lets reduced
     motion set the token to 0% and get an unclipped heading with no second
     rule to keep in step. */
  html.js .section-head.reveal .section-head__title {
    clip-path: inset(-0.14em -0.25em calc(var(--title-wipe) - 0.14em) -0.25em);
    transition: clip-path var(--dur-entrance) var(--ease-out);
    transition-delay: calc(var(--stagger) * 2);
  }

  html.js .section-head.reveal.is-inview .section-head__title {
    clip-path: inset(-0.14em -0.25em -0.14em -0.25em);
  }

  .section-head__lead {
    grid-column: 4 / span 6;
  }

  .section-head__aside {
    grid-column: 11 / span 2;
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
    text-align: end;
    padding-block-start: 0.45em;
  }

  @media (max-width: 60rem) {
    .section-head {
      /* minmax(0, 1fr), never a bare 1fr. See the note in base.css's .grid.
         `1fr` means minmax(auto, 1fr), so one wide child blows the track out
         past the viewport and the page scrolls sideways. */
      grid-template-columns: minmax(0, 1fr);
      gap: var(--s-4);
    }

    .section-head__eyebrow,
    .section-head__title,
    .section-head__lead,
    .section-head__aside {
      grid-column: 1 / -1;
      padding-block-start: 0;
      text-align: start;
    }

    /* The parallax belongs to the wide asymmetric head. Stacked, the label sits
       directly above the heading with a 1rem gap, and ±0.9rem of drift inside a
       1rem gap is not a texture. It is two elements touching. The drift stops
       here rather than being reduced, because a 2mm version of it is invisible
       and still costs a composited layer. */
    .section-head { --label-drift: 0px; }
  }

  /* --- rules --- */

  .rule {
    border: 0;
    border-block-start: var(--bw) solid var(--rule);
    margin: 0;
  }

  .rule--strong { border-block-start-color: var(--rule-strong); }
  .rule--brand  { border-block-start: var(--bw-strong) solid var(--brand); }
  .rule--short  { inline-size: 4rem; }

  /* THE ACCENT SURFACE · corrections */

  /* `.section--accent` swaps paper for the accent colour, which silently
     breaks every component that draws itself IN the accent colour, an accent
     link on an accent background is invisible, and a --rule hairline vanishes.
     These are the corrections. They live next to the component definitions
     rather than in each page's CSS so a new accent band cannot forget them. */

  .section--accent .link-arrow { color: var(--accent-ink); }

  @media (hover: hover) and (pointer: fine) {
    .section--accent .link-arrow:hover {
      color: color-mix(in oklab, var(--accent-ink) 76%, var(--accent));
    }
  }

  /* The press does NOT continue the hover's dimming, and that is the one place
     in this file where the three-step pattern is broken on purpose. Rest here is
     full --accent-ink, which is the highest contrast available on this band, and
     hover already spends 24 percent of it. Spending more would make the pressed
     state the least legible of the three. So the press keeps the full ink and
     says its piece with a wash behind the text instead, which is the same answer
     .btn--on-accent:active reaches for the same reason. */
  .section--accent .link-arrow:active {
    color: var(--accent-ink);
    background-color: color-mix(in oklab, var(--accent-ink) 16%, transparent);
  }

  .section--accent :is(.rule, .accordion, .accordion__item) {
    border-color: color-mix(in oklab, var(--accent-ink) 28%, transparent);
  }

  .section--accent .chip {
    color: var(--accent-ink);
    border-color: color-mix(in oklab, var(--accent-ink) 45%, transparent);
  }

  /* 82%, matching .section--accent .lead and .eyebrow above. 78% measures
     4.78:1 against --accent and passes, but these carry 11px mono and there is
     no reason to sit 0.28 away from the AA floor when 5.07:1 is free. */
  .section--accent :is(.meta-stamp, .note-sm, .spec-list dt) {
    color: color-mix(in oklab, var(--accent-ink) 82%, var(--accent));
  }

  /* No accent band currently contains a .meta-stamp. This and the rule above
     are defensive, kept so the next one cannot ship broken. It does NOT get the
     one-step-lighter treatment the separator gets on paper: the passing band on
     this background runs from about 76% up to 100%, so "lighter than the text
     but still above 4.5:1" is a two-percent window and not a hierarchy. Same
     value as the text, and the gap does the separating. */
  .section--accent .meta-stamp__sep {
    color: color-mix(in oklab, var(--accent-ink) 82%, var(--accent));
  }

  /* PAGE HERO, the subpage masthead */

  /* index.html has `.hero`, which is a poster. Every other page gets this: the
     same asymmetric structure at roughly a third of the volume, so a subpage
     is unmistakably part of the same publication without competing with the
     front page for attention. The heading here is an .h1 class, never
     .display. That size is reserved for the home page. */
  .page-hero {
    padding-block-start: calc(var(--header-h) + var(--s-6));
    padding-block-end: clamp(var(--s-6), 5vw, var(--s-8));
  }

  .page-hero__inner {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: clamp(var(--s-5), 3vw, var(--s-7)) var(--gutter);
    align-items: end;
  }

  /* align-self overrides the row's own `end`, and only for this column. The pair
     is bottom-aligned so that the aside's last hairline lands level with the
     bottom of the action buttons, which is right whenever the headline column is
     the taller of the two, and it is on twelve of the fourteen mastheads. On the
     other two, workshops and ctf-thanks, the aside is a five-row spec list that
     is taller than everything to its left, and bottom-aligning let it decide
     where the headline began: 109px of dead space above the eyebrow on
     workshops, which pushed the lead, both buttons and the closing stamp under
     the fold. Anchoring this column to the top of the row costs nothing on the
     twelve, where it is the column that sets the row height and both edges are
     the same edge. */
  .page-hero__main {
    grid-column: 1 / span 7;
    align-self: start;
  }

  .page-hero__aside {
    grid-column: 9 / span 4;
    display: grid;
    gap: var(--s-4);
    justify-items: start;
    align-content: end;
  }

  /* The hairline + coordinate stamp that closes the masthead. Same device as
     the home hero, at the same position in the reading order, which is what
     makes five separate pages feel like one publication. */
  .page-hero__foot {
    grid-column: 1 / -1;
    padding-block-start: var(--s-5);
    border-block-start: var(--bw) solid var(--rule);
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s-3) var(--s-6);
  }

  .page-hero__title { margin-block-start: var(--s-5); }
  .page-hero__lead  { margin-block-start: var(--s-5); }

  /* A full-width plate under the masthead: the lead image of the page.

     A grid item spanning the whole row rather than a sibling after __inner, and
     that is the only reason it needs a rule at all. Inside the grid it inherits
     the masthead's own row gap, so the distance from the coordinate stamp to the
     image is a number that already exists on the page. Outside it, the plate
     would need a fresh margin, and a margin invented for one figure on one page
     is how a spacing scale starts having two answers for the same gap. It also
     must not carry .plate--ruled here: __foot has already drawn a hairline three
     rows up, and two hairlines that close to each other read as a mistake. */
  .page-hero__plate { grid-column: 1 / -1; }

  .page-hero__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-3);
    margin-block-start: var(--s-6);
  }

  /* the masthead entrance

     Every subpage's masthead rises and fades in, one part at a time, in the
     order you read it. Twelve pages share this; the home page has its own
     longer sequence in home.css.

     No `html.js` gate, unlike .reveal. That gate exists because .reveal's
     hidden state can only be removed by JavaScript, so it must not be applied
     unless JavaScript is running. A CSS animation has no such dependency: with
     `both` fill it holds its end state on its own, and in a browser that does
     not understand `animation` at all the declaration is simply dropped and the
     content is already visible. So this runs with scripting disabled, and it
     runs before main.js has even been fetched, which is the whole reason the
     hero does not use .reveal.

     The delays are written against the class names rather than :nth-child
     because cfp.html ships three mutually exclusive action blocks and hides
     two of them; nth-child would count the hidden ones and hand the visible
     block a delay that depends on which state the CFP is in.

     Longhands rather than the `animation` shorthand, for the reason recorded
     against the hero in home.css: `none` is a valid animation-name AND a valid
     animation-fill-mode, and the shorthand gives it to fill-mode, which shunts
     `both` into the name slot. --entrance: none must mean no animation, not a
     hunt for @keyframes both. */
  .page-hero__main > .eyebrow,
  .page-hero__title,
  .page-hero__lead,
  .page-hero__actions,
  .page-hero__aside,
  .page-hero__foot,
  .page-hero__plate {
    animation-name: var(--entrance);
    animation-duration: var(--dur-entrance);
    animation-timing-function: var(--ease-out);
    animation-fill-mode: both;
  }

  .page-hero__title    { animation-delay: calc(var(--stagger) * 1); }
  .page-hero__lead     { animation-delay: calc(var(--stagger) * 2); }
  .page-hero__actions,
  .page-hero__aside    { animation-delay: calc(var(--stagger) * 3); }
  .page-hero__foot     { animation-delay: calc(var(--stagger) * 4); }
  /* Last in, because it is last down the page. Joining the masthead sequence
     rather than wearing .reveal is deliberate: .reveal is gated on html.js and
     starts at opacity 0, so a lead image would sit as a hole in the page until
     main.js arrives. This runs with scripting off. */
  .page-hero__plate    { animation-delay: calc(var(--stagger) * 5); }

  /* The one shared entrance keyframe. Opacity and translate only, both are
     compositor properties, so nine of these running at once cost one frame's
     work rather than nine layout passes. Travel is a token so that base.css's
     motion policy and the reduced-motion block can flatten it to zero without
     needing a second copy of every rule.

     It finishes at --rest-opacity rather than at 1 so that an element whose
     resting opacity is not 1 can use this keyframe without the animation
     quietly overwriting its own rule. The home page's ridge is the only current
     case, at 0.85. Without the indirection it would fade in to full strength
     and stay there. */
  @keyframes bsd-rise {
    from {
      opacity: 0;
      translate: 0 var(--lift);
    }
    to {
      opacity: var(--rest-opacity, 1);
      translate: 0 0;
    }
  }

  @media (max-width: 60rem) {
    /* One --s step below the base rule, not equal to it. Two things shrink the
       masthead's clearance on a small screen: --header-h drops from 5rem to
       4.25rem of its own accord below 1001px, and this takes a step off the
       padding on top of that. So 80 + 32 on a desktop and 68 + 24 here. When
       the rhythm came down on 29 August 2026 the base rule landed on the value
       this one already held, which left the override declaring nothing, so it
       came down with everything else rather than being deleted. */
    .page-hero {
      padding-block-start: calc(var(--header-h) + var(--s-5));
    }

    .page-hero__inner { grid-template-columns: minmax(0, 1fr); }

    .page-hero__main,
    .page-hero__aside { grid-column: 1 / -1; }

    .page-hero__aside { align-content: start; }
  }

  /* SPLIT, the workhorse two-column body */

  /* 5 + 6 of 12, with a column of air between them. Deliberately not 6 + 6:
     an even split reads as a slide, an uneven one reads as a page. */
  .split {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    align-items: start;
  }

  .split__a { grid-column: 1 / span 5; }
  .split__b { grid-column: 7 / span 6; }

  /* The mirror: narrow column on the right. Used when the prose is the point
     and the metadata is the aside. */
  .split--wide-first .split__a { grid-column: 1 / span 7; }
  .split--wide-first .split__b { grid-column: 9 / span 4; }

  @media (max-width: 60rem) {
    .split { grid-template-columns: minmax(0, 1fr); }

    .split__a,
    .split__b,
    .split--wide-first .split__a,
    .split--wide-first .split__b { grid-column: 1 / -1; }
  }

  /* SPEC LIST, at-a-glance metadata */

  /* A real <dl>, wrapped per pair in a <div> (valid since HTML 5.2), so the
     term/value pairing is announced by screen readers instead of being
     implied by a two-column grid. Platform, dates, contacts, formats. */
  .spec-list {
    display: grid;
    gap: 0;
    border-block-start: var(--bw) solid var(--rule);
  }

  .spec-list > div {
    display: grid;
    grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr);
    gap: var(--s-2) var(--s-4);
    padding-block: var(--s-4);
    border-block-end: var(--bw) solid var(--rule);
  }

  .spec-list dt {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    font-weight: var(--fw-regular);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
    padding-block-start: 0.15em;
  }

  .spec-list dd {
    font-size: var(--fs-base);
    line-height: var(--lh-snug);
    color: var(--ink);
  }

  .spec-list dd small,
  .spec-list__note {
    display: block;
    margin-block-start: var(--s-2);
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    color: var(--ink-3);
  }

  @media (max-width: 30rem) {
    .spec-list > div { grid-template-columns: minmax(0, 1fr); }
  }

  /* RULED LIST, an ordered set of short statements */

  /* The alternative to turning six sentences into six cards. Hairlines, a mono
     index in the left column, and nothing else. Reads as a contents page. */
  .ruled-list {
    display: grid;
    gap: 0;
    list-style: none;
    padding: 0;
    border-block-start: var(--bw) solid var(--rule);
  }

  .ruled-list > li {
    margin-block-start: 0;
    padding-block: var(--s-5);
    border-block-end: var(--bw) solid var(--rule);
    display: grid;
    grid-template-columns: 2.75rem minmax(0, 1fr);
    gap: 0 var(--gutter);
  }

  /* The index is pinned, and everything else is pinned to the second column,
     rather than left to auto-placement. This matters because a leg is allowed
     more than one body paragraph: `grid-row: 1 / span 2` used to reserve the
     index column for exactly two content rows, so an unplaced third child fell
     into the 2.75rem index column and wrapped one word per line. MEASURED at
     1280 px before the fix: the second paragraph of visit-doon.html's leg 01
     was 44 px wide and the row was 939 px tall against 140 px for its
     neighbours. Pinning survives any number of paragraphs. */
  .ruled-list:not(.ruled-list--plain) > li > :not(.ruled-list__index) {
    grid-column: 2;
  }

  .ruled-list__index {
    grid-column: 1;
    grid-row: 1;
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    letter-spacing: var(--track-label);
    color: var(--brand);
    font-variant-numeric: tabular-nums;
    padding-block-start: 0.4em;
  }

  .ruled-list__title {
    display: flex;
    align-items: baseline;
    gap: var(--s-3);
    font-size: var(--fs-lg);
    font-weight: var(--fw-medium);
    letter-spacing: var(--track-body);
    line-height: var(--lh-snug);
    color: var(--ink);
    text-wrap: balance;
  }

  /* A mark on a leg of a journey. Used on visit-doon's four legs, where the
     titles are "By air", "By train", "By road" and the last leg to the campus:
     the three modes of transport ARE the content of those rows, and a plane, a
     train and a car are the closest thing to a universally read mark there is.
     Nowhere else on the site has a ruled list whose rows differ by something a
     drawing can say, so this selector sits inert on the others.

     align-items: baseline, not center. A flex item's baseline for a replaced
     element is its bottom margin edge, so the mark sits ON the text baseline
     and stays there when the title wraps to two lines, which center would not:
     center would drop it to the middle of the block and leave it beside
     nothing. The 0.08em nudge is the 1.3px by which the arithmetic sits the
     square above the type's cap-height centre.

     The wrapped lines of a title hang off the mark rather than returning to the
     column edge, which is the same hanging indent .icon-row already uses a few
     hundred lines down, for the same reason: the mark belongs to the whole
     title, not to its first line. */
  .ruled-list__title .icon {
    --icon-size: 1rem;
    color: var(--brand);
    translate: 0 0.08em;
  }

  .ruled-list__body {
    margin-block-start: var(--s-3);
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--ink-2);
    max-width: var(--measure-narrow);
  }

  /* No index column, for lists where numbering would imply a false order. */
  .ruled-list--plain > li { grid-template-columns: minmax(0, 1fr); }

  @media (max-width: 40rem) {
    .ruled-list > li { grid-template-columns: minmax(0, 1fr); }

    /* One column, so the pinning above has to be undone or every content
       child asks for a column that no longer exists. */
    .ruled-list:not(.ruled-list--plain) > li > :not(.ruled-list__index) {
      grid-column: 1;
    }

    .ruled-list__index {
      grid-row: auto;
      padding-block-start: 0;
      margin-block-end: var(--s-2);
    }
  }

  /* CTA BAND, how every subpage ends */

  /* One band, one action, and it is always the most useful next step from
     THAT page, never a generic "get in touch". Pair with .section--accent
     for the loud version or .section--sunk for the quiet one. */
  .cta-band__inner {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-6) var(--gutter);
    align-items: end;
  }

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

  .cta-band__actions {
    grid-column: 9 / span 4;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-3);
  }

  @media (max-width: 60rem) {
    .cta-band__inner { grid-template-columns: minmax(0, 1fr); }

    .cta-band__main,
    .cta-band__actions { grid-column: 1 / -1; }
  }

  /* SMALL SHARED BITS */

  /* A quieter aside inside a body column. Not `.lead` (wrong direction,
     leads are larger), not `.prose .note` (that is scoped to prose). */
  .note-sm {
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--ink-2);
  }

  /* The "there is more of this elsewhere" line that closes several sections.
     A real relationship between two blocks, so it is a rule here rather than
     a margin utility scattered through the markup. */
  .section-more { margin-block-start: var(--s-6); }

  /* ICON, the whole icon system, and it is deliberately this small.

     This site had exactly one <svg> across eighteen pages before 2 September
     2026 and that was not an oversight. Swiss editorial work is icon-light: a
     hairline, a number and a label out-perform a pictogram at almost every
     size, and the moment a stroke icon appears at the head of all twenty-six
     cards on the home page the page starts reading as a template. The nav caret
     is two borders on a rotated square, the "more" affordance is a real → in
     the text, the theme control is a filled circle. None of those wants
     replacing and none of them is here.

     What is here is for the one job a drawn glyph cannot do: a platform's own
     mark. "X", "IG", "LI", "YT" set in mono inside a bordered box is what the
     footer used to say, and four two-letter abbreviations read as a control
     panel waiting to be finished rather than as a set of links.

     The rules of use, and they are the whole reason this block is a primitive
     rather than four one-off rules in the footer:

       currentColor    always. `fill` and `stroke` inherit the text colour of
                       whatever the icon sits in, so an icon needs no hover
                       rule, no dark-theme rule and no variant. It dims and
                       brightens with its label for free.
       1em by default   so an icon beside text scales with the text, including
                       through every clamp() in the fluid scale. Override with
                       --icon-size where a mark is standalone rather than
                       inline.
       aria-hidden      on every single one. An icon is never the accessible
                       name. Either it sits beside real text, or the link
                       carries a .u-visually-hidden label, which is what the
                       footer marks do.
       flex-shrink: 0   an icon in a flex row must never be squeezed into an
                       ellipse by a long label next to it.
       block display    kills the inline baseline gap that otherwise leaves a
                       few stray pixels under a centred mark.

     Held in a same-document <symbol> sprite at the top of <body> and pulled in
     with <use href="#i-name"> rather than pasted whole at each use site. An
     EXTERNAL sprite file would be one cached request instead of one copy per
     page, and it was still the wrong trade: there is no build step to assemble
     one, the shell is duplicated into eighteen files and verified by
     check-shell.py rather than included at runtime, and an external reference
     stops resolving the moment the site is opened from file://, which
     docs/README.md treats as a supported way to read it. Thirty-six marks in
     the sprite is 9.5K per page against a 68K font file, and each usage after
     that is forty bytes.

     The one thing <use> costs: paths inside an instantiated symbol are not
     reachable by any selector, so only INHERITED properties cross the boundary.
     Everything below inherits. The three glyphs that animate their own
     internals are written inline at their use site for exactly this reason. */
  .icon-sprite {
    position: absolute;
    inline-size: 0;
    block-size: 0;
    overflow: hidden;
  }

  .icon {
    display: block;
    inline-size: var(--icon-size, 1em);
    block-size: var(--icon-size, 1em);
    flex-shrink: 0;
    fill: currentColor;
  }

  /* Stroke marks. The platform marks are filled silhouettes and must NOT wear
     this class, or they invert into outlines.

     stroke-width is in user units, and the viewBox is 24 wide, so a mark drawn
     at 1.5 renders at size/16 CSS pixels: 1.0px at 16px, 1.25px at 20px, 1.5px
     at 24px, 2.75px at 44px. Up to about 24px that scaling is what you want,
     because a mark and its stroke should grow together. Past that it stops
     being a hairline and starts being a border, which is why --icon-stroke
     exists. A 40px mark wants about 0.9, a 64px mark about 0.55.

     There is no `vector-effect: non-scaling-stroke` here, and there was, and it
     never did anything: the property is not inherited and does not apply to the
     <svg> element itself, so it never reached a single path. The five
     properties below all inherit, which is what lets them cross into a <use>
     tree. */
  .icon--stroke {
    fill: none;
    stroke: currentColor;
    stroke-width: var(--icon-stroke, 1.5);
    stroke-linecap: round;
    stroke-linejoin: round;
  }

  /* An icon set beside a label in a flex row. The optical centre of a 24-unit
     mark sits slightly above the x-height centre of the type next to it, so it
     is nudged down by a fraction rather than centred arithmetically. */
  .icon--inline {
    --icon-size: 1.05em;
    translate: 0 0.06em;
  }

  /* A mark that leads a line of text in a grid row: fixed column, aligned to
     the first line's cap height rather than to the block's centre. */
  .icon-row {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: start;
    gap: var(--s-3);
  }

  .icon-row > .icon {
    --icon-size: 1.25rem;
    color: var(--accent);
    translate: 0 0.1em;
  }

  /* STATS, the growth curve is this site's strongest asset */

  .stat-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr));
    gap: 0;
    border-block-start: var(--bw) solid var(--rule);
  }

  .stat {
    padding: var(--s-5) var(--s-5) var(--s-5) 0;
    border-block-end: var(--bw) solid var(--rule);
  }

  .stat-row > .stat + .stat {
    border-inline-start: var(--bw) solid var(--rule);
    padding-inline-start: var(--s-5);
  }

  @media (max-width: 40rem) {
    .stat-row > .stat + .stat {
      border-inline-start: 0;
      padding-inline-start: 0;
    }
  }

  .stat__value {
    display: block;
    font-family: var(--font-sans);
    font-size: var(--fs-3xl);
    font-weight: var(--fw-medium);
    line-height: 1;
    letter-spacing: var(--track-3xl);
    color: var(--ink);
    font-variant-numeric: tabular-nums;
  }

  .stat__value--accent { color: var(--accent); }

  .stat__label {
    display: block;
    margin-block-start: var(--s-3);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  .stat__note {
    display: block;
    margin-block-start: var(--s-2);
    font-size: var(--fs-xs);
    color: var(--ink-3);
  }

  /* CARDS, hairline only. No shadow, no radius, no fill. */

  .tile-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
    gap: var(--gutter);
  }

  .tile-grid--wide {
    grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
  }

  /* base.css puts margin-block-start on li + li, for prose lists, where it is
     right. In a grid it is not: the first tile of a row carries no margin and
     every other tile carries 8px, so the row's items share a bottom edge and
     miss a top edge by exactly that. On a page built out of hairline boxes an
     8px step is visible. Every tile-grid on the site is a ul, so this reset
     belongs on the grid rather than on each page. */
  .tile-grid > li + li { margin-block-start: 0; }

  /* Two grids in a row, which section 04 of visit-doon is the first place to
     need. .section-head and .plate both carry their own bottom margin, so
     everything else in a shell is spaced already and a tile-grid never needed
     one. Two of them stacked, and the second starts exactly where the first
     ends. --head-y is the token the section head uses below itself, so the
     four dishes and the pair under them sit one rhythm step apart rather than
     one gutter apart, which would read as a single six-tile grid broken in an
     odd place. */
  .tile-grid + .tile-grid { margin-block-start: var(--head-y); }

  /* Exactly four tiles, held in one row on a wide screen because four is the
     point: the CFP formats are a set of alternatives, not a feed. auto-fit
     cannot express that, since four 17rem tiles plus their gutters overshoot
     the shell by a handful of pixels and it quietly drops to three and an
     orphan. Two by two next, then one column. */
  .tile-grid--quad {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  @media (max-width: 60rem) {
    .tile-grid--quad { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }

  @media (max-width: 40rem) {
    .tile-grid--quad { grid-template-columns: minmax(0, 1fr); }
  }

  /* Exactly three tiles, in one row, which is what the CFP formats are after
     the panel came out. Same reasoning as --quad above: a set of alternatives
     should read as one row rather than as a feed. Three tiles left in --quad
     would sit in three of four tracks and the fourth would read as a card
     somebody forgot.

     Three is also the count that cannot fold to two without orphaning one, so
     this variant skips the two-column step --quad and --sextet share and goes
     straight to a single column at the same 60rem width. */
  .tile-grid--trio {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  @media (max-width: 60rem) {
    .tile-grid--trio { grid-template-columns: minmax(0, 1fr); }
  }

  /* Exactly six tiles, in two rows of three. Same reasoning as --quad above,
     and the same failure avoided: base .tile-grid fits four 17rem tracks
     across the shell and no more, so six cards land as a row of four and an
     orphaned pair, which reads as a mistake rather than as a set. Three and
     three reads as one block.

     Two by three next, then one column, which is the ladder --quad already
     uses, so the two grids break at the same two widths instead of at four
     arbitrary ones. */
  .tile-grid--sextet {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  @media (max-width: 60rem) {
    .tile-grid--sextet { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }

  @media (max-width: 40rem) {
    .tile-grid--sextet { grid-template-columns: minmax(0, 1fr); }
  }

  .card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    /* The padding is a property rather than a literal because .card__plate has
       to bleed back out through it by exactly the same amount, and --pad-lg
       changes it. Two numbers that must agree is one number too many. */
    --card-pad: var(--s-5);
    padding: var(--card-pad);
    border: var(--bw) solid var(--rule);
    background: transparent;
    transition:
      border-color var(--dur) var(--ease),
      translate var(--dur) var(--ease);
  }

  /* THE RULE THAT DRAWS ITSELF ALONG THE TOP EDGE ON HOVER.

     A colour change on a one-pixel hairline was the entire hover state of the
     largest and most numerous component on the site, sixty-five of them across
     the eighteen pages and twenty on visit-doon alone. --rule to --rule-strong
     is a step of twelve points of lightness on a single pixel. It is a real
     change and it is very nearly an invisible one, which is a bad trade for the
     biggest surface a reader can point at.

     What this adds is a two-pixel accent rule that draws across the top edge,
     left to right, in 300ms. It is the same device as the contour divider and
     the same device as the scroll-progress bar in the header: a rule appearing
     along an edge is how this site says something is happening. A card that
     lifts and glows is how every other site says it.

     scale on the inline axis, not inline-size, so it runs on the compositor and
     never touches layout. transform-origin is `0 50%` rather than a logical
     keyword because transform-origin has no logical values at all: `inline-start`
     there is not a value, the declaration is dropped as invalid, and the rule
     silently grows from its middle outwards instead. A right-to-left locale
     would need a `[dir="rtl"]` override; the site is one language and does not
     have one.

     The hover lift is `translate`, not `transform`, and that is load-bearing.
     A card can also wear .reveal, whose entrance owns `transform`. Two rules
     writing the same property is one of them losing; two independent transform
     properties compose, so a card that is still rising into place and is
     pointed at does both.

     Inset by -1px on three sides so it covers the card's own border rather than
     sitting inside it. pointer-events: none is not optional: ::after is the
     card's last child in paint order, so it lands on top of the stretched
     .card__link::before hit area and would otherwise swallow every click on the
     top two pixels of the card. */
  .card::after {
    content: "";
    position: absolute;
    inset-block-start: calc(var(--bw) * -1);
    inset-inline: calc(var(--bw) * -1);
    block-size: var(--bw-strong);
    background: var(--accent);
    scale: 0 1;
    transform-origin: 0 50%;
    transition: scale var(--dur) var(--ease);
    pointer-events: none;
  }

  @media (hover: hover) and (pointer: fine) {
    .card:has(a:hover),
    .card--link:hover {
      border-color: var(--rule-strong);
      /* One pixel, and it is the only place on the site where a box moves under
         the pointer. Two would start to look like a shadow is missing. */
      translate: 0 -1px;
    }

    .card:has(a:hover)::after,
    .card--link:hover::after {
      scale: 1 1;
    }
  }

  /* Cards are the largest tap targets on the site and there are sixty-five of
     them across the eighteen pages, so a press that produced nothing was most of
     the surface area of the page feeling dead. --accent rather than hover's
     --rule-strong, because the press is a commitment and the hairline is the
     only thing a card has to say it with. (This comment used to say twenty-six
     on the home page. Counted on 2 September 2026: six on the home page, twenty
     on visit-doon, sixty-five in total.) */
  .card:has(a:active),
  .card--link:active {
    border-color: var(--accent);
    translate: 0 0;
  }

  /* The top rule appears instantly on a press instead of drawing, because a tap
     lasts about 100ms and a 300ms draw would be a third of the way across the
     card by the time the finger left it. Touch has no hover state to have drawn
     it already. */
  .card:has(a:active)::after,
  .card--link:active::after {
    scale: 1 1;
    transition-duration: 0s;
  }

  .card__eyebrow {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  /* A mark in front of the eyebrow, for the case where a run of cards all carry
     the SAME eyebrow and differ only in their titles. Six village cards on the
     home page every one of which says VILLAGE is that case exactly: the word
     is doing no work, and a cpu, a car, a padlock, a magnifier, a pair of
     braces and a wifi arc tell you which floor is which before you have read a
     title.

     Red, and the same 0.875rem as the marks on the hero's terms in home.css.
     Same idea, same size, deliberately: tokens.css gives red the labels and an
     eyebrow is a label. If a third place ever wants this, it should become a
     token rather than a third literal. */
  .card__eyebrow .icon {
    --icon-size: 0.875rem;
    color: var(--brand);
  }

  .card__title {
    font-size: var(--fs-lg);
    font-weight: var(--fw-medium);
    letter-spacing: var(--track-body);
    line-height: var(--lh-snug);
    color: var(--ink);
    text-wrap: balance;
    transition: color var(--dur-fast) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .card:has(a:hover) .card__title,
    .card--link:hover .card__title {
      color: var(--accent);
    }
  }

  .card:has(a:active) .card__title,
  .card--link:active .card__title {
    color: var(--accent-active);
  }

  .card__body {
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--ink-2);
    max-width: none;
  }

  .card__meta {
    margin-block-start: auto;
    padding-block-start: var(--s-3);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  /* The whole card becomes the click target via a stretched pseudo-element
     over a real anchor, so the accessible name and keyboard focus stay on the
     link itself rather than on a div with a click handler.

     ::before, not ::after, which leaves ::after free for .link-arrow. A card
     link that wants the arrow can then carry both classes rather than choose
     between the arrow and the click target. It is absolutely positioned
     either way, so it still paints over the card's text. */
  .card__link {
    background-image: none;
    color: inherit;
  }

  .card__link::before {
    content: "";
    position: absolute;
    inset: 0;
  }

  .card--pad-lg { --card-pad: var(--s-6); }

  /* A PHOTOGRAPH AT THE HEAD OF A CARD. Section 02 of visit-doon is the first
     place that wants one: six places, each with a picture and a paragraph.

     It bleeds back out through the card's padding so the image meets the card's
     own border on three sides, and only the bottom hairline of the box survives,
     because the other three would sit directly on top of the card's border and
     draw it twice. One line separating picture from text is the editorial
     treatment; four lines is a box inside a box.

     The bottom margin is the card's padding MINUS the gap the card's flex
     already puts between its children, so the space under the picture equals the
     space around the text instead of being that space plus twelve pixels. This
     is the second reason --card-pad exists.

     The pending state comes down with the box, through the two knobs on
     .plate__pending rather than by overriding it: a card plate is a third the
     width of a lead band, and a ridge drawn at full strength behind eleven
     pixels of mono type in a box that size is competing with the label instead
     of sitting behind it. The note line is dropped from the markup rather than
     hidden here, because ten plates each saying "Photograph to come." is a page
     apologising ten times. */
  .card__plate {
    margin-inline: calc(var(--card-pad) * -1);
    margin-block-start: calc(var(--card-pad) * -1);
    margin-block-end: calc(var(--card-pad) - var(--s-3));
    --pending-ridge: 76%;
    --pending-ridge-weight: 0.3;
  }

  .card__plate .plate__box {
    border-width: 0 0 var(--bw);
  }

  .card__plate .plate__pending {
    padding: var(--s-3);
  }

  /* COMPONENTS INSIDE .prose */

  /* The three reading pages set .prose, which is serif at --fs-lg with a
     generous leading, and a component dropped into that context inherits both.
     For a paragraph that is exactly right. For a component's own title it is
     not: a title in the body's family, at a size the body also uses, sitting in
     the body's own column, stops reading as a title at all.

     Two adjustments, and both are keyed on the context rather than added as a
     modifier to each component, because what makes them necessary is where the
     component landed and not which component it is. Sans on the titles, which is
     the site's rule everywhere else and only needs restating here. And the body
     copy up from --fs-sm to --fs-base, because --fs-sm against a --fs-lg page is
     a drop of a third in a document somebody may be reading in a hurry, and the
     code of conduct is not a page to make anyone squint at. One step down from
     the surrounding prose rather than none, so the title still leads.

     Written a moment before the first components landed in a .prose column, so
     it was a no-op for exactly one edit. It is load-bearing now: the code of
     conduct carries two ruled lists, one plain ruled list and a pair of cards
     inside .prose, and without this block their titles come out serif at the
     body's own size and stop reading as titles. Measured there at a 1280px
     window: titles sans at 21px, bodies serif at 17px against a 21px page. */
  .prose :is(.ruled-list__title, .card__title) {
    font-family: var(--font-sans);
  }

  .prose :is(.ruled-list__body, .card__body) {
    font-size: var(--fs-base);
    line-height: var(--lh-prose);
  }

  /* And a note for whoever reaches for .tile-grid--wide on a reading page: it
     asks 22rem a tile, and two of those plus a gutter is 736px against the 654px
     reading column, so auto-fit drops it to one stacked column and a pair of
     cards stops being a pair. Base .tile-grid asks 17rem and fits twice there.
     Found by measuring the code of conduct, not by reading this file. */

  /* CHIPS, status, always with a text label, never colour alone */

  .chip {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    padding: 0.25rem 0.625rem;
    border: var(--bw) solid var(--rule-strong);
    border-radius: var(--r-pill);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-2);
    white-space: nowrap;
  }

  .chip::before {
    content: "";
    inline-size: 0.375rem;
    block-size: 0.375rem;
    border-radius: 50%;
    background: currentColor;
    flex-shrink: 0;
  }

  .chip--neutral { color: var(--ink-3); border-color: var(--rule); }

  .chip--open {
    color: var(--status-open);
    border-color: color-mix(in oklab, var(--status-open) 40%, transparent);
  }

  .chip--warn {
    color: var(--status-warn);
    border-color: color-mix(in oklab, var(--status-warn) 40%, transparent);
  }

  .chip--closed {
    color: var(--status-closed);
    border-color: color-mix(in oklab, var(--status-closed) 40%, transparent);
  }

  /* Brand red rather than the accent blue, and the reason is not consistency for
     its own sake: a blue pill with a blue outline is the same hue as the buttons
     in the header, so a chip that cannot be clicked was reading as one that
     could. No chip is ever interactive, so no chip is ever blue. */
  .chip--brand {
    color: var(--brand);
    border-color: color-mix(in oklab, var(--brand) 40%, transparent);
  }

  .chip--flat::before { display: none; }

  /* For hexadecimal and code content. `0x03` must never be uppercased into
     `0X03`, the lowercase x is part of the notation, not styling. */
  .chip--code {
    text-transform: none;
  }

  /* the pending pulse */
  /* The selector is the whole design here. Two chips on the site are genuinely
     pending in the sense that a machine, not an author, decided so: main.js reads
     the call for papers dates out of content.js on every load, writes the state
     onto the root element, and rewrites the chip's label to match. So
     `[data-cfp-state="before"]` paired with `[data-cfp-chip]` is exactly the set
     of chips that say "not open yet" because today's date says so, and it was the
     one combination of those two existing attributes that nothing had used. No new
     class, no new attribute, and no JavaScript at all beyond what already runs.

     Sixty-six other chips on the site read as pending to a human and must not
     pulse. `chip--warn` is not the discriminator: it is also worn by scarcity and
     by advice, and a warning that a workshop has twelve seats is not a thing that
     is about to change on its own.

     WHY THE DOT AND NOT THE PILL. The first version of this swept a translucent
     band of currentColor across the whole chip. Measured, that is not affordable.
     `--status-warn` is 5.459:1 on --paper and 5.142:1 on --paper-sunk in the light
     theme, and washing the pill with 14 percent of its own ink takes those to
     4.478 and 4.267. The label is --fs-label, which is 11px, so the bar is 4.5:1
     and both of those fail it. Backing the wash off to 6 percent keeps 5.033 but
     is then invisible, and the contrast table earlier in tokens.css records
     --status-warn already being dragged from 62 percent lightness down to 52 to
     get over that bar once. Spending it again on decoration is not a trade worth
     making.

     The dot has nothing behind it but the chip's own background and nothing on
     top, so its opacity is free. It is also the only part of the chip that
     survives: initCfpStatus assigns textContent, which destroys any child
     element, and a pseudo-element is not a child.

     Losing the dot cannot lose meaning. The rule at the head of this section is
     that a chip always carries a text label and never colour alone, so the dot is
     already redundant by design, which is what makes it the safe thing to animate.

     Three iterations, not infinite. Long enough to say "this one is live, look
     here", short enough that it stops asking. An indefinitely pulsing element on
     an otherwise still page is the thing readers turn animation off to escape.

     Reduced motion needs no rule and this is a deliberate exception to the
     switch-opacity-off-by-name convention in tokens.css. That convention exists
     because an entrance starts at `opacity: 0`, so even a 1ms iteration shows one
     frame of invisible content. This keyframe never goes below 0.42 and the dot's
     resting opacity is 1, so the backstop in base.css clamping it to a single 1ms
     iteration leaves a solid dot, which is what the page looks like today. */
  :root[data-cfp-state="before"] .chip[data-cfp-chip]::before {
    animation: bsd-chip-pulse calc(var(--dur) * 4) var(--ease) 3 both;
  }

  @keyframes bsd-chip-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.42; }
  }

  @media print, (forced-colors: active) {
    :root[data-cfp-state="before"] .chip[data-cfp-chip]::before {
      animation: none;
    }
  }

  /* CHIP CLOUD */

  /* A wrapping bag of chips with no order and no hierarchy, used for the CFP
     topic tags. It is a real list so a screen reader announces how many there
     are, which is the only thing about the set that matters; the list markers
     and indent go, the flex gap does the spacing in both axes.

     `> li` resets the vertical rhythm that prose lists inherit, because here
     the row gap already handles it and the inherited margin would only offset
     the first chip of each wrapped row. */
  .chip-cloud {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .chip-cloud > li { margin-block-start: 0; }

  /* EMPTY STATE */

  /* Load-bearing. Most of this site's content does not exist yet: speakers,
     schedule, sponsors, villages. This component is the difference between
     "organised, announcing soon" and "unfinished". It gets a dashed hairline
     (visibly provisional), a mono title, and always a real next action,
     because an absence the reader can act on is not a gap. */
  .empty-state {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-4);
    padding: clamp(var(--s-5), 4vw, var(--s-8));
    border: var(--bw) dashed var(--rule-strong);
    background: var(--accent-wash);
  }

  .empty-state__title {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-2);
  }

  .empty-state__body {
    font-size: var(--fs-lg);
    line-height: var(--lh-snug);
    letter-spacing: var(--track-body);
    color: var(--ink);
    max-width: var(--measure-narrow);
    text-wrap: pretty;
  }

  .empty-state__action {
    margin-block-start: var(--s-2);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-3) var(--s-4);
  }

  /* The timing that sits beside a muted call to action. Tied to the button by
     aria-describedby in main.js, so it reaches a screen reader as part of the
     control rather than as an unrelated line of text after it. */
  .empty-state__note {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    color: var(--ink-2);
  }

  .empty-state--center {
    align-items: center;
    text-align: center;
  }

  .empty-state--center .empty-state__body { margin-inline: auto; }

  /* ACCORDION, built on <details> so it works with JS disabled */

  .accordion {
    border-block-start: var(--bw) solid var(--rule);
  }

  .accordion__item {
    border-block-end: var(--bw) solid var(--rule);
  }

  .accordion__trigger {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s-5);
    padding-block: var(--s-5);
    font-size: var(--fs-lg);
    font-weight: var(--fw-medium);
    letter-spacing: var(--track-body);
    line-height: var(--lh-snug);
    color: var(--ink);
    transition: color var(--dur-fast) var(--ease);
  }

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

    /* The mark follows the label. It did not before: the text turned accent
       under the pointer while the + stayed --ink-3, so half of the control
       answered and half of it sat there. On a row this wide the mark is the
       far end of the target and it is where the eye is when it decides to
       press. */
    .accordion__trigger:hover .accordion__mark { color: var(--accent); }
  }

  .accordion__trigger:active { color: var(--accent-active); }
  .accordion__trigger:active .accordion__mark { color: var(--accent-active); }

  /* THE MARK, AND WHY IT IS TWO PATHS RATHER THAN A CHARACTER.

     It used to be `content: "+"` on ::after, swapped for `content: "\2212"`
     once the item opened. That is exactly the substitution the theme toggle
     argues against a few hundred lines up: swapping one mark for another reads
     as a substitution, while rotating one out as the other rotates in reads as
     a movement, and a movement is what tells the eye that IT did that. There
     are eighty-nine of these across eleven pages. They are the most-pressed
     control on the site and every one of them was answering a press by cutting
     to a different glyph.

     So the vertical bar rotates onto the horizontal one. At ninety degrees the
     two are coincident and what is left is a minus drawn with the same stroke
     the plus was drawn with, because it IS the same stroke. Clockwise, so the
     top of the bar travels in the inline direction, the same way the header
     progress bar and the card's top rule draw.

     The bars span 5 to 19 in a 24-unit box, so 14 units, which at 1.25rem is
     11.7 CSS pixels. The mono + it replaces carried about eleven pixels of
     visible ink at --fs-xl. The mark weighs the same on the page as the
     character did; the only thing that changed is that it can move.

     transform-box: view-box is what makes 50% 50% mean the centre of the
     drawing and not the centre of the path's own tight bounding box, which for
     a vertical line is a zero-width rectangle centred on the line itself. Both
     land on 12,12 here, so it reads as pedantry right up until someone moves a
     bar off centre.

     Written inline rather than pulled from the sprite, for the reason the theme
     toggle and the nav toggle are inline: the halves need their own selectors
     and a <use> tree cannot be reached by one. Only inherited properties cross
     that boundary, and `rotate` is not one of them.

     Nothing here needs a reduced-motion branch. --dur collapses to 1ms in that
     block, so the rotation still happens and the open state is still a minus;
     it simply arrives instantly. That is the correct behaviour rather than a
     lucky one: the mark is state, not decoration, and a reader who asked for
     less motion still has to be able to see which answers are open. */
  .accordion__mark {
    --icon-size: 1.25rem;
    align-self: start;
    color: var(--ink-3);
    transition: color var(--dur-fast) var(--ease);
  }

  .accordion__mark-bar {
    transform-box: view-box;
    transform-origin: 50% 50%;
    transition: rotate var(--dur) var(--ease-out);
  }

  .accordion__item[open] > .accordion__trigger .accordion__mark {
    color: var(--accent);
  }

  .accordion__item[open] > .accordion__trigger .accordion__mark-bar {
    rotate: 90deg;
  }

  .accordion__panel {
    padding-block-end: var(--s-5);
    padding-inline-end: clamp(0rem, 8vw, 8rem);
    font-size: var(--fs-base);
    line-height: var(--lh-body);
    color: var(--ink-2);
  }

  .accordion__panel > * + * { margin-block-start: var(--s-4); }

  .accordion__panel a {
    color: var(--accent);
  }

  /* opening smoothly

     A <details> has always snapped open, because the browser flips its content
     from `display: none` and no height was ever animatable to or from `auto`.
     Every smooth accordion on the web has historically been JavaScript
     measuring a pixel height and writing it back. This one is CSS.

     Three separate things had to land for that to be possible, and all three
     are now in our floor:

       ::details-content   a pseudo-element for the part a <details> reveals,
                           which is the thing that needs a height at all;
       interpolate-size    permission to interpolate to the keyword `auto`,
                           granted once on :root in base.css;
       allow-discrete      lets content-visibility flip at the right END of the
                           transition instead of instantly, without it the
                           content is either invisible while opening or still
                           painted while closing.

     The whole block is behind @supports on all three. A browser missing any of
     them keeps today's instant open, which is a working accordion, not a
     broken one. That is the only acceptable fallback for a FAQ: the answer
     must appear, and how it appears is a detail.

     overflow: hidden is what makes the height mean anything; without it the
     content spills out of the collapsing box and the panel appears to close
     around text that is still sitting on the page. */
  @supports (interpolate-size: allow-keywords) and
            (transition-behavior: allow-discrete) and
            selector(details::details-content) {
    .accordion__item::details-content {
      block-size: 0;
      overflow: hidden;
      transition:
        block-size var(--dur) var(--ease),
        content-visibility var(--dur) var(--ease) allow-discrete;
    }

    .accordion__item[open]::details-content {
      block-size: auto;
    }
  }

  /* PULL QUOTE */

  .quote {
    padding-inline-start: var(--s-5);
    border-inline-start: var(--bw-strong) solid var(--accent);
  }

  .quote__text {
    font-family: var(--font-serif);
    font-size: var(--fs-2xl);
    line-height: 1.24;
    letter-spacing: -0.01em;
    color: var(--ink);
    text-wrap: pretty;
  }

  .quote__cite {
    display: block;
    margin-block-start: var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    font-style: normal;
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  /* STATEMENT, one line lifted out of a section and set large */

  /* The design brief's fix for a page that reads as a wall: pull the one sentence
     a section is actually about, set it big, and let it break the column of
     paragraphs. It is NOT .quote, and the difference is the whole point of having
     both.

       .quote      somebody said this. Accent bar down the left, a mono
                   attribution under it, sits beside the prose in its own column.
       .statement  the page says this. No bar, no attribution, no quotation
                   marks, and it interrupts the prose rather than accompanying it.

     Rules above and below rather than a bar at the side, because a horizontal
     rule is what stops a reading eye and a vertical one is what a reading eye
     reads past. That is also why nothing here is centred: the line starts on the
     same left edge as the paragraphs it interrupts, so it reads as the same voice
     raised, not as a caption that wandered in.

     No max-width on the wrapper. A statement is meant to be able to run wider
     than the measure of the prose around it, and the line length is controlled on
     the text instead, where it belongs.

     A named container, because the type size below has to answer to the width of
     this box and not to the width of the window. The two come apart badly: the
     first statement on the site sits in the prose column of the about split,
     which is 557px at a 1280px viewport, so a viewport-scaled size that is
     correct across a full-width section is far too large there. Named rather than
     anonymous so the query cannot be captured by some future container between
     here and the text. */
  .statement {
    container: statement / inline-size;
    margin-block: clamp(var(--s-7), 4.5vw, var(--s-9));
    padding-block: clamp(var(--s-5), 2.2vw, var(--s-7));
    border-block: var(--bw) solid var(--rule);
  }

  /* An optional eyebrow. Mono, uppercase and accent, matching .section-head's
     own eyebrow exactly, because a skimmer who has learned that small accent mono
     means "you are here" should not have to learn a second vocabulary halfway
     down a page. */
  .statement__mark {
    display: block;
    margin-block-end: var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--accent);
  }

  /* Serif at --fs-2xl, which is deliberately one step under .h2's --fs-3xl and
     exactly .h3's size. The family is what keeps it out of the heading hierarchy:
     every heading on this site is sans, so a serif line at heading size reads as
     raised body copy and never as a heading a screen reader failed to announce.

     44ch, not the 68ch measure. At this size 68ch is four or five lines and turns
     back into the paragraph it was supposed to break; 44ch lands on two or three,
     which is the shape that reads as a graphic. The cap only bites in a wide
     container, though, which is what the container query underneath is for. */
  .statement__text {
    font-family: var(--font-serif);
    font-size: var(--fs-2xl);
    line-height: 1.22;
    letter-spacing: -0.014em;
    color: var(--ink);
    max-width: 44ch;
    text-wrap: balance;
  }

  /* One step down the same scale when the box is narrow. The threshold is
     measured rather than guessed, and the measurement is worth writing down
     because it is not the number the eye expects: across four rendered
     specimens, a serif character in this line averages 0.42em of advance, not
     the 0.5em a monospace habit suggests. Holding a sentence of about 130
     characters to three lines therefore needs roughly 44 characters a line,
     which is 18.6em, which is 47rem at --fs-2xl's desktop value. So a container
     under 48rem cannot do it at the larger step, and both places this component
     appears are under it: the prose column of the about split is around 35rem,
     and the reading measure of a legal page is around 41rem. In both the same
     sentence ran to five lines and became the paragraph it was lifted out of.
     --fs-xl recovers three.

     A token swap rather than a new clamp on purpose. Writing clamp() with a cqi
     term here would size beautifully and would also be a font-size authored
     outside tokens.css, which the top of that file forbids for good reason. Two
     steps of an existing scale, chosen by available width, stays inside the
     contract. */
  @container statement (max-width: 48rem) {
    .statement__text { font-size: var(--fs-xl); }
  }

  /* For a statement sitting inside .section--sunk or .section--accent, where the
     surface change has already done the work of separating it and two rules would
     just be two rules. */
  .statement--bare {
    padding-block: 0;
    border-block: 0;
  }

  /* SECTION DIVIDER, a contour band */

  /* The design brief asks for section breaks that mean something instead of one
     more hairline, and for the meaning to come from the conference's own motif:
     "Security, above sea level". So this is a topographic map. Nested contour
     loops, seven hills, generated by docs/build-contour.py, which explains at
     length why a script draws them rather than a hand.

     It lives BETWEEN sections as a direct child of <main>, never inside one. That
     is the whole trick to it being full-bleed: every section on this site wraps
     its content in .shell, so anything placed outside a section is already the
     width of the viewport and needs no negative margins, no 100vw, and no
     scrollbar-width arithmetic to escape a container it was never in.

     mask-image and not <img>, and this is the load-bearing decision in the block.
     An <img> cannot inherit currentColor, so a themed drawing would have to be
     inlined into all 18 pages to be recoloured, and a data URI in CSS would be
     refused by the img-src directive in _headers. A mask fetches one self-hosted
     file, which img-src 'self' allows, and takes its colour from this stylesheet,
     so one file serves both themes and a token change carries. The file is
     painted black at graded opacity for exactly this reason: under a mask only
     the alpha channel survives, and that opacity ramp becomes tonal depth in
     whatever colour is set below. */
  .divider {
    /* No mask support means no divider at all. That is the graceful degradation
       the brief asks for, and it is the right way round: without the mask,
       background-color would paint a solid bar across the page, which is worse
       than the nothing that sections already survive without. */
    display: none;
  }

  @supports (mask-image: url("../img/contour.svg")) {
    .divider {
      display: block;
      /* 8.3vw is one twelfth of the viewport, which is the drawing's own 1440 by
         120. So at a 1440 viewport the band is exactly its natural size, and the
         clamp holds it readable on a phone and stops it becoming a section of its
         own on a very wide screen. */
      block-size: clamp(2.75rem, 8.3vw, 7.5rem);
      background-color: var(--rule-strong);
      mask-image: url("../img/contour.svg");
      mask-repeat: no-repeat;
      /* auto 100%, so the height is always used in full and a summit is never
         sliced off. Wider than the drawing, it centres and leaves bare air at the
         sides, which lands the contours on the shell's own 1440. Narrower, it
         crops the sides and keeps every loop the shape it was drawn, rather than
         squashing twelve hills into a phone. */
      mask-size: auto 100%;
      mask-position: center;
    }
  }

  /* Quieter still, for a divider between two sections that are already close
     together, and louder, for the one break on a page that is doing real work.

     The accent variant mixes toward transparent rather than setting an opacity,
     and that is not a stylistic preference: .reveal's own rules below set
     opacity: 1 on this element at a higher specificity than a single modifier
     class, so an opacity here would be silently overridden on exactly the
     dividers that animate. Mixing the colour cannot be undone by that. */
  .divider--faint { background-color: var(--rule); }

  .divider--accent {
    background-color: color-mix(in oklab, var(--accent) 55%, transparent);
  }

  /* The drawing-in. It rides the same .reveal observer as everything else, so it
     costs no new JavaScript, but it takes a wipe from the left instead of
     .reveal's rise and fade: a contour map that draws itself across the page
     reads as a survey, and a contour map that floats up reads as a banner.
     Hence the opacity and transform resets, which are undoing .reveal rather
     than disagreeing with it.

     Reduced motion needs nothing here. --wipe is a travel token, it is zeroed in
     tokens.css alongside every other one, and at zero the starting state IS the
     finished state, so a still band is painted on the first frame. That is the
     mechanism the token block describes and the reason components in this file do
     not re-declare the media query. */
  html.js .divider.reveal {
    opacity: 1;
    transform: none;
    clip-path: inset(0 var(--wipe) 0 0);
    transition: clip-path var(--dur-slow) var(--ease-out);
  }

  html.js .divider.reveal.is-inview {
    clip-path: inset(0 0 0 0);
  }

  /* A mask is a background, and print drops backgrounds; forced-colors replaces
     the palette wholesale and would paint this band in a system colour it was
     never designed in. Rather than leave a reserved empty strip on the page in
     either case, the divider goes entirely: both media already separate sections
     with the rules that are on them. */
  @media print, (forced-colors: active) {
    .divider { display: none; }
  }

  /* PLATE, the image system */

  /* A plate is a figure with a reserved box in it. One component covers all
     three kinds of image this site has, because the box does not care what is
     inside it:

       a photograph        supplied by the organisers, cropped to fill
       a drawing           hand-authored SVG, fitted with air around it
       nothing yet         a captioned pending state, which is a real state

     THE THIRD ONE USED TO BE THE POINT, and what happened to it is worth
     recording rather than deleting. This comment said for months that there was
     not a single photograph in the repository and would not be until the
     organisers handed files over. They have: thirty-four frames from the two past
     editions are encoded under assets/img/photos, and seven pages draw them.
     Every slot in that set filled without one layout moving, which is the whole
     claim the empty state was making, tested the only way it could be.

     So the pending state is not scaffolding that outlived its use. It is down to
     the slots where the answer is still genuinely nothing, and there is one of
     those left on the site: the third edition figure on the home page, for a
     conference that has not happened. Keep it. A designed empty state is what
     stops the next person filling that slot with something invented.

     The contract underneath is unchanged, and it is the one .person__portrait
     already keeps for the board headshots, built the same way and for the same
     reason, so there is one idea here rather than two: the box owns the aspect
     ratio, the photo is an optional child that paints over the fallback, and
     check-links.py fails the build if anyone wires up a filename that is not
     there. That last clause is now load-bearing rather than theoretical, because
     there are 214 files it can catch a typo against.

     Ratio comes through a custom property rather than a class per shape, so a
     one-off can set --plate-ratio inline in a section stylesheet without a new
     modifier here. 3:2 is the default because that is what a camera hands you. */
  .plate {
    /* figcaption carries its own margin-block-start, so no gap here: two
       spacing mechanisms for one gap is how a component starts drifting. */
    display: block;
  }

  .plate__box {
    position: relative;
    aspect-ratio: var(--plate-ratio, 3 / 2);
    overflow: hidden;
    background: var(--paper-sunk);
    border: var(--bw) solid var(--rule);
  }

  .plate--16x9 { --plate-ratio: 16 / 9; }
  .plate--4x3  { --plate-ratio: 4 / 3; }
  .plate--1x1  { --plate-ratio: 1; }
  /* 2:1 is here for one photograph and the reason is arithmetic rather than
     taste. The arrival frame is a 2:1 master. A half-width plate is 706 CSS
     pixels, which wants 1412 device pixels on a two-times display, and the
     widest file the feature ladder writes is 1280, so it is already being
     stretched 1.10 times in its own shape. Forcing it into 3:2 crops the sides
     away and stretches what is left by 1.47 times; 16:9 lands at 1.24. Giving it
     the shape it was taken in is the cheapest of the three, and it is the only
     one where the queue still reaches both walls. */
  .plate--2x1  { --plate-ratio: 2; }
  /* The two portrait shapes, and they are here because a camera turned sideways
     produces them and nothing else about the plate needs to change. 2:3 is what a
     full-frame body writes on its side, which is what the five rotated frames in
     the past-edition set are; 3:4 is the gentler crop for a head-and-shoulders
     that would otherwise leave a lot of ceiling.

     A portrait plate in a three-column row is about 461 by 691 CSS pixels at the
     widest the shell gets, which is tall. Use one deliberately, next to landscape
     neighbours, rather than filling a whole row with them: three portraits side by
     side read as a passport page. */
  .plate--2x3  { --plate-ratio: 2 / 3; }
  .plate--3x4  { --plate-ratio: 3 / 4; }
  /* A letterbox band, for a wide drawing or a landscape that is doing the work
     of a section rule. */
  .plate--21x9 { --plate-ratio: 21 / 9; }
  /* Wider still, and this one is a measurement rather than a preference: 36:5 is
     1440 by 200, which is ridge.svg's own viewBox. It lives here as a modifier
     instead of as a one-off in home.css because the elevation band is not the
     only place that draws ridge.svg in a plate: the styleguide has to be able to
     show the same thing, and a ratio that only exists inside one page's
     stylesheet is a ratio the styleguide cannot reach. */
  .plate--36x5 { --plate-ratio: 36 / 5; }

  /* Absolutely positioned and z-indexed above the pending layer, for exactly the
     reason spelled out on .person__photo: the readable place to write the image
     is as the first child, which is also the place that would paint it behind
     its own fallback. Measured there, so not repeated as a guess here.

     --plate-focus exists because the interesting part of a photograph is
     hardly ever its centre. A room shot wants 50% 40%, a ridge wants 50% 65%. */
  .plate__img {
    position: absolute;
    inset: 0;
    z-index: 1;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    object-position: var(--plate-focus, 50% 50%);
  }

  /* THE THREE CROPS THAT ARE NOT CENTRED, and they are classes rather than numbers
     written on the element because _headers sets style-src 'self' with no
     'unsafe-inline', so no page on this site may carry a style attribute at all.
     A ratio modifier could not carry these: two photographs in the same 21:9 band
     want opposite ends of their own frames.

     The names are the percentages because anything else would be a guess about
     what the number means later. All three were measured off the photograph rather
     than chosen: 10 is a stage where the slide's top edge sits at 3% of frame
     height and the bottom fifth is empty chairs, and 35 is a lectern shot whose
     subject starts at 19%, where a centred band leaves 25 pixels above the head
     and this one leaves 79.

     65 IS TWO PORTRAIT PHOTOGRAPHS OF PLACES, AND ONE NUMBER FITS BOTH, which is
     the only reason it is one class and not two. Each of them is taller than the
     3:2 tile it sits in, so the browser drops a window on it and this chooses where
     the window lands. The stepped weirs put four spillway lips at 64%, 69%, 74% and
     82%; the centred window holds three and cuts the fourth, and the 65 window
     holds all four and the stone bathing steps below them. The gorge ends its
     centred window 16 pixels under the feet of the group wading through it, which
     reads as an accident, and the 65 window leaves 170 with the overhanging wall
     still in frame. Both crops were rendered at the width they ship at and looked
     at, because none of that arithmetic tells you whether the result is a
     photograph anybody would want to look at.

     IT WAS THREE UNTIL 6 SEPTEMBER 2026, and the third left by replacement rather
     than by retuning. It was a hill road above Landour, 2:3 in a 3:2 plate, holding
     a figure at the railing between 66% and 76% of frame height: the centred window
     ended at 72% and cut them, and the 65 window ended at 81% and left 271 pixels
     of road under their feet. On instruction the photograph was swapped for a
     different hill road in mist, which is exactly 1.5 and therefore has no window
     to place at all, so the class came off the plate with the frame. A number
     measured against a photograph is only good for that photograph.

     THERE WERE FIVE OF THESE, AND TWO WERE MEASURED, WRITTEN AND THEN DELETED. A
     22 was cut for a lecture room whose standing questioner sits at 18% of frame
     height, and a 60 for the frame that is on the site for what is printed on the
     sponsor boards. Both went when their photographs were rendered at the exact
     crop they ship in and looked at. The questioner turned out to be a 3:2 master
     in a 3:2 box, so it is trimmed by a single pixel and nothing else. The sponsor
     boards need 81% of their frame height, which no band tighter than 16:9 can
     hold and which 16:9 already contains without being moved. Two dead classes
     whose comment still named the photographs that had stopped using them is a
     worse thing to leave behind than a short list, so this is the short list.

     EVERYTHING ELSE IS CENTRED EXCEPT ONE, and that is a finding rather than
     laziness: a 16:9 band cut out of a 3:2 room photograph keeps 84% of its height,
     and every head in this set sits below 18%, so the trim takes ceiling and floor.

     THE SIXTEEN PLACE PHOTOGRAPHS ON VISIT-DOON DID NOT CHANGE IT EITHER, AND THE
     TALLY BELOW WAS MEASURED IN THE BROWSER, because the version of it that stood
     here before was measured off the markup instead and came out wrong in a way
     worth naming. It counted seven frames as the exact ratio of their plate. Not one
     of the eleven in the filmstrip is, and the reason is .plate--drift: it makes the
     image 40 px taller than the plate, so the box those eleven fill is 382 by 294
     and its ratio is 1.30, not 1.50. A 3:2 photograph in a 1.30 box loses 13.3% of
     its width. Reading a ratio off a class name is how a whole strip came to be
     described as uncropped.

     WHAT IS ACTUALLY CROPPED, at the desktop layout. Four frames keep everything,
     99.7% and 99.8%, and all four are section 03 plates, which do not drift. Two of
     the drifting eleven keep 97.6% of their width, being 4:3 in a 1.30 box, and five
     keep 86.7%, being 3:2. Two lose height instead, 57.7% and 61.5%, and those are
     the pair the 65 above exists for. Two lose more width, 68.1% and 56.0%: the 56
     is the forest gate below, and the 68 is a market street whose centred window
     happens to drop both its far edges, one of which is the mosaicked board, so
     moving it would put a redaction back on screen. And the valley band is the one
     real vertical window among them: it does not drift either, and it keeps 64.2% of
     its height with the ridge, the cloud, the town and the road all inside the
     centred one. */
  .plate--focus-10 { --plate-focus: 50% 10%; }
  .plate--focus-35 { --plate-focus: 50% 35%; }
  .plate--focus-65 { --plate-focus: 50% 65%; }

  /* THE ONE THAT MOVES THE OTHER AXIS, and the x in the name is there so nobody has
     to guess which 84 this is. The three above are all `50% n%`, because a room
     photograph in a band only ever needs to choose a height. This one is `84% 50%`.

     It is a forest gate, 2.33 in a plate that is 1.30 once the drift band is added,
     so width binds and the visible window is 55.9% of the frame at the desktop
     layout and 53.5% at the narrow one. What has to be inside that window is the
     signboards at 42% and the gatehouse roof, whose right eave ends at 89.7%. The
     two constraints leave 77.3% to 95.2% at desktop and 78.5% to 90.3% narrow, and
     84 is in both: it shows 37% to 93% of the frame on a desktop and 39% to 92% on a
     phone, so the gate, the boards and the roof are all in frame at every width the
     page has. Centred showed 22% to 78% and cut the roof off the photograph.

     Read at three times the master's own resolution to place the two edges, because
     at the width it ships at the eave and the tree behind it are the same colour. */
  .plate--focus-x84 { --plate-focus: 84% 50%; }

  /* The drifting square crop needs a small move toward the second tikki while
     keeping the filled bun and serving leaf inside the frame. */
  .plate--focus-bun-tikki { --plate-focus: 58% 50%; }

  /* A drawing is not a photograph and must not be cropped like one. It gets the
     paper rather than the sunk surface, it fits rather than fills, and it keeps
     air on all four sides so the hairlines never touch the frame. */
  .plate--drawn .plate__box {
    background: var(--paper);
  }

  .plate--drawn .plate__img {
    padding: var(--s-5);
    object-fit: contain;
  }

  /* Artwork that already runs to its own edges, and so wants no frame around it
     and no surface under it. The elevation band on the home page is the case
     this exists for: its lines are authored to overhang the viewBox on both
     sides precisely so they leave the drawing rather than stop inside it, and a
     border would turn that into a line bumping into a wall. Everything else the
     plate gives it, the reserved box and the caption, it still wants. */
  .plate--bleed .plate__box {
    border: 0;
    background: none;
  }

  /* A PHOTOGRAPH, treated so it belongs to this brand rather than looking like it
     was dropped in from a camera roll. The design brief asks for black and white
     or lightly purple-toned on a dark ground, and the argument for doing it here
     rather than in the export is in docs/ARTWORK.md: a treatment baked into
     a JPEG cannot follow a token change, cannot differ between the two themes,
     and leaves the organisers without a clean original for a poster.

     ONE CLASS ON THE IMG DRIVES ALL THREE RULES: the isolation below, the filter,
     and the accent wash. :has() is what makes that work, and it is load-bearing
     rather than clever: the tint has to appear only when
     there is a photograph under it. A .plate with no img draws .plate__pending,
     and an accent wash over a pending state would tint a hairline box and two
     lines of mono type for no reason. Selecting on the presence of the photo
     means the empty state is untouched by every rule in this block.

     Both knobs are custom properties so a single plate that needs less can turn
     it down in a page stylesheet without a modifier here. */
  .plate__box:has(.plate__img--photo) {
    /* THERE IS DELIBERATELY NO BACKGROUND HERE, and the removal is the fix to a
       bug this rule carried from the day it was written until the day the first
       photograph arrived and made it execute.

       It used to say background: var(--ink), reaching for "the dark colour" through
       the token that happens to be dark in the light theme. --ink is the text
       colour, so it flips: in the dark theme it resolves to oklch(96% 0.004 85), a
       near-white, and the rule painted a white ground under a photograph on a black
       page. Invisible for as long as no plate had a photograph in it, which is why
       it survived. The moment one did, a lazily loaded image showed it as a white
       flash on the way in, and a photograph that failed to load would have left a
       white hole with alt text in it.

       Letting .plate__box's own --paper-sunk stand is better than correcting the
       token, because it is the surface .plate__pending already draws on. So a
       photograph that has not arrived yet looks exactly like the designed empty
       state sitting underneath it, in both themes, instead of looking like a
       different kind of nothing. The tint is unaffected: the img is inset: 0 with
       object-fit: cover, so it covers this box completely and the ::after below
       blends with the photograph rather than with anything behind it. */

    /* isolation, not decoration. mix-blend-mode below blends with its backdrop,
       and without an explicit stacking context here that backdrop is whatever
       the section behind the plate happens to be painting. isolate confines the
       blend to this box, so the tint reads the photograph and nothing else.
       overflow: hidden clips, it does not isolate. */
    isolation: isolate;
  }

  /* Not grayscale(1). Fully desaturated skin goes grey and dead, and the last
     eight percent of colour is what keeps a face looking like a person. The
     contrast nudge is there because desaturation flattens a photograph slightly
     and this puts the bite back without touching the midtones.

     THE CONTRAST NUDGE READS A TOKEN FOR ONE REASON: it exists because of the
     desaturation, so anything that turns the desaturation off has to be able to
     turn this off with it. visit-doon does exactly that, in pages.css. Leaving
     1.04 on an otherwise untouched photograph would be a 4 percent contrast boost
     with no argument behind it, which is a small thing to get wrong but is still
     not the original frame. Both fallbacks are the site's values, so the eight
     other pages that use this class are unchanged by the token existing. */
  .plate__img--photo {
    filter:
      grayscale(var(--photo-desaturate, 0.92))
      contrast(var(--photo-contrast, 1.04));
  }

  /* The accent pass, and it is deliberately faint. soft-light rather than
     multiply or color: multiply darkens everything and loses the shadow detail
     these photographs are mostly made of, whereas soft-light leans the midtones
     and shadows toward the hue and leaves the highlights close to neutral. That
     is the "lightly purple-toned" the brief describes, and it reads as a
     treatment rather than as a filter.

     var(--accent) rather than a hex, so the one accent hue on the site is the one
     hue on the photographs, in both themes, and a token change carries. */
  .plate__box:has(.plate__img--photo)::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    background: var(--accent);
    opacity: var(--photo-tone, 0.14);
    mix-blend-mode: soft-light;
    pointer-events: none;
  }

  /* Print drops the surface under the photograph and forced-colors mode replaces
     the palette wholesale, so in both the tint is at best pointless and at worst a
     wash over a photograph the user has asked to see plainly. The desaturation goes
     too: someone printing a page or running high contrast wants the photograph, not
     our reading of it.

     background: none still earns its line now that the rule above sets no
     background of its own: what it overrides is .plate__box's --paper-sunk, and a
     printer has no reason to lay down a panel of near-black behind an image that
     covers it anyway. */
  @media print, (forced-colors: active) {
    .plate__box:has(.plate__img--photo) { background: none; }
    .plate__box:has(.plate__img--photo)::after { content: none; }
    .plate__img--photo { filter: none; }
  }

  /* THE PENDING STATE. A hairline box, the site's own ridge as a faint texture
     along the bottom, and two lines of mono type saying what belongs here. It is
     the empty-state rule from the brief applied to imagery: designed, never
     "image placeholder", never a broken-image glyph.

     The texture is a background-image, which is the one place a background is
     the right choice rather than a compromise: backgrounds are dropped in print
     and under forced-colors, and a decorative texture is precisely what should
     disappear in both. The label survives, because it is text. */
  .plate__pending {
    position: absolute;
    inset: 0;
    display: grid;
    align-content: end;
    gap: var(--s-1);
    padding: var(--s-4);
  }

  .plate__pending::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    block-size: var(--pending-ridge, 62%);
    background: url("../img/ridge.svg") no-repeat center bottom / 150% auto;
    opacity: var(--pending-ridge-weight, 0.4);
    pointer-events: none;
  }

  .plate__pending-label,
  .plate__pending-note {
    position: relative;
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    /* The same halo the locator's names carry, in this surface's colour rather
       than that one's, and here for the same reason: eleven-pixel mono sitting on
       top of hairlines. The texture behind is a ridge, so its lines cross the
       label at whatever height the label happens to be, and a caption a drawn
       line runs through is the difference between a designed empty state and an
       unfinished one. Four passes, because two leave the diagonals thin. */
    text-shadow:
      0 0 2px var(--paper-sunk), 0 0 2px var(--paper-sunk),
      0 0 2px var(--paper-sunk), 0 0 2px var(--paper-sunk);
  }

  .plate__pending-label { color: var(--ink-2); }
  .plate__pending-note  { color: var(--ink-3); text-transform: none; letter-spacing: var(--track-mono); }

  /* THE DRIFT, a parallax inside the frame. Opt-in per plate, because a page
     where every image drifts is a page that will not hold still to be read.

     It is the same single idea as .section-head__eyebrow's label drift and it is
     built the same way, off a view-progress timeline with no scroll listener
     anywhere. What differs is what moves: the label drifts against the page and
     is free to leave its own box, whereas here the frame is part of the layout
     and must not move at all. So the thing inside it moves and the frame clips.

     WHY THE INSETS GO NEGATIVE. A box that exactly fills its frame cannot be
     translated without uncovering the surface behind it at one edge or the other.
     Growing it by the travel distance at both block edges first, and only then
     translating it within that slack, is the whole trick. Zero travel collapses
     the insets back to the frame, so the reduced-motion case is the original
     rule and not an approximation of it.

     IT MOVES WHATEVER IS IN THERE. With a photograph this is parallax on the
     photograph, which is what nearly every drifting plate on the site now holds.
     Where the slot is still empty it moves the pending state's ridge instead, so
     the rule needed no second edit when the files landed, which was the point of
     writing it against both children rather than against the img. The ridge's
     height is read through --pending-ridge rather than written here, so a plate
     that has turned the ridge down keeps its own value and still drifts by the
     same distance.

     Travel is a token so the motion policy switches this off by zeroing a length,
     which is the only thing that works on an animation that has no duration to
     shorten. base.css detaches the timeline as well. */
  @supports (animation-timeline: view()) {
    .plate--drift .plate__img,
    .plate--drift .plate__pending::before {
      animation: bsd-plate-drift linear both;
      animation-timeline: view(block);
      animation-range: entry 0% exit 100%;
    }

    .plate--drift .plate__img {
      inset-block: calc(var(--plate-drift) * -1);
      /* THE HEIGHT IS WRITTEN OUT BECAUSE `auto` MEANT THE WRONG THING HERE, and
         this line is the fix to a bug that could not show itself until the first
         photograph landed in a drifting plate.

         It used to say block-size: auto, on the reasoning that both block insets
         are set so auto is the height they imply, the frame plus twice the
         travel, and a literal would only repeat the sum. That is true of a
         normal box and it is not true of a replaced one. For an absolutely
         positioned replaced element, auto height means the intrinsic height,
         CSS 2.1 10.6.5 rather than 10.6.4, and the over-constrained inset is
         then ignored. So the img was laid out at its own aspect ratio and the
         insets bought nothing.

         It failed two different ways at once, both measured on visit-doon at
         1440 x 900 with the strip's 382 x 254.7 frame. A photograph whose ratio
         matches its frame came out 254.7 tall rather than 294.7, so at the far
         end of the travel it uncovered 21.5 px of the surface behind it, which
         is the exact thing the negative insets were added to prevent. A
         photograph taller than its frame came out taller still, 573 for a
         portrait, and covered the frame at every offset, but by then the img box
         was the photograph's own ratio, so object-fit had nothing left to fit
         and object-position had nothing left to choose: --plate-focus went
         inert and the frame showed the top of the photograph whatever the class
         said. That is why the fix belongs here and not on the plates.

         calc(100% + var(--plate-drift) * 2) is the sum the old comment claimed
         auto would produce. 100% resolves against the containing block, which
         for an absolutely positioned box is the padding box the insets are
         measured from, so the two agree by construction. Zero travel still
         collapses this to 100%, so the reduced-motion case is still the
         original rule rather than an approximation of it. */
      block-size: calc(100% + var(--plate-drift) * 2);
    }

    .plate--drift .plate__pending::before {
      inset-block-end: calc(var(--plate-drift) * -1);
      block-size: calc(var(--pending-ridge, 62%) + var(--plate-drift) * 2);
    }

    @keyframes bsd-plate-drift {
      from { translate: 0 var(--plate-drift); }
      to   { translate: 0 calc(var(--plate-drift) * -1); }
    }
  }

  /* The caption. figcaption is already mono, small and quiet from base.css; all
     that is added here is a measure, because a caption that runs the full width
     of a wide plate stops being a caption and starts being a paragraph. */
  .plate__caption {
    max-width: 68ch;
  }

  .plate__credit {
    color: var(--ink-3);
  }

  /* A hairline above, for a plate that closes a section rather than sitting
     inside its prose. This is the treatment the elevation figure carried as
     bespoke CSS in home.css before there was a component to put it in.

     .plates--ruled shares the rule rather than restating it, so a row of plates
     and a single plate sit at exactly the same distance from whatever is above
     them. Two selectors on one declaration block is the cheapest way to promise
     that; two blocks with the same three values in them is how the promise
     quietly stops being true. .partner-block--ruled joins them for that reason
     and not because a wall of logos is a plate. */
  .plate--ruled,
  .plates--ruled,
  .partner-block--ruled {
    margin-block-start: var(--s-7);
    padding-block-start: var(--s-6);
    border-block-start: var(--bw) solid var(--rule);
  }

  /* The mirror of it: a plate that opens a section body rather than closing one,
     so the space it needs is underneath. The value is --head-y, the token the
     section head itself uses, so a figure dropped between a head and a grid
     does not invent a third rhythm on the page. */
  .plate--lead {
    margin-block-end: var(--head-y);
  }

  /* Two or three plates in a row. minmax(0, 1fr) rather than 1fr, for the reason
     it is always minmax(0, 1fr) in this file: a grid item's automatic minimum
     size is its content, and one long caption word would otherwise widen a
     column and push the row into a horizontal scroll. */
  .plates {
    display: grid;
    grid-template-columns: repeat(var(--plates-cols, 2), minmax(0, 1fr));
    gap: var(--s-6) var(--gutter);
  }

  .plates--3 { --plates-cols: 3; }
  /* Four across, and it exists because one page has four photographs of the same
     thing and three-plus-one would read as a mistake. A quarter of the widest
     the shell gets is about 340 CSS pixels, so the 960-wide tile files still
     cover it at better than two times density, which is why four is the last
     column count worth having here rather than the first one that fits. */
  .plates--4 { --plates-cols: 4; }

  @media (max-width: 60rem) {
    .plates--3 { --plates-cols: 2; }
    .plates--4 { --plates-cols: 2; }
  }

  @media (max-width: 40rem) {
    .plates { --plates-cols: 1; }
  }

  /* PARTNER WALL, the marks of past sponsors and partners */

  /* Nine organisations backed the first two editions. Their logos arrived from
     three sources over two years in nine different palettes, and three of them
     are white artwork that renders as nothing on this site's light theme while
     four are near-black artwork that renders as nothing on its dark one. So
     there is no set of native-colour files that can be dropped into a themed
     page and left alone.

     One ink for all nine is the answer, and it is not a compromise: a wall of
     logos in nine palettes reads as a scrapbook, and a wall in one ink reads as
     a set of organisations that all backed the same conference. Which is the
     claim the page is making.

     THE INK IS currentColor AND THE ARTWORK IS A MASK. Same mechanism as
     .divider above and for the same reasons: an <img> cannot inherit a token, a
     data URI would be refused by img-src, and a mask is one self-hosted file
     that img-src 'self' allows and this stylesheet colours. docs/build-partners.py
     writes the nine files, each one its source's own alpha channel on a shared
     384 by 120 canvas, optically sized so a compact monogram and a long wordmark
     carry the same weight rather than the same width. Nothing is redrawn.

     --ink-2 and not --ink, because these are nine organisations that supported
     two conferences that are over. They are evidence on the page, not the
     argument of it. */
  .partner-wall {
    display: grid;
    grid-template-columns: repeat(var(--partner-cols, 3), minmax(0, 1fr));
    gap: var(--s-6) var(--gutter);
    margin: 0;
    padding: 0;
    list-style: none;
    color: var(--ink-2);
    --partner-w: 15rem;
  }

  /* The same reset .tile-grid needs, for the same reason: base.css spaces li + li
     for prose lists, and in a grid that lands as a step between rows. */
  .partner-wall > li + li { margin-block-start: 0; }

  /* Smaller marks for the wall on the home page, which is carrying the two
     edition plates above it and should not out-shout them. */
  .partner-wall--compact { --partner-w: 11rem; }

  /* A labelled wall, for the home page, where this is a block inside the editions
     section rather than a section of its own with a numbered head. The rule goes
     above the label and not between the label and the marks, because the label
     belongs to the wall. */
  .partner-block__label {
    margin-block-end: var(--s-5);
  }

  /* Grid items stretch by default, so each cell is already the height of its
     row and the mark only needs centring inside it. */
  .partner {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  /* THE NAME IS REAL TEXT AND IT IS THE FALLBACK, not a decoration.

     A masked div has no alt attribute to carry, so the organisation's name has
     to be an element. Making that element the fallback rather than hiding it
     unconditionally means the wall degrades into a legible list of names in
     three places where a mask cannot be painted: a browser without mask support,
     a printed page, since print drops backgrounds and a mask is a background,
     and forced-colors, which would repaint every mark in a system colour it was
     never drawn in.

     It is a component class and not .u-visually-hidden, because a utility that
     sometimes shows is not a utility. The names are set as they were published
     on 0x02's own sponsor page, not as guessed from a filename. */
  .partner__name {
    font-size: var(--fs-xs);
    line-height: 1.35;
    text-align: center;
  }

  .partner__mark { display: none; }

  @supports (mask-image: url("../img/partners/offsec.webp")) {
    .partner__mark {
      display: block;
      inline-size: min(100%, var(--partner-w));
      /* 16 / 5 is 512 by 160, the canvas build-partners.py writes. With
         mask-size: contain and the same ratio, the mask is downsampled at every
         width up to 16rem on a 2x screen, never stretched, and the optical sizing
         baked into the file survives to the glass. */
      aspect-ratio: 16 / 5;
      background-color: currentColor;
      mask-repeat: no-repeat;
      mask-size: contain;
      mask-position: center;
    }

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

    /* One line per organisation, alphabetical, which is the only ordering that
       carries no claim. There is no tier here, no edition and no amount, so any
       other order would imply a ranking this page is not making.

       Nine rules and not one rule reading a custom property, because style-src
       in _headers carries no 'unsafe-inline' and a per-item value would have to
       arrive as a style attribute. A tenth organisation is one line here and one
       list item in the page, which is cheap enough. */
    .partner--altered-security .partner__mark { mask-image: url("../img/partners/altered-security.webp"); }
    .partner--cloudsek        .partner__mark { mask-image: url("../img/partners/cloudsek.webp"); }
    .partner--hack-defender   .partner__mark { mask-image: url("../img/partners/hack-defender.webp"); }
    .partner--infosectrain    .partner__mark { mask-image: url("../img/partners/infosectrain.webp"); }
    .partner--iotsrg          .partner__mark { mask-image: url("../img/partners/iotsrg.webp"); }
    .partner--jsmon           .partner__mark { mask-image: url("../img/partners/jsmon.webp"); }
    .partner--leetsec         .partner__mark { mask-image: url("../img/partners/leetsec.webp"); }
    .partner--offsec          .partner__mark { mask-image: url("../img/partners/offsec.webp"); }
    .partner--upes-x-pivot    .partner__mark { mask-image: url("../img/partners/upes-x-pivot.webp"); }
  }

  /* attention falls off with distance */
  /* Point at one mark and it comes up to full ink; its neighbours follow part of
     the way, and the far corner of the wall does not move at all. Nothing else
     changes. No lift, no scale, no shadow, no border. A wall of nine logos that
     each jump under the cursor is the single most recognisable template tell in
     the genre, and it is also wrong for what this wall is: a mark is not a
     button, and making it behave like one implies a link that is not there.

     Weight is the honest variable. These marks are painted with
     `background-color: currentColor` on a mask, so the entire effect is one
     property on the list item, `color`, inheriting down to the paint. It touches
     none of the nine mask rules above and adds no new box.

     It also settles the question the .partner-wall comment leaves open. That
     comment argues for --ink-2 rather than --ink because these are nine
     organisations that supported two conferences that are over: evidence on the
     page, not the argument of it. That is right at rest and it is what this keeps.
     Full ink is what attention costs, not what the wall is worth by default.

     A NOTE ON THE PARAGRAPH ABOVE, which says nine rules rather than one rule
     reading a custom property, because style-src carries no 'unsafe-inline' and a
     per-item value would have to arrive as a style attribute. That is still true
     and this does not contradict it. A mask URL is a fact about an organisation
     and could only ever come from the markup, which is where CSP bites.
     --partner-ink is computed at the moment of the hover and written through the
     CSSOM with setProperty, which is not an inline style and is not covered by
     style-src at all. The same reasoning is written out at the reading-progress
     bar in main.js.

     The falloff is Euclidean in grid cells, computed in main.js: same cell 100
     percent, the mark beside it 58, the diagonal 41, two along 17, and nothing
     past about two and a half cells. Row and column come from the wall's RESOLVED
     column count, read back out of getComputedStyle, and not from a constant. The
     wall is three columns and folds to two at 60rem, so a hardcoded 3 would
     compute the wrong neighbours on every phone.

     The mix can only ever move toward --ink, which is to say toward more contrast
     than the resting state, so there is no reader for whom this is a downgrade
     and no @media (prefers-contrast: more) case to answer.

     Hover with no matching focus rule, which is usually the mistake base.css
     warns about. Not here: nothing in the wall takes focus. The list items are
     not links and hold no interaction, and the organisation's name is already in
     the accessibility tree whether or not a pointer is anywhere near it. There is
     nothing this reveals for a keyboard reader to be denied.

     Reduced motion needs no rule. base.css clamps transition-duration to 1ms, so
     the ink still changes and simply stops taking 300ms to do it, which is the
     right answer for feedback: the reader asked for less motion, not less
     response. */
  @media (hover: hover) and (pointer: fine) {
    .partner {
      color: color-mix(in oklab, var(--ink) var(--partner-ink, 0%), var(--ink-2));
      transition: color var(--dur) var(--ease-out);
    }
  }

  /* Nine marks fold three, two, one. Two columns is deliberate on a phone rather
     than one: at one column the wall is nine screens of scrolling for what is
     meant to read at a glance, and a mark at --partner-w is small enough that two
     fit a 375 viewport with air to spare. */
  @media (max-width: 60rem) {
    .partner-wall { --partner-cols: 2; }
  }

  /* Where the names come back. Both media replace something the mask depends on:
     print drops the background that IS the paint, and forced-colors substitutes
     the palette the paint is drawn from. A row of nine blank boxes is worse than
     a list of nine names, so the fallback the @supports block hid is restored
     and the marks go. Source order does this, not specificity: same layer, later
     rule, and neither at-rule adds weight. */
  @media print, (forced-colors: active) {
    .partner__mark { display: none; }

    .partner__name {
      position: static;
      width: auto;
      height: auto;
      margin: 0;
      overflow: visible;
      clip-path: none;
      white-space: normal;
    }
  }

  /* LOCATOR, a drawing, with the places on it named in HTML */

  /* valley.svg draws the land and not one word of text. The names are these
     elements, laid over the drawing at percentages that are the drawing's own
     coordinates. The file's comment argues the case at length; the short version
     is that a name is content, so it should be text a reader can select, a
     screen reader can announce, a translator can replace and a theme can
     recolour, and none of that is true of glyphs baked into an image.

     A phone cannot fit eleven readable labels across a 2:1 drawing without
     collisions. The canvas therefore keeps the desktop width and scrolls inside
     its viewport on narrower screens; the labels remain overlaid at their exact
     map coordinates at every breakpoint.

     THE BOX RATIO AND THE viewBox RATIO ARE THE SAME NUMBER, and they have to
     stay that way, because that identity is what makes a percentage here equal a
     coordinate there. valley.svg carries preserveAspectRatio of none so that a
     mismatch shows up as a stretched diagram rather than as a label pointing at
     the wrong hill. If the drawing is ever re-cut, both numbers move together. */
  /* Keep the full desktop composition at every viewport. On narrower screens,
     the map becomes a horizontally scrollable canvas instead of separating its
     labels into a legend, so every name remains attached to its location. */
  .locator__viewport {
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-inline: contain;
    scrollbar-gutter: stable;
    -webkit-overflow-scrolling: touch;
    touch-action: pan-x pan-y;
  }

  .locator__viewport:focus-visible {
    outline: var(--bw-focus) solid var(--focus);
    outline-offset: var(--s-1);
  }

  .locator__canvas {
    position: relative;
    min-inline-size: 60rem;
    aspect-ratio: 2 / 1;
  }

  .locator__box {
    position: absolute;
    inset: 0;
    background: var(--paper);
    border: var(--bw) solid var(--rule);
  }

  .locator__map {
    display: block;
    inline-size: 100%;
    block-size: 100%;
  }

  .locator__names {
    position: absolute;
    inset: 0;
    margin-block-start: 0;
    padding: 0;
    list-style: none;
    pointer-events: none;
  }

  /* base.css puts margin-block-start on li + li, which in a wrapped row pushes
     every name but the first down by 8px. Same reset the other horizontal lists
     in this file need. */
  .locator__names li + li { margin-block-start: 0; }

  /* Two kinds of name, and the difference is whether the thing has a point.
     A place does: a town, an airport, somewhere you can arrive at, so it gets a
     dot. A feature does not: a range, a river, a park, a road leaving the frame,
     so it gets type alone, one step quieter. */
  .locator__place,
  .locator__feature {
    position: relative;
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--ink-2);
  }

  .locator__feature { color: var(--ink-3); }

  .locator__place { padding-inline-start: var(--s-3); }

  /* currentColor, so the dot and its name can never disagree about which of the
     three ink levels this label is at. */
  .locator__place::before {
    content: "";
    position: absolute;
    inset-inline-start: 0;
    inset-block-start: 50%;
    translate: -50% -50%;
    inline-size: 5px;
    block-size: 5px;
    border-radius: 50%;
    background: currentColor;
  }

  /* The city. Full ink, the accent on the dot, and the only accent anywhere on
     the map, which is the same rule the elevation band keeps: one mark means
     "you are here" and a second one would mean nothing. */
  .locator__place--city {
    color: var(--ink);
  }

  .locator__place--city::before {
    inline-size: 7px;
    block-size: 7px;
    background: var(--accent);
  }

  /* The venue, which shares the city's ink and deliberately not its accent. The
     rule directly above is the reason: a second accent would leave the map with
     two marks meaning "you are here" and therefore neither. Its dot stays the
     standard 5px too, because a larger one would imply a precision a schematic
     does not have. The coordinate below is interpolated from the labels either
     side of it, not measured. */
  .locator__place--campus {
    color: var(--ink);
  }

  /* Overlay labels remain part of the map on desktop, tablet and phone. */
  .locator__place,
  .locator__feature {
    position: absolute;
    translate: calc(var(--locator-flip, 0) * -100%) -50%;
    text-shadow:
      0 0 2px var(--paper), 0 0 2px var(--paper),
      0 0 2px var(--paper), 0 0 2px var(--paper);
  }

  .locator__place--flip,
  .locator__feature--flip { --locator-flip: 1; }

  .locator__place {
    padding-inline:
      calc((1 - var(--locator-flip, 0)) * var(--s-3))
      calc(var(--locator-flip, 0) * var(--s-3));
  }

  .locator__place::before {
    inset-inline-start: calc(var(--locator-flip, 0) * 100%);
  }

  .locator__feature--foothills { inset-inline-start: 3%;  inset-block-start: 6%; }
  .locator__place--mussoorie   { inset-inline-start: 35%; inset-block-start: 17%; }
  .locator__feature--yamuna    { inset-inline-start: 12%; inset-block-start: 18%; }
  .locator__feature--ganges    { inset-inline-start: 82%; inset-block-start: 20%; }
  .locator__place--campus      { inset-inline-start: 29%; inset-block-start: 25%; }
  .locator__place--city        { inset-inline-start: 35%; inset-block-start: 44%; }
  .locator__place--rishikesh   { inset-inline-start: 91%; inset-block-start: 55%; }
  .locator__place--airport     { inset-inline-start: 64%; inset-block-start: 66%; }
  .locator__feature--rajaji    { inset-inline-start: 76%; inset-block-start: 86%; }
  .locator__feature--shivaliks { inset-inline-start: 3%;  inset-block-start: 88%; }
  .locator__feature--delhi     { inset-inline-start: 19%; inset-block-start: 92%; }

  /* FORM FIELDS */

  .field {
    display: flex;
    flex-direction: column;
    gap: var(--s-2);
  }

  .field__label {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-2);
  }

  .field__input {
    min-block-size: 2.75rem;
    padding: var(--s-3) var(--s-4);
    border: var(--bw) solid var(--rule-strong);
    border-radius: var(--r-1);
    background: var(--paper);
    color: var(--ink);
    font-size: var(--fs-base);
    transition: border-color var(--dur-fast) var(--ease);
  }

  /* Guarded like the rest, though this one is the mildest of the set: an input
     that keeps a slightly darker hairline after a tap is about to receive
     :focus-visible anyway, so nothing was misreported. Consistency is the
     reason, not a bug. */
  @media (hover: hover) and (pointer: fine) {
    .field__input:hover { border-color: var(--ink-3); }
  }

  .field__input:focus-visible {
    border-color: var(--accent);
    outline-offset: 1px;
  }

  .field__hint {
    font-size: var(--fs-xs);
    color: var(--ink-3);
  }

  .field__error {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-mono);
    color: var(--status-closed);
  }

  .field--invalid .field__input { border-color: var(--status-closed); }

  /* Validation styling is `:user-invalid`, never `:invalid`. `:invalid` matches
     an empty required field from first paint, so the contact form would load
     already marked in red before the visitor had typed one character.
     `:user-invalid` waits until they have actually interacted and left.

     It landed in Safari 16.5, one release above this site's 16.4 floor. That
     is acceptable here and nowhere else, because this is a cosmetic hint and
     nothing more: the `required` and `type` attributes do the enforcing, and a
     browser that never matches this selector still refuses to submit and still
     shows its own message. Do not move any real behaviour behind it. */
  .field__input:user-invalid { border-color: var(--status-closed); }

  /* the shake of a refused field */
  /* Try to send either form with something missing or malformed and the field
     that stopped it moves about four pixels, twice, decaying to nothing. It is a
     head shake. It says no in the place where the answer has to change, which is
     the one thing a message at the top of a form cannot do.

     WHAT FIRES IT is the `invalid` event, listened for in the capture phase on
     the form, and NOT a submit handler over a form with `novalidate`. That
     distinction is the whole design. `novalidate` would hand every rule the
     browser already enforces to our own JavaScript: the message, the focus, the
     announcement, `type="email"`'s parser, and the behaviour of a form whose
     script failed to load. `invalid` fires once per failing control during the
     browser's own validation, so the native bubble still appears, the first bad
     control still takes focus, the screen reader still says what is wrong, and a
     visitor with JavaScript off loses the shake and nothing else. The event does
     not bubble, which is why the listener is registered with capture true rather
     than on each input.

     The attribute goes on the input and not on .field, so the label and the hint
     stay put. What is being refused is the value, and a label that shakes along
     with it reads as the question being unsure rather than the answer being
     wrong. It is an attribute and not a class because that is how state written
     by main.js is spelled everywhere on this site, `data-pending` on the submit
     button and `data-cfp-state` on the root, with classes kept for variants that
     ship in the markup.

     Repeat submissions need the animation restarted, not started, which is the
     attribute / void offsetWidth / attribute idiom main.js already uses twice.
     The attribute is removed again on animationend, latched so it runs once, with
     a setTimeout fallback behind it.

     THE AMPLITUDE HAS TO BE A TOKEN, and this is not a preference. The universal
     backstop in base.css cannot switch an animation off, only clamp it, and what
     it clamps to is one iteration of 1ms. A hardcoded 4px would leave a reader who
     asked for no motion with a single instantaneous 4px jerk, which is worse than
     the effect it replaced. --shake is 0px inside the reduced-motion block, and at
     zero amplitude every keyframe below resolves to translateX(0px). The travel is
     zeroed rather than the animation named and removed, which is mechanism two of
     the two tokens.css declares.

     The shake is never the only signal. :user-invalid above paints the border in
     --status-closed at the same moment, because a failed submit sets the
     user-interacted flag, and the browser's own message says what to fix. Motion
     is the part that carries no information and it is allowed to be the part that
     disappears.

     EVERY invalid field shakes, not only the first, because :user-invalid already
     paints every one of them. Submitting this form empty fires four invalid events,
     for name, email, subject and message, and all four borders turn at once. A
     shake on the first alone would have the two signals disagree about how many
     answers are wrong. The browser still moves focus to the first, so where to
     start is answered by focus and not by which field moved. Measured at 0.3s the
     four run in unison rather than as a ripple, which reads as the form refusing
     once instead of four separate objections.

     Two rules above this one, .field__error and .field--invalid .field__input,
     have no markup anywhere on the site and did not gain any here. They are a
     manual error-message channel from before validation was left to the browser.
     Wiring them up is a different piece of work from a shake and was not done. */
  @keyframes bsd-field-shake {
    0%, 100% { transform: translateX(0); }
    15%      { transform: translateX(calc(var(--shake) * -1)); }
    30%      { transform: translateX(var(--shake)); }
    45%      { transform: translateX(calc(var(--shake) * -0.6)); }
    60%      { transform: translateX(calc(var(--shake) * 0.6)); }
    75%      { transform: translateX(calc(var(--shake) * -0.3)); }
    90%      { transform: translateX(calc(var(--shake) * 0.3)); }
  }

  /* Shorthand, because the name is a literal here. The longhand rule in home.css
     applies to an animation whose name arrives in a custom property, where `none`
     would be read as a fill mode. */
  .field__input[data-shake] {
    animation: bsd-field-shake var(--dur) var(--ease);
  }

  /* A required field is marked with a word, not an asterisk. An asterisk is a
     convention you have to already know, it is announced as "star" or dropped
     entirely depending on the screen reader, and at --fs-label it is about four
     pixels of ink. "Required" costs one word and needs no key. The `required`
     attribute is what enforces it; this is only the visible half. */
  .field__req {
    color: var(--ink-3);
    letter-spacing: var(--track-mono);
  }

  /* Two columns, so name/email and phone/subject pair off, collapsing to one
     at 44rem. That is a narrower breakpoint than any other grid on the site,
     deliberately: a 200px-wide text input is worse than a taller form. */
  .form {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s-5) var(--gutter);
    max-inline-size: 46rem;
  }

  .field--full { grid-column: 1 / -1; }

  @media (max-width: 44rem) {
    .form { grid-template-columns: minmax(0, 1fr); }
  }

  /* The submit row. Centred rather than baseline-aligned: the button and the
     privacy line beside it are different type sizes, and baseline-aligning a
     44px control to a 12px line drops the button visibly low. */
  .form__foot {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-4) var(--s-5);
  }

  /* <select> needs a wrapper of its own. ::before and ::after do not render on
     a replaced element, so the chevron cannot hang off the select itself.

     The chevron is two borders on a rotated empty box rather than an SVG or a
     background image. `img-src 'self'` would allow a real file, but a rotated
     square inherits currentColor and therefore themes itself for free, costs no
     request, and cannot go stale against the tokens the way baked artwork does. */
  .field__control {
    position: relative;
    display: flex;
  }

  .field__control .field__input { inline-size: 100%; }

  .field__control--select::after {
    content: "";
    position: absolute;
    inset-inline-end: var(--s-4);
    inset-block-start: 50%;
    inline-size: 0.45rem;
    block-size: 0.45rem;
    border-inline-end: 1.5px solid var(--ink-2);
    border-block-end: 1.5px solid var(--ink-2);
    transform: translateY(-70%) rotate(45deg);
    pointer-events: none;
  }

  select.field__input {
    appearance: none;
    /* Room for the chevron, so a long option label never runs underneath it. */
    padding-inline-end: var(--s-7);
    /* Safari lays an appearance:none select out on its own line-height, which
       leaves the control visibly shorter than the text inputs beside it. */
    line-height: var(--lh-snug);
  }

  /* In forced-colors mode the OS supplies its own control chrome and drops our
     border colours, the hand-drawn chevron goes with them, leaving a control
     with no affordance at all. Handing the select its native appearance back is
     the only way it stays recognisable as a select. */
  @media (forced-colors: active) {
    select.field__input {
      appearance: auto;
      padding-inline-end: var(--s-4);
    }

    .field__control--select::after { display: none; }
  }

  textarea.field__input {
    min-block-size: 9rem;
    /* Vertical only. A horizontal drag can push the textarea wider than its
       grid track and scroll the whole page sideways, the same failure the
       minmax(0, 1fr) rule guards against, reached by the visitor's mouse. */
    resize: vertical;
    line-height: var(--lh-body);
  }

  /* CHECKBOXES · used by the CTF team registration form on ctf.html, which is
     the only form on the site that has any. Two things are deliberate.

     The control is NOT `.field__input`. A checkbox is a replaced element whose
     own box is the hit target, so the padding and 2.75rem min-block-size that
     make a text input comfortable would draw a large empty square instead.

     It is given its native appearance back, and `accent-color` is the whole of
     the styling. That means the tick is the operating system's tick: it survives
     forced-colors mode, Windows high contrast and any zoom level with nothing
     to maintain. A hand-drawn box would need an ::after glyph, a :checked rule,
     its own focus ring and a forced-colors fallback, and would still be less
     recognisable than the control the visitor already knows.

     `appearance: auto` is load-bearing, not tidiness. base.css resets every
     control with `appearance: none` so that text inputs, selects and buttons
     start from nothing, correct for those three, fatal for a checkbox, which
     has no border or background of its own to fall back on. Without this line
     the box renders as seventeen transparent pixels: present, focusable,
     clickable, and completely invisible. The same reset is handed back to
     `<select>` a few rules above for a related reason.

     `.field--check` overrides the `flex-direction: column` it inherits from
     `.field` by switching to grid. Same specificity, and it wins on order.
     Keep it below `.field`. */
  .field--check {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    gap: var(--s-3);
  }

  /* A radio takes exactly the same treatment as a checkbox, for exactly the
     same reasons, so it shares the rule rather than repeating six
     declarations. The class name in the markup still says which control it is,
     which is what a reader of the HTML needs. */
  .field__check,
  .field__radio {
    appearance: auto;
    /* Nothing else needs handing back. base.css also clears the background and
       the border on every control, but a natively painted checkbox has neither
       in the UA sheet (it is drawn by the platform, not by CSS), so those two
       declarations resolve to the same values either way. */
    /* Sized directly: the default checkbox is around 13px and does not scale
       reliably from font-size. Nudged down by a fraction so it sits on the
       first line of the label rather than above it, in rem, because a
       percentage margin would resolve against the inline size. */
    margin-block-start: 0.15rem;
    inline-size: 1.05rem;
    block-size: 1.05rem;
    accent-color: var(--accent);
    cursor: pointer;
  }

  .field__check-label,
  .field__choice-label {
    display: block;
    color: var(--ink);
    cursor: pointer;
  }

  /* The hint inside a check label breaks to its own line. Inline, a 12px hint
     running on from a 16px label reads as one long line of clashing type. */
  .field__check-label .field__hint,
  .field__choice-label .field__hint {
    display: block;
    margin-block-start: var(--s-1);
  }

  /* The label is body copy, not the uppercase mono of `.field__label`, so the
     "(required)" marker beside it needs its own size to stay a marker. */
  .field__check-label .field__req,
  .field__choice-label .field__req {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
  }

  /* RADIO GROUPS · one on the site, "how are you playing" on ctf.html, and it
     is a <fieldset> because three radios sharing one question are a group and
     a group needs a name a screen reader announces once.

     A fieldset is not an ordinary box and needs three things undone. Its UA
     border and padding come off. Its `min-inline-size: min-content` comes off
     too, or a long hint inside it refuses to wrap and pushes the grid column
     wider than its track, which is the sideways-scroll failure again. And a
     <legend> is laid out by the browser inside the border it no longer has, so
     it is floated to make it behave like the block it looks like, then cleared.
     `inline-size: 100%` on a float is what stops the following content sitting
     beside it. */
  .field--choice {
    border: 0;
    padding: 0;
    margin: 0;
    min-inline-size: 0;
  }

  .field__legend {
    float: left;
    inline-size: 100%;
    padding: 0;
    margin-block-end: var(--s-3);
  }

  .field__choices {
    clear: both;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  /* Same two-column grid as `.field--check`, and it has to be repeated here
     rather than shared: `.field--check` is a modifier on the field itself,
     while a radio group has one field wrapping three of these rows. */
  .field__choice {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    gap: var(--s-3);
  }

  /* SCROLL REVEAL */

  /* The contract, and it matters: the VISIBLE state is the default. The
     hidden state applies only under `html.js`, which an inline script in
     <head> adds. With JavaScript disabled or broken, `.js` is never added,
     the hidden rules never match, and every word on the site is visible.
     Never invert this.

     148 elements across 11 pages wear this class, 31 of them on the home page,
     which makes it by far the most-used piece of motion on the site and the one
     worth getting right. It moved from --dur-slow to --dur-entrance on
     2 September 2026. --dur-slow was 620ms for a 14px rise, and against
     --ease-out that spent its last 186ms travelling under half a pixel. The
     duration is now the same one the hero entrance uses, which is also the
     correct relationship: a card rising into place and a hero line rising into
     place are one gesture at two sizes, and they should not have drifted apart.

     The delays below reference --stagger rather than repeating multiples of it
     as literals, which is what they used to be. That mattered the moment
     --stagger changed value.

     The rise now reads --lift instead of repeating 14px, and that was not a
     tidy-up. tokens.css described --lift as "deliberately equal to .reveal's"
     while the two numbers sat in different files with nothing holding them
     together, so the token could be raised and the most-used motion on the site
     would not move. That is exactly what a comment claiming equality is for and
     exactly what it cannot do. */
  html.js .reveal {
    opacity: 0;
    transform: translateY(var(--lift));
    transition:
      opacity var(--dur-entrance) var(--ease-out),
      transform var(--dur-entrance) var(--ease-out);
  }

  html.js .reveal.is-inview {
    opacity: 1;
    transform: none;
  }

  html.js .reveal--delay-1 { transition-delay: calc(var(--stagger) * 1); }
  html.js .reveal--delay-2 { transition-delay: calc(var(--stagger) * 2); }
  html.js .reveal--delay-3 { transition-delay: calc(var(--stagger) * 3); }
  html.js .reveal--delay-4 { transition-delay: calc(var(--stagger) * 4); }

  @media (prefers-reduced-motion: reduce) {
    html.js .reveal {
      opacity: 1;
      transform: none;
      transition: none;
    }
  }

  /* SITE FOOTER */

  .site-footer {
    background: var(--paper-sunk);
    border-block-start: var(--bw) solid var(--rule);
    padding-block: clamp(var(--s-7), 6vw, var(--s-9));
  }

  .site-footer__inner {
    width: min(100% - (var(--page-margin) * 2), var(--container));
    margin-inline: auto;
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: clamp(var(--s-6), 5vw, var(--s-8)) var(--gutter);
  }

  .site-footer__brand { grid-column: 1 / span 3; }
  .site-footer__nav   { grid-column: 4 / span 7; display: flex; gap: var(--gutter); flex-wrap: wrap; }
  .site-footer__contact { grid-column: 11 / span 2; }
  .site-footer__legal { grid-column: 1 / -1; }

  @media (max-width: 75rem) {
    .site-footer__brand   { grid-column: 1 / span 5; }
    .site-footer__nav     { grid-column: 7 / span 6; }
    .site-footer__contact { grid-column: 1 / span 5; }
  }

  @media (max-width: 60rem) {
    .site-footer__brand,
    .site-footer__nav,
    .site-footer__contact,
    .site-footer__legal { grid-column: 1 / -1; }
  }

  .site-footer__tagline {
    margin-block-start: var(--s-4);
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--ink-2);
    max-width: 34ch;
  }

  .site-footer__meta {
    margin-block-start: var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  /* Four columns since the footer started mirroring the header's nav groups
     (Programme · Attend · Take part · About). 7rem rather than 8rem so all
     four still sit on one row inside `grid-column: 4 / span 7`; below that
     they wrap two-and-two, which is why they are equal-width flex items and
     not a four-column grid. */
  .footer-col { flex: 1 1 7rem; }

  .footer-col__title {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    font-weight: var(--fw-regular);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
    margin-block-end: var(--s-4);
  }

  .footer-col__list {
    list-style: none;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  .footer-col__list li + li { margin-block-start: 0; }

  .footer-col__list a {
    font-size: var(--fs-sm);
    color: var(--ink-2);
    background-size: 0% 1px;
  }

  @media (hover: hover) and (pointer: fine) {
    .footer-col__list a:hover {
      color: var(--accent);
      background-size: 100% 1px;
    }
  }

  .footer-col__list a:active {
    color: var(--accent-active);
    background-size: 100% 1px;
  }

  .site-footer__mailto {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    margin-block-end: var(--s-5);
    color: var(--ink-3);
  }

  .site-footer__mailto .icon {
    --icon-size: 1rem;
    flex-shrink: 0;
  }

  .site-footer__email {
    display: inline-block;
  }

  /* Confirmation after the email is copied to the clipboard. */
  .site-footer__email[data-copied]::after {
    content: " copied";
    color: var(--status-open);
  }

  .social-list {
    list-style: none;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
  }

  .social-list li + li { margin-block-start: 0; }

  /* 2.75rem is 44px, which is the floor for a touch target in WCAG 2.5.5 and in
     Apple's own guidance, and it stays exactly that whatever goes inside it. The
     mark is 20px, so there is 12px of quiet on every side of it: the target the
     finger gets is comfortably larger than the thing the eye sees, which is the
     right way round.

     The box keeps its hairline. A row of four bare marks would float without
     the rule, and the bordered square is the same device the chips and cards
     use, so the footer still belongs to the rest of the site. What changed on
     2 September 2026 is only what sits inside the square. */
  .social-list__link {
    display: grid;
    place-items: center;
    inline-size: 2.75rem;
    block-size: 2.75rem;
    --icon-size: 20px;
    border: var(--bw) solid var(--rule);
    color: var(--ink-2);
    background-image: none;
    padding-block-end: 0;

    /* The mono type is for the two-letter fallback, and it is kept rather than
       deleted so that a link which has not been given a mark yet still reads
       correctly instead of setting "IG" in the body face. Once every link in the
       list carries an <svg>, these three declarations match no text and cost
       nothing. */
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-mono);

    transition:
      border-color var(--dur-fast) var(--ease),
      color var(--dur-fast) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .social-list__link:hover {
      border-color: var(--accent);
      color: var(--accent);
    }
  }

  /* These four boxes are the most-tapped thing in the footer and the one place a
     stuck hover was unmistakable: tap one on a phone, come back, and it was
     still outlined in accent as though it were selected. */
  .social-list__link:active {
    border-color: var(--accent-active);
    color: var(--accent-active);
    translate: 0 1px;
  }

  /* An arrow inside a button. It leans forward on hover, which is the same
     gesture .link-arrow already makes, so the two agree: a thing that takes you
     somewhere points where it is taking you. 3px matches .link-arrow exactly.

     The translate is on the icon, not the button, because moving the button
     under a finger that is already on it is how you get a mis-tap. */
  .btn__icon {
    --icon-size: 1rem;
    transition: translate var(--dur-fast) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .btn:hover .btn__icon { translate: 3px 0; }
  }

  .btn:active .btn__icon { translate: 1px 0; }

  .site-footer__legal {
    padding-block-start: var(--s-6);
    border-block-start: var(--bw) solid var(--rule);
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: var(--s-4) var(--s-6);
    font-size: var(--fs-xs);
    color: var(--ink-3);
  }

  .site-footer__legal p { max-width: 60ch; margin: 0; }

  .site-footer__legal p:last-child {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-4);
  }

  .site-footer__legal a {
    color: var(--ink-3);
    background-size: 0% 1px;
  }

  @media (hover: hover) and (pointer: fine) {
    .site-footer__legal a:hover {
      color: var(--accent);
      background-size: 100% 1px;
    }
  }

  .site-footer__legal a:active {
    color: var(--accent-active);
    background-size: 100% 1px;
  }

  /* CONSENT PANEL */

  /* The markup this styles is built by assets/js/consent.js and appended to
     <body>, rather than written into nineteen HTML files. Two modes share it:
     the first-visit banner, and the same panel reopened from the footer's
     Cookie settings control.

     It is a sheet in the bottom corner and not a full-screen interruption.
     Reading this site is not conditional on answering the question, and a
     modal that blocks the page while it asks is applying pressure. */
  .consent {
    position: fixed;
    inset-block-end: 0;
    inset-inline: 0;
    z-index: var(--z-toast);
    display: flex;
    justify-content: flex-end;
    padding: var(--gutter);
    /* Only the sheet itself takes clicks; the padding around it does not sit
       over the page swallowing them. */
    pointer-events: none;
  }

  /* An author `display` beats the UA stylesheet's [hidden] rule, so the
     attribute has to be honoured here or the panel would never be hidden. */
  .consent[hidden] { display: none; }

  .consent__inner {
    pointer-events: auto;
    inline-size: min(34rem, 100%);
    /* A notice asking a question must never be taller than the screen it asks
       on. MEASURED 30 August 2026, with no cap: the sheet stood 520px tall on a
       375x812 phone and 463px on a 1440x640 laptop, and in a 489x353 window its
       own top edge sat 138px above the viewport, putting the eyebrow and the
       heading off-screen with no way to reach them. Neither element scrolls and
       the wrapper is fixed, so nothing gave. The cap makes the sheet its own
       scroll container instead, so the question, both category descriptions and
       both buttons are reachable at any viewport height. Below roughly 560px of
       viewport the reader scrolls the sheet a little to reach the buttons,
       which is the trade for a heading that is no longer off the top of the
       screen. Above it nothing overflows and nothing scrolls.

       This cannot be a percentage. .consent sets inset-block-end and
       inset-inline but no inset-block-start, so its height is auto, and a
       percentage max-block-size resolved against an auto height computes to
       none. The vh line is the fallback for browsers without dvh, where a
       retracting mobile toolbar leaves the cap conservative rather than
       wrong. */
    max-block-size: calc(100vh - (var(--gutter) * 2));
    max-block-size: calc(100dvh - (var(--gutter) * 2));
    overflow-y: auto;
    overscroll-behavior: contain;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    padding: clamp(var(--s-5), 3vw, var(--s-6));
    border: var(--bw) solid var(--rule-strong);
    background: var(--paper-raised);
    box-shadow: var(--shadow-overlay);
    opacity: 0;
    transform: translateY(var(--lift));
    transition:
      opacity var(--dur) var(--ease),
      transform var(--dur) var(--ease-out);
  }

  .consent[data-open] .consent__inner {
    opacity: 1;
    transform: none;
  }

  /* The focus ring belongs to the sheet and not to the wrapper.
     openPanel moves focus to .consent so a screen reader announces the dialog
     with its title and description, and .consent is a full-bleed positioning
     box: inset-inline 0 on both sides, block-end flush with the bottom of the
     viewport. MEASURED 30 August 2026 at 1440x900: the ring drew its left,
     right and bottom edges outside the viewport and left only the top edge on
     screen, a 2px accent rule straight across the page 27px above the sheet,
     cutting through the hero headline. It reads as a rendering fault, and it
     was reported as one twice before this was found.

     Both rules are load-bearing. Taking the ring off the wrapper without
     giving one to the sheet would leave a sighted keyboard user with focus
     somewhere and no way to see where, which is the entire job of
     :focus-visible.

     This cannot be verified in the local preview pane. That pane's document
     never holds focus, so neither :focus nor :focus-visible ever match and no
     ring paints there in any state. Check it in a real browser, on a first
     visit with the panel opening by itself: a programmatic focus with no
     pointer interaction before it is the case that matches. */
  .consent:focus-visible {
    outline: none;
  }

  .consent:focus-visible .consent__inner {
    outline: var(--bw-strong) solid var(--accent);
    outline-offset: 3px;
  }

  .consent__eyebrow {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

  .consent__title {
    font-size: var(--fs-lg);
    line-height: var(--lh-snug);
    letter-spacing: var(--track-body);
    font-weight: var(--fw-medium);
  }

  .consent__body {
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--ink-2);
    text-wrap: pretty;
  }

  .consent__cats {
    display: grid;
    gap: var(--s-3);
    margin: 0;
    padding-block: var(--s-4);
    border-block: var(--bw) solid var(--rule);
  }

  .consent__cat dt {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink);
  }

  .consent__cat dd {
    margin: var(--s-1) 0 0;
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    color: var(--ink-3);
  }

  .consent__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
  }

  .consent__meta {
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    color: var(--ink-3);
  }

  .consent__meta a { color: var(--ink-2); }
  @media (hover: hover) and (pointer: fine) {
    .consent__meta a:hover { color: var(--accent); }
  }

  .consent__meta a:active { color: var(--accent-active); }

  /* One overlay at a time. The mobile menu is the thing the reader just
     touched, so the panel steps out of its way and is still there afterwards. */
  [data-nav-open] .consent { display: none; }

  /* The cookie control in the footer is a <button> and not an <a>, because it
     opens a panel on this page rather than navigating anywhere. Styled to sit in
     the same row as its neighbours without pretending to be a link. */
  .site-footer__legal button {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: var(--ink-3);
    cursor: pointer;
    text-decoration: underline;
    text-decoration-color: transparent;
    text-underline-offset: 0.25em;
    transition:
      color var(--dur-fast) var(--ease),
      text-decoration-color var(--dur-fast) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .site-footer__legal button:hover {
      color: var(--accent);
      text-decoration-color: currentColor;
    }
  }

  .site-footer__legal button:active {
    color: var(--accent-active);
    text-decoration-color: currentColor;
  }

  /* BODY SCROLL LOCK */

  /* Set by main.js while the mobile nav is open. Padding compensates for the
     removed scrollbar so the layout does not jump sideways. */
  [data-nav-open] body {
    overflow: hidden;
    padding-inline-end: var(--scrollbar-w, 0px);
  }

  /* VIEW TRANSITIONS */

  /* The navigation opt-in itself is in base.css, outside @layer, and the note
     there explains why. This block is about what the transition does.

     By default a cross-document view transition cross-fades the entire
     viewport, header included, which looks like a slide deck advancing. Naming
     the header and footer lifts them out of the page snapshot into snapshots of
     their own, so they hold still while the content between them changes. The
     logo does not blink, the nav does not blink, and the site reads as one
     document with sections rather than as fourteen separate pages, which is
     exactly what a publication should feel like.

     A view-transition-name must be unique per document. Both of these appear
     once per page, and they are the only two named elements on the site.

     Named elements are ALSO captured separately when a transition is started
     from script, which is why the header excludes itself while the theme wipe
     is running. See the next block. */
  :root:not([data-theme-wipe]) .site-header {
    view-transition-name: bsd-header;
  }

  :root:not([data-theme-wipe]) .site-footer {
    view-transition-name: bsd-footer;
  }

  /* the theme wipe

     Flipping the theme repaints every colour on the page at once. Done plainly
     that is a hard cut; done as a cross-fade it is a grey smear halfway
     between two themes. This is the third option: the new theme is revealed by
     a circle growing out of the toggle button that was just pressed, so the
     change appears to come from the control the reader used.

     main.js measures the button, writes the centre and a radius that reaches
     the furthest corner into --wipe-x/y/r, sets [data-theme-wipe], and calls
     startViewTransition. There is no fallback state to author: without
     startViewTransition the attribute is never set, the theme flips instantly,
     and nothing here matches.

     The old snapshot is held still and the new one is clipped, rather than the
     other way round. Wiping the old theme away would expose whatever is
     underneath it before the new paint arrives; growing the new theme over the
     old means every pixel is a finished pixel.

     clip-path over a scaled circle: an animated clip-path does not change the
     element's paint area, so the new theme's text stays pin-sharp at every
     frame. A scaled div would soften it. Both are composited; only one is
     honest at 100% zoom.

     Reduced motion never reaches here, main.js checks the preference before
     calling startViewTransition, and base.css removes the animations from the
     pseudo tree as a backstop even if it somehow did. */
  :root[data-theme-wipe]::view-transition-old(root) {
    animation: none;
  }

  :root[data-theme-wipe]::view-transition-new(root) {
    animation: bsd-theme-wipe var(--dur-slow) var(--ease-out) both;
  }

  @keyframes bsd-theme-wipe {
    from { clip-path: circle(0 at var(--wipe-x) var(--wipe-y)); }
    to   { clip-path: circle(var(--wipe-r) at var(--wipe-x) var(--wipe-y)); }
  }
}
