/* home.css: section layouts unique to index.html.

   The rule for this file: if a pattern appears on more than one page it does
   NOT belong here, it belongs in components.css. Everything below is either
   the hero (which exists once, by definition) or a layout that only the home
   page needs.

   @layer sections sits ABOVE components in the layer order, so a section may
   reposition a component without a specificity fight and without that
   override leaking anywhere else. */

@layer sections {

  /* HERO */

  /* The whole argument of the design is made here, in about four seconds:
     enormous tightly-tracked display type, one accent, a mono coordinate
     stamp, and a great deal of paper. No image, no gradient, no video,
     the type IS the graphic. */
  /* THE VERTICAL RHYTHM WAS RE-CUT ON 29 AUGUST 2026, one --s step at every
     join, when the display came down from 168px to 128px. Shrinking the type
     without shrinking the gaps had made the hero looser than it was drawn, and
     at 1470 by 834, a 13.6 inch laptop, the composition ran 886px under an 81px
     header: the standfirst broke mid-sentence and the countdown never appeared.
     It closes at 814 now, with the terrain starting at the fold.

     If the display size ever moves again, these five numbers move with it. They
     are a ratio to the headline, not a fixed frame around it. */
  .hero {
    position: relative;
    padding-block-start: calc(var(--s-8) + var(--s-4));
    padding-block-end: 0;
    overflow: hidden;
  }

  .hero__top {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s-4) var(--s-6);
    padding-block-end: var(--s-4);
    border-block-end: var(--bw) solid var(--rule);
  }

  /* The display line. Set as two lines by hand rather than left to wrap:
     "Security," / "above sea level." is the intended rag, and at this size
     an accidental break would be the difference between a poster and a
     mistake. */
  .hero__title {
    margin-block-start: var(--s-5);
    max-width: 18ch;
  }

  /* The comma after "Security" is doing real typographic work. It holds the
     pause the tagline depends on, but it also punches a visible hole in the
     left-aligned block. Pulling the second line back by a hair's breadth is
     the kind of correction a typesetter makes and nobody notices. */

  /* Each line is its own clip window, and the inner span is what moves inside
     it. See the choreography block at the end of this section for why the two
     elements are needed rather than one.

     The clip is a clip-path with NEGATIVE insets rather than `overflow: hidden`,
     and that is the part that had to be measured rather than guessed.
     --lh-display is 0.88, which means the line BOX is shorter than the glyphs
     drawn in it: a grotesque at cap height plus a descender needs about 0.95em
     and gets 0.88em, so ink already sits a hair outside the content box before
     anything is clipped. Clipping to that box shears the tops of the capitals
     and the tail of the comma. inset(-0.14em …) opens the window wide enough for
     both.

     The first attempt here was `overflow: hidden` with padding-block: 0.14em and
     an equal negative margin-block to give the space back. Measured, it did not:
     two adjacent negative margins COLLAPSE to one, so the pair returned 0.14em
     between the lines where it had taken 0.28em, and the second line sat 7.3px
     low with everything below it pushed down to match. A clip-path cannot make
     that mistake, because a clip has no layout effect by construction. The
     horizontal -0.25em is slack for glyph side bearings; nothing overflows
     sideways that we would want hidden.

     If a browser drops the declaration outright, the resting typography is
     still exactly right and only the entrance loses its window, which is the
     correct way round for an effect that runs once and a page that is read for
     minutes. */
  .hero__line {
    display: block;
    clip-path: inset(-0.14em -0.25em);
  }

  .hero__line-in {
    display: block;
  }

  .hero__line:last-child {
    color: var(--accent);
  }

  .hero__body {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-6) var(--gutter);
    align-items: end;
    margin-block-start: var(--s-6);
  }

  .hero__lead {
    grid-column: 1 / span 5;
  }

  .hero__facts {
    grid-column: 7 / span 3;
  }

  .hero__actions {
    grid-column: 11 / span 2;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--s-3);
  }

  /* A definition list, because that is genuinely what this is: three terms and
     their values. Screen readers announce the pairing for free.

     (This said "four terms" until 2 September 2026. There are three: dates,
      venue, capacity.) */
  .hero__facts dl {
    display: grid;
    gap: var(--s-4);
  }

  .hero__facts dt {
    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);
    font-weight: var(--fw-regular);
  }

  /* The mark that leads each term. --brand, and that is not a free choice:
     tokens.css gives red the labels, indices and rules, and blue everything a
     reader can act on. A term in a definition list is a label. Nothing else in
     the hero is red except the logo, and three fourteen-pixel marks are about
     the smallest amount of it that registers at all.

     Sized in rem, not em. --fs-label is eleven pixels and a mark at 1em of
     that is a smudge; 0.875rem clears the label's cap height while staying
     subordinate to the value underneath it, which is --fs-lg. */
  .hero__facts dt .icon {
    --icon-size: 0.875rem;
    color: var(--brand);
  }

  .hero__facts dd {
    font-size: var(--fs-lg);
    line-height: var(--lh-snug);
    letter-spacing: -0.015em;
    color: var(--ink);
    margin-block-start: var(--s-1);
  }

  .hero__stamp {
    margin-block-start: var(--s-6);
    padding-block: var(--s-4);
    border-block-start: var(--bw) solid var(--rule);
  }

  /* THE TERRAIN, full-bleed, anchored to the bottom of the hero. Low contrast on
     purpose: it is a texture that rewards a second look, not an illustration
     competing with the headline.

     It is an inline <svg> rather than an <img>, and the two reasons are the two
     things an <img> cannot do: var() inside an inlined SVG resolves against this
     document, so the strokes below are the live theme tokens and the manual
     theme toggle repaints the drawing for free; and the three ranges are
     separate elements in this document's tree, so each can be given its own
     scroll speed. Through an <img> the file is a sealed document with its own
     root, which is why it carries hex fallbacks for exactly that case.

     aspect-ratio is belt and braces. An inline SVG with a viewBox does have an
     intrinsic ratio and `block-size: auto` does resolve against it, but stating
     the ratio means the band reserves its height in the first frame regardless,
     and it is the one declaration that keeps the hero from reflowing. */
  .hero__ridge {
    display: block;
    inline-size: 100%;
    block-size: auto;
    aspect-ratio: 1440 / 200;
    margin-block-start: var(--s-5);
    opacity: 0.85;
  }

  /* Depth is line weight and group opacity. That is the entire vocabulary: no
     gradient, no fill, no haze, no tint, because this design language does not
     own any of those and a terrain drawing is not a good enough reason to buy
     one. Three hairlines, each a step fainter and a step thinner than the one in
     front of it, is how a printed map has always done aerial perspective.

     The strokes are set on the <g> and inherited by the polyline inside it.
     stroke, stroke-width, stroke-linejoin and stroke-linecap are all inherited
     SVG properties, so the group is the right place for them; opacity is not
     inherited, and that is also correct here, because it should apply to the
     layer as one object rather than multiply per child.

     Contrast follows the tokens, which means the prefers-contrast: more block in
     tokens.css darkens all three ranges without this file knowing about it. */
  .hero__ridge-datum {
    stroke: var(--rule);
    stroke-width: 1;
    stroke-dasharray: 3 5;
    opacity: 0.9;
  }

  .hero__ridge-layer {
    stroke: var(--rule-strong);
    stroke-linejoin: round;
    stroke-linecap: round;
  }

  .hero__ridge-layer--far  { stroke-width: 1;    opacity: 0.45; }
  .hero__ridge-layer--mid  { stroke-width: 1.25; opacity: 0.68; }
  .hero__ridge-layer--near { stroke-width: 1.5; }

  /* The only accent in the drawing, and the only place it is allowed. It marks
     the valley floor, so it travels with the near range rather than being
     pinned: the mark belongs to that line, and a mark that slid off its own
     contour would just look broken. */
  .hero__ridge-stem {
    stroke: var(--accent);
    stroke-width: 1;
    opacity: 0.55;
  }

  .hero__ridge-mark {
    fill: var(--accent);
  }

  /* HERO CHOREOGRAPHY */

  /* The first four seconds, in nine steps of --stagger. Reading order, top to
     bottom, exactly as a person's eye moves: label, coordinate stamp, the two
     display lines, then a longer beat before the lead, because the headline is
     the sentence and everything after it is the footnote, then the facts, the
     buttons, the countdown, and the ridge last.

     Two rules govern all of it.

     It is CSS, not JavaScript. No `html.js` gate, no IntersectionObserver, no
     class flipped from a module. The hero is the first thing painted and it
     animates before main.js has been fetched, let alone parsed. `both` fill
     means every element holds its finished state afterwards, so there is no
     state for a script to get wrong and nothing to leave behind if a script
     fails. Compare .reveal, which genuinely needs the gate: its hidden state
     can only be removed by JavaScript, so it must not exist unless JavaScript
     is running.

     Only opacity and translate. Nine simultaneous animations on the largest
     type on the site, and the compositor handles all of it without a single
     layout pass. Nothing scales, nothing rotates, nothing bounces (brief §5).

     The display lines are the one thing built differently. They do not fade;
     they rise into view from under the clip edge of their own line box, which
     is why .hero__line is a window and .hero__line-in is the thing that moves.
     A fade on type this size looks like a slow image load. A wipe looks like
     letterpress. That is the whole of the effect and it is worth the extra
     span.

     Written as longhands, not as the `animation` shorthand, and that is not a
     style preference. `none` is a valid value for BOTH animation-name and
     animation-fill-mode, and the shorthand grammar resolves the ambiguity in
     favour of fill-mode, so `animation: var(--entrance) … both` with
     --entrance: none parses as fill-mode: none plus animation-name: BOTH.
     Measured in Chrome: getComputedStyle().animationName came back "both".
     There is no @keyframes both, so the effect was still "no animation" and
     nothing was visibly wrong, but the off-switch should mean what it says
     rather than work by the accident of a missing keyframes name. */
  .hero__top > .eyebrow,
  .hero__top > .meta-stamp,
  .hero__lead,
  .hero__facts,
  .hero__actions,
  .hero__stamp,
  .hero__ridge {
    animation-name: var(--entrance);
    animation-duration: var(--dur-entrance);
    animation-timing-function: var(--ease-out);
    animation-fill-mode: both;
  }

  .hero__line-in {
    animation-name: var(--entrance-line);
    animation-duration: var(--dur-entrance);
    animation-timing-function: var(--ease-out);
    animation-fill-mode: both;
  }

  .hero__top > .meta-stamp { animation-delay: calc(var(--stagger) * 1); }
  .hero__line:nth-child(1) .hero__line-in { animation-delay: calc(var(--stagger) * 2); }
  .hero__line:nth-child(2) .hero__line-in { animation-delay: calc(var(--stagger) * 3); }
  .hero__lead    { animation-delay: calc(var(--stagger) * 5); }
  .hero__facts   { animation-delay: calc(var(--stagger) * 6); }
  .hero__actions { animation-delay: calc(var(--stagger) * 7); }
  .hero__stamp   { animation-delay: calc(var(--stagger) * 8); }

  /* The ridge fades and does not rise. It is anchored to the bottom edge of a
     hero with `overflow: hidden`, so any upward travel would open a strip of
     bare paper between it and the section below for the length of the
     animation, so its travel is zeroed locally rather than globally. The
     elevation profile arriving as a tone rather than a movement is also closer
     to what it is: a texture, not an element.

     --rest-opacity is what stops the shared keyframe fading it in to full
     strength and leaving it there, overwriting the 0.85 declared above. */
  .hero__ridge {
    --lift: 0px;
    --rest-opacity: 0.85;
    animation-delay: calc(var(--stagger) * 9);
  }

  @keyframes bsd-line-rise {
    from { translate: 0 var(--lift-line); }
    to   { translate: 0 0; }
  }

  /* THE TERRAIN ON SCROLL */

  /* Three ranges, three speeds. The nearest slides the full --ridge-drift as the
     first screen scrolls away, the middle range a little over half of it, the far
     range a quarter, and the gaps between them open and close as they go. That is
     parallax, and it is the one effect on this site that genuinely could not be
     done with type: a drawing gains depth by being taken apart.

     WHAT IT DOES NOT DO, deliberately. It does not move the mark along the
     profile, however good that would look. The mark means "the conference is
     here" and the copy beside it publishes 640 m, so a mark that climbed the
     range as you read would be the drawing telling a lie the text has to answer
     for. It does not touch opacity either: the entrance above owns opacity, this
     owns position, and keeping the two apart is what lets each be switched off by
     its own mechanism instead of fighting over one. And it does not move the
     datum, because sea level does not pan.

     SIDEWAYS, NOT VERTICALLY. A vertical parallax would slide the ranges out
     through the bottom edge of a hero that clips, and the effect would be the
     drawing leaving rather than the drawing deepening.

     Off-switch: the travel token is zeroed in tokens.css under
     prefers-reduced-motion, which stops all three layers at once, and base.css
     detaches the timeline as well. Zeroing travel is the correct mechanism for
     anything scroll-driven, because a scroll-driven animation never reads a
     duration and `animation-duration: 1ms` does nothing whatsoever to it.

     Behind @supports because a scroll-driven animation is the one modern feature
     here with no reasonable polyfill, and because the fallback is genuinely fine:
     three static hairlines, which is what the drawing was on the day it was
     drawn. Nothing is conditional on it except the movement. */
  @supports (animation-timeline: scroll(root block)) {
    .hero__ridge-layer {
      animation-name: bsd-ridge-drift;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root block);
      /* The first screen of scroll, which is roughly the hero's own exit. Left
         unranged it would spread the drift across the whole document and the
         terrain would be gone long before it had moved. */
      animation-range: 0 100vh;
    }

    .hero__ridge-layer--far  { --ridge-depth: 0.25; }
    .hero__ridge-layer--mid  { --ridge-depth: 0.55; }
    .hero__ridge-layer--near { --ridge-depth: 1; }

    /* Negative x: scrolling down travels north, into the drawing. The px here is
       a user unit in the SVG's own coordinate space rather than a screen pixel,
       so the drift scales with the band. */
    @keyframes bsd-ridge-drift {
      from { translate: 0 0; }
      to   { translate: calc(var(--ridge-drift) * var(--ridge-depth) * -1) 0; }
    }
  }

  @media (max-width: 60rem) {
    .hero {
      padding-block-start: var(--s-8);
    }

    .hero__body {
      grid-template-columns: minmax(0, 1fr);
      align-items: start;
    }

    .hero__lead,
    .hero__facts,
    .hero__actions {
      grid-column: 1 / -1;
    }

    .hero__facts dl {
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: var(--s-5) var(--gutter);
    }

    .hero__actions {
      flex-direction: row;
      flex-wrap: wrap;
    }

    .hero__actions > * {
      flex: 1 1 12rem;
    }
  }

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

    .hero__actions > * {
      flex-basis: 100%;
    }

    /* A 1440-by-200 band is 54px tall on a phone, which is not a terrain, it is a
       squiggle. A minimum height plus the `slice` in the markup gives the band
       back its presence by showing the middle of the drawing instead of all of a
       thinner version of it. The mark sits at a third of the way across, well
       inside the crop, and the datum stays at the bottom because the drawing is
       anchored there. */
    .hero__ridge {
      min-block-size: 5.5rem;
    }
  }

  /* COUNTDOWN */

  /* Tabular figures and a fixed column width, so a digit rolling over never
     nudges the layout. This is the detail that separates a countdown that
     feels engineered from one that feels bolted on. */
  .countdown {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--s-5);
    font-variant-numeric: tabular-nums;
  }

  .countdown__unit {
    display: flex;
    flex-direction: column;
    gap: var(--s-1);
    min-inline-size: 3.5ch;
  }

  .countdown__value {
    font-size: var(--fs-3xl);
    line-height: 1;
    letter-spacing: var(--track-3xl);
    font-weight: var(--fw-medium);
    color: var(--ink);
    transition:
      translate var(--dur-fast) var(--ease),
      opacity var(--dur-fast) var(--ease);
  }

  /* the roll

     A digit that changes should look like it turned over, not like it was
     replaced. main.js drives this in two phases and the CSS provides the two
     end states: the outgoing value lifts and fades, then the new text is
     written while nothing is visible, dropped below the line without a
     transition, and released to travel back up.

     The important constraint is not visual. There is only ever ONE value in the
     DOM (the old text is replaced, never duplicated beside the new one), so a
     screen reader or a text-only browser reads a clean "184", never "184185".
     Every CSS-only digit-roll technique fails that test, which is why this one
     is worth the fifteen lines of JavaScript.

     0.3em of travel, not more. At --fs-3xl the value renders up to 50px, so
     0.3em is already ~15px, and the hairline of .hero__stamp sits directly
     above it. Any further and the digit visibly crosses the rule on its way
     out. There is no clip window here for the same reason the hero has one and
     this does not: the digit is fully transparent by the time it has travelled,
     so nothing needs hiding. */
  .countdown__value[data-roll="out"] {
    translate: 0 -0.3em;
    opacity: 0;
  }

  .countdown__value[data-roll="in"] {
    translate: 0 0.3em;
    opacity: 0;
    /* No transition on the way down: this is the invisible reset between the
       two halves of the roll, and animating it would send the digit on a
       round trip through the space it just left. */
    transition: none;
  }

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

  /* --rule-strong was the instinct here and it was wrong: at 40px the slash is
     text, so it needs 3:1, and --rule-strong measures 2.06:1 on --paper and
     2.13:1 on dark paper. Both fail. --ink-3 measures 5.05:1 and 5.58:1, and is
     still two clear steps lighter than the numerals it separates, which is all
     the subordination this glyph actually needed. Rule tokens are for rules. */
  .countdown__sep {
    font-size: var(--fs-2xl);
    line-height: 1;
    color: var(--ink-3);
    align-self: start;
  }

  /* ABOUT */

  .about__body {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    align-items: start;
  }

  .about__prose {
    grid-column: 1 / span 6;
  }

  .about__side {
    grid-column: 8 / span 5;
  }

  @media (max-width: 60rem) {
    .about__body { grid-template-columns: minmax(0, 1fr); }
    .about__prose,
    .about__side { grid-column: 1 / -1; }
  }

  /* A short list of what the conference is not, which is a faster way to
     explain BSides than a paragraph about its history. */
  .contrast-list {
    display: grid;
    gap: 0;
    list-style: none;
    padding: 0;
    border-block-start: var(--bw) solid var(--rule);
  }

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

  .contrast-list__from {
    font-family: var(--font-mono);
    font-size: var(--fs-sm);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
    text-decoration: line-through;
    text-decoration-color: var(--rule-strong);
    text-decoration-thickness: 1px;
  }

  .contrast-list__to {
    font-size: var(--fs-base);
    color: var(--ink);
    font-weight: var(--fw-medium);
  }

  /* ELEVATION / WHY DEHRADUN */

  /* Nothing. The elevation band was two bespoke rules here until .plate existed;
     it is now .plate .plate--bleed .plate--36x5 .plate--ruled and every one of
     those is a component modifier the styleguide can show. A section stylesheet
     with no rules in it is the correct outcome for a section that turned out to
     be made of components, so the heading stays as the record of that. */


  /* EDITIONS */

  /* Three editions, three attendance figures, one of them still a target.
     Laid out as a row so the growth reads as a slope without a chart. */
  .editions {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--gutter);
    margin-block-start: var(--s-7);
    border-block-start: var(--bw-strong) solid var(--ink);
  }

  .edition {
    padding-block-start: var(--s-5);
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  /* The current edition is marked with an accent top rule that sits ON the
     shared rule above, not next to it, so the emphasis is unmistakable
     without adding a box, a tint or a shadow. */
  .edition--current {
    box-shadow: 0 calc(var(--bw-strong) * -1) 0 0 var(--accent);
  }

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

  /* The id line is the brand red and the count below it stays the accent blue.
     That is deliberate rather than a leftover: it is the same red-label-above-
     larger-content shape the eyebrow makes at the head of every section, so the
     one card that carries both reads as an instance of the site's pattern. Red
     names the edition; blue is the number. */
  .edition--current .edition__id {
    color: var(--brand);
  }

  .edition__count {
    font-size: var(--fs-4xl);
    line-height: 0.9;
    letter-spacing: var(--track-display);
    font-weight: var(--fw-medium);
    font-variant-numeric: tabular-nums;
  }

  .edition--current .edition__count {
    color: var(--accent);
  }

  .edition__label {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
    line-height: var(--lh-snug);
  }

  /* the counter reel */
  /* The three attendance figures roll in when the row reaches the viewport, and
     this is the half of that which lives in CSS. main.js replaces the text of
     each `[data-countup]` span with one column per digit; a column holds a hidden
     copy of the real digit and a strip of faces that is pulled upwards past it.
     When the last column lands, the JavaScript puts the original text node back,
     so the DOM finishes the animation byte for byte what the server sent.

     None of these rules match anything until that has happened, which means they
     are inert on every page but this one and, on this one, inert for every reader
     who asked for reduced motion: the observer's callback checks the preference
     and returns before it builds anything. There is no `.is-ready` here for the
     same reason there is none anywhere else on the site. The markup as served is
     already the finished number.

     WHY THE WINDOW IS A CLIP-PATH AND NOT `overflow: hidden`. The column has to
     be an inline-block, because it sits in a line of text beside a `+` that must
     not move. An inline-block whose overflow computes to anything other than
     visible takes its baseline from its bottom margin edge rather than from the
     text inside it, so `overflow: hidden` would drop that `+` by most of a line.
     A clip-path has no layout effect whatsoever and still clips absolutely
     positioned descendants, which is exactly the pair of properties wanted. It
     is also the device the hero uses on oversized display type for a closely
     related reason, argued at the top of this file.

     Measured at the 68px this resolves to on a 1440px viewport, at rest and again
     mid-roll: the three columns come to 117.117px against 117.102px for the text
     they replaced, and the `+` beside them moves from 196.305px to 196.320px. That
     is fifteen thousandths of a pixel and it is the same number twice, because the
     one is the cause of the other. Do not add `letter-spacing: normal` to the
     column to "correct" the tracking. It was tried, and it is what breaks the
     parity: the trailing letter-space is already inside the inline-block's
     shrink-to-fit width, and an atomic inline gets no second helping from its
     parent, so resetting it makes the group 7.75px too wide.

     WHY 1lh IS THE CELL. `.edition__count` sets `line-height: 0.9`, so a line box
     here is 61.195px while the digit ink inside it is 50.46px tall and sits
     5.23px below the top of the box, from the font's own metrics at this size.
     Every face therefore clears both window edges with about 5px to spare, and
     the strip stacks at exactly one line box per face with no gap to accumulate
     over eighteen of them. Reading the cell as 1lh rather than as 0.9em keeps
     that in one place: change the line-height above and the reel follows. Do not
     set line-height anywhere inside this subtree, or 1lh stops meaning what the
     rest of the arithmetic assumes. */
  .reel__col {
    position: relative;
    display: inline-block;
    clip-path: inset(0 -0.25em);
  }

  /* In flow, hidden, and load-bearing. This copy of the real digit is what gives
     the column its width and, more to the point, its baseline. `visibility:
     hidden` keeps the box and the line box that produced it, which is the whole
     reason it is not `display: none`. The strip cannot do this job because it is
     out of flow. */
  .reel__sizer {
    visibility: hidden;
  }

  /* Two custom properties, and both are bare integers rather than lengths or
     times: --reel-index is which face to land on, --reel-col is which column this
     is from the left. The arithmetic that turns them into a distance and a delay
     stays here, next to the tokens it multiplies, so --stagger and the cell are
     each named in exactly one place. main.js writes numbers, the same way
     initNavIndicator writes --nav-ind-w.

     The two-step offset is why the row settles at 160 + 720 = 880ms, which is the
     figure the hero's nine reveal steps already land on. Adding a fourth digit to
     any of these numbers would push it to 240 + 720; that is fine, and nothing
     here needs changing for it.

     A note on the fallbacks, because the rule stated in components.css does not
     quite reach this case. There the requirement is that a custom property's
     fallback be the state the page paints in, since the element exists either way.
     A reel column does not exist either way: it is only ever built by script, and
     `--reel-index: 0` would be a wrong number rather than a plain one. The guard
     here is therefore not the fallback at all, it is that main.js feature-tests
     `lh` and declines to build if it is missing. The `0`s below are there so the
     declarations are valid, not because 0 is ever the right answer. */
  .reel__strip {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    transform: translateY(calc(var(--reel-index, 0) * -1lh));
    animation: bsd-reel-spin var(--dur-reel) var(--ease-reel)
      calc(var(--stagger) * 2 * var(--reel-col, 0)) both;
  }

  .reel__digit {
    display: block;
  }

  /* No `to` keyframe, on purpose. An omitted `to` takes the element's own
     computed value as the end state, and that value is the resting transform
     above. The landing position is therefore written exactly once, by JavaScript,
     as a digit index; a `to` here would mean repeating that arithmetic in a
     second place and keeping the two in step by hand.

     The shorthand is allowed here because the keyframe name is a literal. If it
     ever moves behind a custom property, split this into the four longhands: with
     a token in the name slot, `both` and `none` are both valid for two different
     properties and the shorthand resolves the ambiguity the wrong way. That trap
     is documented at the top of this file and again in components.css.

     `both` is load-bearing rather than habitual. Without the backwards half, a
     column would paint its resting position, which is the true digit, for the
     length of its stagger delay, then jump back to zero and spin. On the second
     and third columns that reads as a glitch. */
  @keyframes bsd-reel-spin {
    from { transform: translateY(0); }
  }

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

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

    .edition--current {
      box-shadow: none;
      border-inline-start: var(--bw-strong) solid var(--accent);
      padding-inline-start: var(--s-4);
    }
  }

  /* PROGRAMME */

  .programme__body {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    align-items: start;
  }

  .programme__main { grid-column: 1 / span 7; }
  .programme__side { grid-column: 9 / span 4; }

  @media (max-width: 60rem) {
    .programme__body { grid-template-columns: minmax(0, 1fr); }
    .programme__main,
    .programme__side { grid-column: 1 / -1; }
  }

  /* A two-day skeleton schedule with no times in it. Publishing a structure
     without inventing a timetable, the honest version of a programme page
     eight months out. */
  .daylist {
    display: grid;
    gap: var(--s-5);
    list-style: none;
    padding: 0;
  }

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

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

  .daylist__what {
    font-size: var(--fs-lg);
    letter-spacing: -0.015em;
    line-height: var(--lh-snug);
  }

  .daylist__note {
    display: block;
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
    margin-block-start: var(--s-2);
  }

  @media (max-width: 40rem) {
    .daylist > li {
      grid-template-columns: minmax(0, 1fr);
      gap: var(--s-2);
    }
  }

  /* VENUE */

  .venue__body {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-7) var(--gutter);
    align-items: start;
  }

  .venue__main { grid-column: 1 / span 5; }
  .venue__travel { grid-column: 7 / span 6; }

  @media (max-width: 60rem) {
    .venue__body { grid-template-columns: minmax(0, 1fr); }
    .venue__main,
    .venue__travel { grid-column: 1 / -1; }
  }

  /* Travel options as a table, because they are tabular data: mode, place,
     distance. A card grid here would be decoration pretending to be
     structure. */
  .travel-table th:first-child,
  .travel-table td:first-child {
    inline-size: 8rem;
  }

  .travel-table td {
    color: var(--ink-2);
  }

  .travel-table td:first-child {
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink);
  }

  /* REGISTER / TICKETS */

  .tiers {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--gutter);
    margin-block-start: var(--s-7);
  }

  .tier {
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    padding-block-start: var(--s-5);
    border-block-start: var(--bw-strong) solid var(--ink);
  }

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

  .tier__price {
    font-size: var(--fs-3xl);
    line-height: 1;
    letter-spacing: var(--track-3xl);
    font-weight: var(--fw-medium);
    font-variant-numeric: tabular-nums;
  }

  /* The currency symbol set smaller and raised, standard practice in price
     setting, and it stops the ₹ from dominating the figure. */
  .tier__currency {
    font-size: 0.55em;
    vertical-align: 0.42em;
    margin-inline-end: 0.08em;
    color: var(--ink-3);
    letter-spacing: 0;
  }

  /* Every price on this page is the first of three stages, so "from" is not
     decoration: without it the page quotes the cheapest number as the price.
     Set as a mono label above the figure, on its own line, where it cannot be
     read as part of the number. */
  .tier__from {
    display: block;
    font-family: var(--font-mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
    letter-spacing: var(--track-label);
    color: var(--ink-3);
    margin-block-end: var(--s-2);
  }

  .tier__rise {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    color: var(--ink-3);
    font-variant-numeric: tabular-nums;
  }

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

  /* THE COMPETITOR PASS, and the reason it is not a fourth .tier. It admits
     one person to day one and the CTF final and to nothing else, and only a
     player who qualified online can buy it. Sat in that row of three it would
     read as "the cheapest way into the conference", which is the one thing it
     is not. So it is a note under the grid: a hairline, a mono label in the
     side column, and the price set in the body rather than at display size,
     because a subordinate offer should not out-shout the three real prices. */
  .tiers-aside {
    display: grid;
    grid-template-columns: minmax(0, 3fr) minmax(0, 9fr);
    gap: var(--s-3) var(--gutter);
    margin-block-start: var(--s-6);
    padding-block-start: var(--s-5);
    border-block-start: var(--bw) solid var(--rule);
  }

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

  .tiers-aside__body {
    display: grid;
    gap: var(--s-3);
    max-width: 62ch;
  }

  .tiers-aside__title {
    font-size: var(--fs-lg);
    line-height: var(--lh-snug);
    font-variant-numeric: tabular-nums;
  }

  .tiers-aside__price {
    font-family: var(--font-mono);
    letter-spacing: var(--track-mono);
    color: var(--accent);
  }

  @media (max-width: 48rem) {
    .tiers-aside {
      grid-template-columns: minmax(0, 1fr);
      gap: var(--s-4);
    }
  }

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

    .tier {
      padding-block: var(--s-4);
      border-block-start: 0;
      border-block-end: var(--bw) solid var(--rule);
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      align-items: baseline;
      gap: var(--s-2) var(--s-4);
    }

    .tier__name  { grid-column: 1; grid-row: 1; }
    .tier__price { grid-column: 2; grid-row: 1; font-size: var(--fs-2xl); }
    .tier__rise  { grid-column: 1 / -1; grid-row: 2; }
    .tier__note  { grid-column: 1 / -1; grid-row: 3; }

    /* Inline here, so the price cell stays one line and the row's baseline
       alignment with the tier name still means something. */
    .tier__from {
      display: inline;
      margin-block-end: 0;
      margin-inline-end: 0.4em;
    }
  }

  /* CFP BAND */

  /* The one saturated surface on the page. It carries a single message and
     one action, and it earns the colour by being the thing we most want a
     visitor to do. */
  .cfp-band__body {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    gap: var(--s-6) var(--gutter);
    align-items: end;
  }

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

  .cfp-band__side {
    grid-column: 9 / span 4;
    display: grid;
    gap: var(--s-4);
  }

  /* The computed CFP state line. All three ship visible; main.js hides the
     two that do not apply. */
  .cfp-band__status {
    color: var(--accent-ink);
  }

  .cfp-band__dates {
    display: grid;
    gap: var(--s-3);
    font-family: var(--font-mono);
    font-size: var(--fs-sm);
    letter-spacing: var(--track-mono);
    color: color-mix(in oklab, var(--accent-ink) 80%, var(--accent));
  }

  .cfp-band__dates > div {
    display: flex;
    justify-content: space-between;
    gap: var(--s-4);
    padding-block-end: var(--s-3);
    border-block-end: var(--bw) solid color-mix(in oklab, var(--accent-ink) 25%, transparent);
  }

  .cfp-band__dates b {
    color: var(--accent-ink);
    font-weight: var(--fw-regular);
  }

  @media (max-width: 60rem) {
    .cfp-band__body { grid-template-columns: minmax(0, 1fr); }
    .cfp-band__main,
    .cfp-band__side { grid-column: 1 / -1; }
  }

  /* VOLUNTEER / SPONSOR TEASERS */

  /* `.split` used to live here. It now sits in components.css, because the CFP,
     villages and sponsors pages all need it and this file's own rule at the
     top says a pattern used by more than one page does not belong here. */

  /* Sponsor tiers as a plain strip of names. No logos to show yet, and a
     grid of empty grey rectangles labelled "your logo here" is the single
     most common way a conference site looks unfinished. */
  .tier-strip {
    display: flex;
    flex-wrap: wrap;
    gap: 0;
    list-style: none;
    padding: 0;
    margin-block-start: var(--s-6);
    border-block-start: var(--bw) solid var(--rule);
    border-block-end: var(--bw) solid var(--rule);
  }

  .tier-strip > li {
    flex: 1 1 10rem;
    margin-block-start: 0;
    padding: var(--s-4) var(--s-5) var(--s-4) 0;
    display: grid;
    gap: var(--s-2);
    align-content: start;
  }

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

  .tier-strip__name {
    font-size: var(--fs-lg);
    letter-spacing: -0.015em;
    font-weight: var(--fw-medium);
  }

  .tier-strip__note {
    font-family: var(--font-mono);
    font-size: var(--fs-xs);
    line-height: var(--lh-snug);
    letter-spacing: var(--track-mono);
    color: var(--ink-3);
  }

  /* SMALL SHARED BITS */

  /* `.note-sm` and `.section-more` also used to live here, and moved to
     components.css for the same reason as `.split`. */

  /* The tickets CTA follows either the tier grid or, now that the competitor
     pass sits between them, the aside. Both selectors, same gap. */
  .tiers + .empty-state,
  .tiers-aside + .empty-state { margin-block-start: var(--s-7); }
}
