/* pages.css: section layouts for the subpages.

   Loaded by cfp.html, villages.html, sponsors.html, advisory-board.html,
   review-board.html, code-of-conduct.html and 404.html, in the same slot
   index.html gives to home.css. Exactly one page stylesheet loads per page, so
   these pages' section rules can share a file without any of them paying for
   the others.

   Same rule as home.css: if a pattern here starts appearing on the home page
   too, it graduates to components.css rather than being duplicated. `.split`,
   `.spec-list`, `.ruled-list`, `.page-hero` and `.cta-band` already made that
   trip and are NOT redefined here.

   @layer sections sits above components in the layer order, so a section may
   reposition a component without a specificity fight and without the override
   leaking to another page. */

@layer sections {

  /* CFP · TIMELINE */

  /* Four dates on one hairline. The whole CFP page hangs off this: a speaker
     deciding whether to submit wants to know where in the cycle they are, and
     a horizontal timeline answers that in one glance where a bulleted list
     needs a paragraph.

     The live step is marked from `data-cfp-state` on <html>, which main.js
     computes from the ISO dates in content.js. So the highlight moves by
     itself on 1 October 2026 and again on 26 December 2026, nobody edits a
     class, and the page cannot contradict its own chip. */
  .timeline {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--gutter);
    list-style: none;
    padding: 0;
    margin-block-start: var(--s-7);
    border-block-start: var(--bw-strong) solid var(--rule-strong);
    counter-reset: none;
  }

  .timeline__step {
    position: relative;
    margin-block-start: 0;
    padding-block-start: var(--s-6);
    display: grid;
    gap: var(--s-2);
    align-content: start;
  }

  /* The marker sits ON the shared rule rather than below it, a small solid
     square, the same mark the wordmark uses, so the visual language repeats. */
  .timeline__step::before {
    content: "";
    position: absolute;
    inset-block-start: calc(var(--bw-strong) * -1);
    inset-inline-start: 0;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    background: var(--rule-strong);
    transform: translateY(-50%);
    transition: background-color var(--dur) var(--ease);
  }

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

  .timeline__label {
    font-size: var(--fs-lg);
    font-weight: var(--fw-medium);
    letter-spacing: -0.015em;
    line-height: var(--lh-snug);
    color: var(--timeline-label, var(--ink));
  }

  .timeline__note {
    font-size: var(--fs-sm);
    line-height: var(--lh-body);
    color: var(--timeline-note, var(--ink-2));
  }

  /* --- the computed "you are here" state ----------------------------- */

  /* Before 1 Oct 2026 the opening date is the live step; while the window is
     open it is the closing date; once closed, the notification date. Each is a
     separate selector rather than a JS-applied class so the highlight is
     already correct in the very first painted frame. */
  :root[data-cfp-state="before"] .timeline__step--opens,
  :root[data-cfp-state="open"]   .timeline__step--closes,
  :root[data-cfp-state="closed"] .timeline__step--notify {
    box-shadow: 0 calc(var(--bw-strong) * -1) 0 0 var(--accent);
  }

  :root[data-cfp-state="before"] .timeline__step--opens::before,
  :root[data-cfp-state="open"]   .timeline__step--closes::before,
  :root[data-cfp-state="closed"] .timeline__step--notify::before {
    background: var(--accent);
  }

  :root[data-cfp-state="before"] .timeline__step--opens :is(.timeline__date, .timeline__label),
  :root[data-cfp-state="open"]   .timeline__step--closes :is(.timeline__date, .timeline__label),
  :root[data-cfp-state="closed"] .timeline__step--notify :is(.timeline__date, .timeline__label) {
    color: var(--accent);
  }

  /* Steps already behind us are quietened, but never struck through or hidden:
     a speaker who arrives late still needs to see the shape of the cycle.

     Quietening is done by stepping each line down one level in the ink
     hierarchy, NOT with `opacity: 0.55`, which is what this rule used to be.
     Opacity is the wrong instrument for text: it composites the glyphs toward
     whatever is behind them, so it silently took --ink-3 at 11px from 5.05:1 to
     2.45:1 and --ink-2 at 14px to 3.62:1. Both fail AA, and because this state
     is computed from the clock, the failure would have appeared by itself on
     1 October 2026 with nobody watching. Tokens can be measured; a multiplier
     against an unknown backdrop cannot.

     Setting custom properties rather than colours means the children keep their
     own single `color` declaration and there is no specificity fight here. */
  :root[data-cfp-state="open"]   .timeline__step--opens,
  :root[data-cfp-state="closed"] .timeline__step--opens,
  :root[data-cfp-state="closed"] .timeline__step--closes {
    --timeline-label: var(--ink-2);
    --timeline-note:  var(--ink-3);
  }

  /* THREE-COLUMN VARIANT, used by the CTF page. The base grid is four columns
     because the CFP cycle has four dates. The CTF has three stages, and a
     fourth empty column would read as a step somebody forgot to fill in.

     No `--live` state here: the CTF's dates are not confirmed, so there is
     nothing for the clock to highlight. When the qualifier date exists, the
     honest way to light a step is the CFP's, computed from an ISO date in
     content.js, never a class typed into the markup. */
  .timeline--3 {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* SPACE THE SIBLING, NOT THE TIMELINE. On cfp.html and speakers.html the
     timeline is the last thing in its section, which is why it carries a top
     margin and no bottom one. On ctf.html a .split follows it, and with no
     rule here the two would sit flush. Putting the margin on whatever comes
     next leaves the two pages where it ends a section byte-identical, and
     --head-y is the same token .section-head uses, so the vertical rhythm is
     the same. */
  .timeline + * {
    margin-block-start: var(--head-y);
  }

  @media (max-width: 60rem) {
    .timeline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  @media (max-width: 40rem) {
    .timeline {
      grid-template-columns: minmax(0, 1fr);
      gap: 0;
      border-block-start: 0;
    }

    .timeline__step {
      padding-block: var(--s-5);
      padding-inline-start: var(--s-5);
      border-block-start: var(--bw) solid var(--rule);
    }

    /* Vertical layout: the marker moves to the left edge and the accent
       highlight becomes a left border rather than a top one. */
    .timeline__step::before {
      inset-block-start: var(--s-6);
      transform: none;
    }

    :root[data-cfp-state="before"] .timeline__step--opens,
    :root[data-cfp-state="open"]   .timeline__step--closes,
    :root[data-cfp-state="closed"] .timeline__step--notify {
      box-shadow: inset var(--bw-strong) 0 0 0 var(--accent);
    }
  }

  /* CFP · SUBMISSION ROUTE */

  /* The platform link, the email fallback, and the honest admission that the
     deep link does not exist yet. Framed as a single bordered block because
     this is the one thing on the page a speaker must actually do. */
  .submit-box {
    display: grid;
    gap: var(--s-5);
    padding: clamp(var(--s-5), 4vw, var(--s-7));
    border: var(--bw-strong) solid var(--ink);
  }

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

  .submit-box__what {
    font-size: var(--fs-2xl);
    line-height: 1.1;
    letter-spacing: var(--track-2xl);
    font-weight: var(--fw-medium);
    color: var(--ink);
  }

  .submit-box__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-3);
  }

  /* VILLAGES */

  /* A numbered grid rather than a card wall. Each village is a name and a
     one-line description of what it is; there is no confirmed programme
     behind any of them yet, and a card implies more content than exists. */
  .village-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0 var(--gutter);
    list-style: none;
    padding: 0;
    margin-block-start: var(--s-7);
    border-block-start: var(--bw) solid var(--rule);
  }

  .village {
    margin-block-start: 0;
    padding-block: var(--s-5);
    border-block-end: var(--bw) solid var(--rule);
    display: grid;
    gap: var(--s-3);
    align-content: start;
  }

  .village__id {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--accent);
    font-variant-numeric: tabular-nums;
  }

  .village__name {
    font-size: var(--fs-xl);
    line-height: var(--lh-snug);
    letter-spacing: var(--track-xl);
    font-weight: var(--fw-medium);
    color: var(--ink);
    text-wrap: balance;
  }

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

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

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

  /* CTF */

  /* The one genuinely boastable fact about the CTF. Every challenge is tuned
     by the team, set as a pull quote's cousin: an accent left rule, large
     text, no box.

     Used on ctf.html. It lived on villages.html until that page existed, which
     is why this block sits between the villages and sponsors sections rather
     than in page order; the selectors are unscoped, so it does not matter. */
  .ctf-claim {
    padding-inline-start: var(--s-6);
    border-inline-start: var(--bw-strong) solid var(--accent);
  }

  .ctf-claim__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;
    max-width: var(--measure-lead);
  }

  /* SPONSORS · TIERS */

  /* Four columns, no prices, benefits described in KIND rather than quantity.
     The tier names are provisional and the page says so; what is not
     provisional is that there is a free tier for community groups, and giving
     it the same visual weight as the paid ones is the point. */
  .tier-cards {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--gutter);
    margin-block-start: var(--s-7);
    list-style: none;
    padding: 0;
  }

  .tier-card {
    margin-block-start: 0;
    padding-block-start: var(--s-5);
    border-block-start: var(--bw) solid var(--rule-strong);
    display: flex;
    flex-direction: column;
    gap: var(--s-4);
  }

  /* The headline tier gets the accent rule. That is the whole hierarchy, no
     "most popular" ribbon, no scaled-up column, no filled background. */
  .tier-card--lead {
    border-block-start-color: var(--accent);
    border-block-start-width: var(--bw-strong);
  }

  /* The community tier is free and in-kind, and marking it with a dashed rule
     says "different in kind, not lesser in status" without a word of copy. */
  .tier-card--community {
    border-block-start-style: dashed;
  }

  .tier-card__name {
    font-size: var(--fs-xl);
    line-height: 1;
    letter-spacing: var(--track-xl);
    font-weight: var(--fw-medium);
    color: var(--ink);
  }

  .tier-card--lead .tier-card__name { color: var(--accent); }

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

  .tier-card__list {
    list-style: none;
    padding: 0;
    display: grid;
    gap: var(--s-3);
    font-size: var(--fs-sm);
    line-height: var(--lh-snug);
    color: var(--ink-2);
  }

  .tier-card__list li {
    margin-block-start: 0;
    padding-inline-start: var(--s-4);
    position: relative;
  }

  /* A hairline dash rather than a bullet or a tick. A tick implies a feature
     matrix, and there is no feature matrix. These are examples, and the
     prospectus is the contract. */
  .tier-card__list li::before {
    content: "";
    position: absolute;
    inset-inline-start: 0;
    inset-block-start: 0.62em;
    inline-size: 0.5rem;
    block-size: var(--bw);
    background: var(--rule-strong);
  }

  .tier-card__foot {
    margin-block-start: auto;
    padding-block-start: var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
  }

  @media (max-width: 68rem) {
    .tier-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }

  @media (max-width: 40rem) {
    .tier-cards {
      grid-template-columns: minmax(0, 1fr);
      gap: var(--s-6);
    }
  }

  /* BOARDS · advisory and review */

  /* Five people per board, so the desktop grid is five columns and the row
     fills exactly, no orphan, no "and one more on the next line". That is the
     whole reason this is an explicit column count rather than an auto-fit
     track: auto-fit would drop to four across at some width and leave the
     fifth person sitting alone under an empty row.

     Both board pages share this. The only difference between them is copy.

     If a board ever gains a sixth member, change the count here and check the
     ragged row rather than leaving auto-fit to guess. */
  .person-grid {
    display: grid;
    grid-template-columns: repeat(5, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    list-style: none;
    padding: 0;
    margin-block-start: var(--s-7);
  }

  /* The .tier-card idiom: a hairline above each card, so a full row reads as
     one continuous rule across the page rather than as five boxes. Flex column
     rather than grid for the same reason .tier-card is, it lets the link at
     the bottom claim the leftover space with an auto margin, so all five links
     sit on one line however long the bios above them run. In a grid an auto
     margin would only distribute space inside the item's own row. */
  .person {
    margin-block-start: 0;
    padding-block-start: var(--s-5);
    border-block-start: var(--bw) solid var(--rule);
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  /* --- the headshot ---------------------------------------------------- */

  /* Square, per the brief, and normally the full width of its column so the
     portrait and the text below it share one edge.

     The cap is for one narrow case. `--container` holds the shell at 1440px, so
     above roughly 1610px viewport the shell stops growing while the columns do
     not: a fifth of 1440px is 266px, and past that an uncapped portrait starts
     out-shouting the name under it. The cap trims about 25px there and does
     nothing at any narrower width in this layout.

     It is switched OFF in the 3-up and 2-up ranges below. Those columns reach
     341px and 362px, where a 240px cap would strand up to a third of the column
     empty beside every portrait, the opposite of what it is for.

     The box exists whether or not there is a photo in it: it holds its own
     aspect ratio, so adding a headshot later cannot shift the layout, and the
     monogram underneath means an empty slot still looks designed. */
  .person__portrait {
    position: relative;
    inline-size: 100%;
    max-inline-size: 15rem;
    aspect-ratio: 1;
    overflow: hidden;
    background: var(--paper-sunk);
    border: var(--bw) solid var(--rule);
  }

  /* Absolutely positioned rather than a plain block image, so it covers the
     monogram instead of pushing it out of the way.

     `z-index` is not decoration here, it is the whole mechanism. Both children
     are positioned with no stacking value of their own, so paint order would
     fall back to DOM order, and the instructions in the markup say to add the
     image as the FIRST child, which is the readable place for it but puts it
     BEHIND the monogram. Without this line a real headshot renders with the
     initials stamped across the face. Measured, not assumed: that is exactly
     what happened before it was added.

     The broken-photo case still degrades well. A failed image with alt="" paints
     only a small glyph and stays transparent elsewhere, so the monogram shows
     through around it and the box keeps its size, the card still reads as
     designed, with a visible hint that a filename is wrong. `check-links.py`
     catches that case before it ever reaches a browser. */
  .person__photo {
    position: absolute;
    inset: 0;
    z-index: 1;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    /* Faces are rarely centred in the lower half of a crop. */
    object-position: 50% 30%;
  }

  .person__monogram {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    font-family: var(--font-mono);
    font-size: var(--fs-2xl);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
    user-select: none;
  }

  /* --- the text ------------------------------------------------------- */

  .person__name {
    font-size: var(--fs-lg);
    line-height: var(--lh-snug);
    letter-spacing: -0.015em;
    font-weight: var(--fw-medium);
    color: var(--ink);
    text-wrap: balance;
  }

  .person__role {
    font-size: var(--fs-sm);
    line-height: var(--lh-snug);
    color: var(--ink);
  }

  /* Mono uppercase, so the employer reads as metadata rather than as part of
     the sentence. It also separates the title above from the bio below, which
     would otherwise be two identical-looking lines of small sans. */
  .person__org {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
  }

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

  /* These name the platform, "LinkedIn profile" on eight cards and "X profile"
     on one, rather than printing the address, so the `overflow-wrap: anywhere`
     that used to sit here has gone along with the URL that needed it: a two-word
     label has a space to break at. The size stays the smallest in the card, now
     for hierarchy rather than for fit, since the link is its least important
     line. The auto margin is what pulls it to the bottom of the card. */
  .person__link {
    font-size: var(--fs-xs);
    margin-block-start: auto;
    padding-block-start: var(--s-3);
    align-self: start;
  }

  /* An empty board is five portrait boxes and five monograms, which on its own
     looks like a page that failed to load. This says the quiet part out loud.

     Every text line drops together, not just the name. Quietening the name
     alone left "Role or job title" as the loudest thing in the card, the least
     meaningful line winning the hierarchy. Uniform --ink-3 reads as one empty
     slot instead of as a card with an emphasis bug.

     The hierarchy inside the card does not disappear, it just stops being
     carried by colour: the name keeps --fs-lg and --fw-medium, the org keeps
     its mono uppercase treatment. Which is the house rule anyway, never lean
     on colour alone to rank two things.

     Removing the .person--tba class restores the real hierarchy in one move. */
  .person--tba :is(.person__name, .person__role, .person__bio) {
    color: var(--ink-3);
  }

  /* One paragraph of a card greyed on its own, rather than the whole card, for
     a person who has a name and a title but no line under it yet, because that
     line is theirs to write and not ours to invent. This says "waiting on one
     sentence" where .person--tba above says "waiting on a person", and the two
     states look different on the page because they are different. No card is in
     this state as of 30 August 2026, when the last blank line was written from
     a source. The rule stays here, because the fifth advisory seat could still
     arrive as a name before it arrives as a sentence. Remove the class when the
     sentence arrives. */
  .person__bio--tba {
    color: var(--ink-3);
  }

  /* The link slot before there is a link to put in it. A paragraph rather than
     an anchor with a placeholder href, because `href="https://"` is a real dead
     link the moment somebody clicks it and the link checker cannot tell the
     difference between a stub and a mistake. Styled to match .link-mono so that
     swapping the <p> for an <a> changes nothing but the behaviour, which is a
     claim now tested twice: eight of the ten cards made that swap on 28 August
     2026 and a ninth on 30 August 2026, and nothing here needed touching either
     time. One is still a paragraph, the open fifth advisory seat. */
  .person__link--tba {
    font-family: var(--font-mono);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
  }

  /* Fewer columns means wider ones, so the portrait cap comes off here. See
     the note on .person__portrait. Both of these ranges let the portrait fill
     its column and share an edge with the text beneath it. */
  @media (max-width: 75rem) {
    .person-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .person__portrait { max-inline-size: none; }
  }

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

  /* On a phone, five full-width square portraits are five screens of scrolling
     to read five names. The card turns on its side instead: a small fixed
     portrait in the first column, everything else in the second.

     Back to grid here, because side-by-side with a shared column width is what
     grid is for. The portrait's row span must match the number of text
     children, five: name, role, org, bio, link. */
  @media (max-width: 34rem) {
    .person-grid {
      grid-template-columns: minmax(0, 1fr);
      gap: 0;
      border-block-start: var(--bw) solid var(--rule);
    }

    .person {
      display: grid;
      grid-template-columns: 5.5rem minmax(0, 1fr);
      column-gap: var(--s-4);
      row-gap: var(--s-2);
      padding-block: var(--s-5);
      border-block-start: 0;
      border-block-end: var(--bw) solid var(--rule);
    }

    .person__portrait {
      grid-column: 1;
      grid-row: 1 / span 5;
      align-self: start;
      max-inline-size: none;
    }

    .person :is(.person__name, .person__role, .person__org, .person__bio, .person__link) {
      grid-column: 2;
    }

    /* justify-self, not just the align-self the base rule already sets. In this
       breakpoint the card is a grid and the link is one of its items, so it
       blockifies and stretches the full column unless told otherwise, and
       .link-mono draws its underline as a background sized 100% by 1px. A
       stretched box therefore draws a rule the width of the column instead of an
       underline the width of the label. It went unnoticed while the label was a
       full URL, because a URL filled that width anyway. */
    .person__link {
      margin-block-start: var(--s-2);
      padding-block-start: 0;
      justify-self: start;
    }
  }

  /* PROSE PAGE, the code of conduct */

  /* The only page on the site built for sustained reading rather than
     scanning, so it gets the serif `.prose` treatment, a narrow measure and a
     sticky contents rail. A code of conduct that is unpleasant to read is a
     code of conduct nobody reads. */
  .prose-page {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    align-items: start;
  }

  .prose-page__toc  { grid-column: 1 / span 3; }
  .prose-page__body { grid-column: 5 / span 7; }

  /* The container sets `align-items: start` so a short column does not stretch.
     The contents rail has to opt back OUT of that, because a sticky element can
     only travel inside its own containing block, and with `start` the grid item
     is exactly as tall as the rail, giving it nowhere to go. Stretching the item
     to the full row height is what makes the sticky below actually stick. It has
     no background or border, so stretching it is invisible. */
  .prose-page__toc { align-self: stretch; }

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

    .prose-page__toc,
    .prose-page__body { grid-column: 1 / -1; }
  }

  /* Two conditions have to hold for this to work, and both are easy to break:
       · nothing between here and the viewport may be a scroll container. `main`
         uses overflow-x: clip, which deliberately does NOT create one, `hidden`
         would, and would silently kill this. See the note in base.css.
       · the parent grid item must be taller than this element, which is what the
         `align-self: stretch` above buys. */
  .toc {
    position: sticky;
    inset-block-start: calc(var(--header-h) + var(--s-5));

    /* Eleven entries fit comfortably in a laptop viewport, but a short window
       plus a longer contents list must not clip the last item beyond reach. */
    max-block-size: calc(100svh - var(--header-h) - var(--s-7));
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
  }

  .toc__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);
    padding-block-end: var(--s-4);
    border-block-end: var(--bw) solid var(--rule);
  }

  .toc__list {
    list-style: none;
    padding: 0;
    display: grid;
    gap: 0;
  }

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

  /* The hover indent moves `translate`, not `padding-inline-start`, and that is
     a correctness fix rather than a preference. Padding is a layout property:
     transitioning it ran layout on this element and its subtree on every frame
     of a hover, which is the one thing a hover must never do. Translate gets an
     identical result on the compositor. It also leaves the hit area where it
     was, so the link no longer slides out from under the cursor that opened it,
     which was a second, quieter bug in the same two lines. */
  .toc__list a {
    display: block;
    padding-block: var(--s-3);
    font-size: var(--fs-sm);
    line-height: var(--lh-snug);
    color: var(--ink-2);
    background-image: none;
    transition:
      color var(--dur-fast) var(--ease),
      translate var(--dur) var(--ease);
  }

  @media (hover: hover) and (pointer: fine) {
    .toc__list a:hover {
      color: var(--accent);
      translate: var(--s-3) 0;
    }
  }

  /* No translate on the press. The hover indent is a pointer affordance saying
     "this is the one under your cursor", and repeating it on touch would move the
     link out from under the finger already on it. Colour alone here. */
  .toc__list a:active {
    color: var(--accent-active);
  }

  /* Below the breakpoint the rail is just a list at the top of the page, so it
     loses the sticky behaviour AND the height cap that goes with it, otherwise
     the contents become a small scrollable box inside a scrolling page. */
  @media (max-width: 60rem) {
    .toc {
      position: static;
      max-block-size: none;
      overflow-y: visible;
    }
  }

  /* The reporting block. This is the single most important element on the
     code-of-conduct page, so it is the only place on the site that gets a
     solid-ink rule on all four sides. */
  .report-box {
    display: grid;
    gap: var(--s-5);
    padding: clamp(var(--s-5), 4vw, var(--s-7));
    border: var(--bw-strong) solid var(--ink);
    margin-block: var(--s-7);
  }

  .report-box__label {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--accent);
  }

  .report-box__what {
    font-family: var(--font-sans);
    font-size: var(--fs-xl);
    line-height: var(--lh-snug);
    letter-spacing: var(--track-xl);
    font-weight: var(--fw-medium);
    color: var(--ink);
  }

  .report-box__how {
    display: grid;
    gap: var(--s-4);
    font-family: var(--font-sans);
    font-size: var(--fs-base);
    line-height: var(--lh-body);
    letter-spacing: var(--track-body);
    color: var(--ink-2);
  }

  .report-box__how strong { color: var(--ink); }

  /* VISIT DOON · FILMSTRIP */

  /* SIX PLACES ON ONE LINE. Section 02 has six cards and each now carries a
     photograph, which at three across is two rows of picture-plus-paragraph and
     puts the second row under the fold on a laptop. Sideways, the six are one
     line, and the fact that there are more of them than fit is visible instead
     of implied.

     THE BROWSER DOES THE WORK. This is overflow plus scroll snapping. The
     dragging, the momentum, the snapping, the keyboard, the focus ring and the
     scrollbar are all native. main.js adds two buttons and a position readout
     and nothing else, so with JavaScript off this is still a strip you can pull,
     which is why the controls are the only part that is script-dependent and
     the only part hidden when there is nothing to scroll. */
  .filmstrip__viewport {
    /* `visible` is not honoured on the block axis next to `auto` on the inline
       one, so this is `hidden` and the cards must not draw outside themselves.
       They do not: the plate clips its own drift. */
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: inline mandatory;
    /* The scrollbar stays. On a machine without a trackpad it is the only
       affordance that says this scrolls, and hiding it to look tidy is a
       decision taken on behalf of a reader who then cannot find the thing. */
    scrollbar-width: thin;
    scrollbar-color: var(--rule-strong) transparent;
    /* The viewport is focusable, so the global focus ring lands on it, and the
       links inside the cards get their own. The ring is 2px at a 3px offset, so
       8px of padding clears it on the clipped axis, and the matching negative
       margin gives that space back to the gutter so the first card still starts
       on the text column. Without this the ring on a link near a card's top edge
       is sliced off by the overflow. */
    margin-inline: calc(var(--s-2) * -1);
    padding: var(--s-2);
    /* And the snap origin has to be moved in by the same amount, or the padding
       is simply scrolled out of view: a snap position is measured to the padding
       box, so the first card's would be 8px in, the container would rest there,
       and the card would sit 8px OUTSIDE the text column rather than on it.
       Measured before this line existed: card at x=13 against a heading at x=21.
       It also puts the resting scroll offset back at 0, which is what
       `initFilmstrips` derives its start-of-strip test from. */
    scroll-padding-inline: var(--s-2);
  }

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

  /* base.css spaces consecutive list items, for prose lists. Here they sit
     side by side, so that margin would be a stray gap above every card but the
     first, on the block axis, in a row. */
  .filmstrip__track > li + li { margin-block-start: 0; }

  .filmstrip__item {
    flex: 0 0 auto;
    /* 88% on a phone so the next card visibly peeks and the strip explains
       itself without a hint. Capped at 24rem so on the 1440 shell a little
       over three cards show: enough to read as a row rather than as a slide,
       and few enough that the sixth is plainly off the edge. */
    inline-size: min(88%, 24rem);
    scroll-snap-align: start;
  }

  /* THE CONTROLS. A hairline, a count, two buttons. No dots: six dots that each
     move you one card are six ways to do what the strip already does, and they
     are the first thing to break on a phone.

     They are DISPLAY: NONE until main.js has measured and found something to
     scroll. That one default covers both cases where they would be a lie: no
     JavaScript, where nothing would be listening to them, and no overflow, where
     there is nowhere to go. Native scrolling needs neither of those checks and
     keeps working in both. */
  .filmstrip__foot { display: none; }

  .filmstrip.is-live .filmstrip__foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-4);
    flex-wrap: wrap;
    margin-block-start: var(--s-5);
    padding-block-start: var(--s-4);
    border-block-start: var(--bw) solid var(--rule);
  }

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

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

  .filmstrip__buttons {
    display: flex;
    gap: var(--s-2);
    margin-inline-start: auto;
  }

  .filmstrip__btn {
    inline-size: 2.75rem;
    block-size: 2.75rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: var(--bw) solid var(--rule);
    border-radius: var(--r-0);
    background: none;
    color: var(--ink-2);
    cursor: pointer;
    transition:
      border-color var(--dur-fast) var(--ease),
      color var(--dur-fast) var(--ease);
  }

  /* The glyph lives here, not in the markup, for the reason .link-arrow gives:
     an arrow is a picture of a direction and not a word, and a screen reader
     that reads it out loud is reading punctuation. The button's accessible name
     comes from its aria-label. */
  .filmstrip__btn::before {
    font-family: var(--font-mono);
    font-size: var(--fs-sm);
    line-height: 1;
  }

  .filmstrip__btn--prev::before { content: "\2190"; }
  .filmstrip__btn--next::before { content: "\2192"; }

  @media (hover: hover) and (pointer: fine) {
    .filmstrip__btn:hover:not(:disabled) {
      border-color: var(--rule-strong);
      color: var(--accent);
    }
  }

  .filmstrip__btn:active:not(:disabled) {
    border-color: var(--accent);
    color: var(--accent-active);
    translate: 0 1px;
  }

  /* Disabled, not hidden. A control that vanishes at the end of the strip
     moves the other one under the cursor. */
  .filmstrip__btn:disabled {
    opacity: 0.45;
    cursor: default;
  }

  /* Nothing overflows, or nothing is driving it, so the controls stay hidden.
     The overflow stays as it is either way: with nothing to scroll there is no
     scrollbar and nothing to clip, and setting `overflow-x: visible` next to
     `overflow-y: hidden` only computes back to `auto` anyway. */

  /* VISIT DOON · PHOTOGRAPH COLOUR */

  /* THIS PAGE'S PHOTOGRAPHS COME TO FULL COLOUR, and where they do not, colour is
     one hover away. Asked for on 6 September 2026: colour on a phone and on a
     tablet, the site's own near-monochrome treatment kept on a desktop, and the
     original frame under the pointer.

     THE QUERY IS ABOUT THE POINTER AND NOT ABOUT THE WIDTH, and that is the whole
     argument for the rule. A desaturated photograph is defensible here only because
     the colour is one movement away, so on a device that cannot hover the colour has
     to be there from the start or it can never be reached at all. iPadOS Safari and
     Android both report `hover: none` and `pointer: coarse`, so "phone and tablet"
     is what the first query actually selects, without asking a viewport width to
     stand in for a device. A laptop at a narrow window keeps the treatment, because
     it still has a mouse. A pointer that hovers but is coarse, a remote or a stylus,
     matches the first query and gets colour outright, which is right: it cannot
     reliably rest on one tile in a six tile grid. The two queries are complements,
     so no device falls between them.

     FULL COLOUR MEANS THREE TOKENS AND NOT ONE. The treatment is a grayscale filter,
     a 4 percent contrast nudge that exists to compensate for it, and a faint accent
     wash on the box in soft-light. Turning off only the grayscale leaves a
     purple-toned photograph, which is a different treatment rather than the frame as
     it was taken. All three are set to their neutral values together.

     IT SETS TOKENS RATHER THAN RE-DECLARING THE FILTER, so 0.92, 1.04 and 0.14 stay
     written once, in components.css, and a change to the strength of the treatment
     there carries into the hover state here without anybody remembering to.

     THE TRANSITION IS ON `filter` AND `opacity`, NOT ON THE TOKENS. A custom
     property that has not been registered with @property animates discretely, so
     transitioning the tokens themselves would snap. The two properties that read
     them are both animatable and both recompute when the token changes, so
     transitioning those is what turns the reveal into a fade. Reduced motion needs
     nothing here: base.css already clamps every transition-duration on the page to
     1ms with !important, which no layer can outrank.

     The hover target is `.plate__box`, which is the photograph's own rectangle and
     not the card around it. The instruction said on the images. */
  @media (hover: none), (pointer: coarse) {
    .page-visit-doon {
      --photo-desaturate: 0;
      --photo-contrast: 1;
      --photo-tone: 0;
    }
  }

  @media (hover: hover) and (pointer: fine) {
    .page-visit-doon .plate__img--photo {
      transition: filter var(--dur) var(--ease);
    }

    .page-visit-doon .plate__box:has(.plate__img--photo)::after {
      transition: opacity var(--dur) var(--ease);
    }

    .page-visit-doon .plate__box:has(.plate__img--photo):hover {
      --photo-desaturate: 0;
      --photo-contrast: 1;
      --photo-tone: 0;
    }
  }

  /* 404 */

  /* A 404 is a navigation failure, so it gets navigation, a real list of
     where the reader probably meant to go, rather than an apology and a
     cartoon. */
  .notfound {
    padding-block: calc(var(--header-h) + var(--s-7)) var(--section-y);
    min-block-size: 70svh;
    display: grid;
    align-content: center;
  }

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