/* ==========================================================================
   00-tokens.css — THE PALETTE, THE FACES AND THE FRAME'S GEOMETRY

   The stylesheet header and the whole :root block — palette slots, semantic
   aliases, the four locked and free font stacks, the rainbow gradients, and the
   frame's pitch and tooth tokens. The components that READ these live everywhere
   else; nothing that paints belongs here.

   ORDER AND LOCKS
   First, always. The header comment describes the sheet that follows it, and
   every month sheet in /assets/themes/ re-points these same slots after main.css
   loads. Never cut inside the block — several tokens carry multi-paragraph
   comments between declarations.

   RULES · style/tokens.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* Agoramores — main stylesheet.
   Structure, typography, base elements, components, the breadcrumb/tarot bar,
   ambient scenery defaults and responsive rules. The July / Tokyo Terminal
   palette lives here in :root because it is the base every other month
   overrides — there is no july.css.
   Month overrides live in /assets/themes/<month>.css and load after this file. */

/* Agoramores july / Tokyo Terminal web theme */

/* THE SLOT NAMES ARE HISTORICAL, NOT CLAIMS ABOUT COLOUR (2026-08-15).
   Six of these tokens are named for hues the no-blue-light rule now bars —
   cyan, blue, purple, pink, teal. They survive as NAMES because every month
   sheet re-points them (october has assigned `--tt-cyan: var(--oct-bone)`
   for a year), so they are structural slots: an accent, a soft accent, a
   dark structural tone. Read `--tt-cyan` as "slot 1", not as cyan. Renaming
   them would rewrite roughly a thousand references across sixteen sheets to
   change nothing on screen, so it was not done — but do not add a seventh
   misnamed slot, and do not "restore" one of these to the hue it is named
   after. See DUH/rules/presentation.md.

   July's own palette is now an amber terminal rather than a cyan one: the
   ground is true black, the ink is the same yellow it always was, and the
   accents run the permitted arc from scarlet through amber to phosphor
   green. Nothing here emits more blue than it does red or green. */
:root {
  /* True black. The rule bars white, not black, and this theme is the one
     that most wants the floor: a night terminal, zero emission behind the
     glyphs, maximum contrast under the glow. */
  --tt-bg: #000000;
  --tt-bg-alt: #291c12;
  --tt-cyan: #b0d68f;          /* slot 1 — the structural accent, borders */
  --tt-cyan-soft: #D5E7C4;     /* slot 1 soft — secondary text */
  --tt-yellow: #FFE61C;        /* the ink */
  --tt-orange: #fe8019;
  --tt-green: #A3FF8C;
  --tt-chartreuse: #7fff00;
  /* Slots 2 and 3 used to be hotpink and a hot magenta-red, close in hue and
     far apart in temperature. Their blue-free counterparts landed almost on
     top of each other (#ff7675 and #ff5e5d — two corals, indistinguishable
     in a gradient), so they were re-spaced by hand: salmon for the soft one,
     scarlet for the hot one. */
  --tt-pink: #ff9a7a;          /* slot 2 — the soft hot accent */
  --tt-red: #ff4f45;           /* slot 3 — the loud one */
  --tt-purple: #e08a52;        /* slot 4 — burnt amber */
  --tt-blue: #c89a4e;          /* slot 5 — brass */
  --tt-teal: #4a6842;          /* slot 6 — deep olive, structure only */
  --tt-gray: #9f988d;
  --tt-gold: #E3E9AE;
  --tt-breadcrumb-text: #ff9a7a;
  --tt-breadcrumb-glow: rgba(255, 154, 122, 0.7);
  /* Halo that traces the zodiac GLYPH on hover/focus. Separate from
     --tt-breadcrumb-glow because several months set that to `transparent`,
     which would leave their chips with no hover affordance at all. Months
     override this one value; the rule itself lives once, below. */
  --zodiac-glow: var(--tt-breadcrumb-glow);
  /* How loud the named sky is. One number for the whole constellation layer,
     so a month tunes it in a line rather than restating five colours. Whether
     it is drawn at all is a separate decision and is made by lighting; see
     .night-sky__constellations. */
  --constellation-strength: 0.72;
  /* One offset shadow under a character, which is the whole of an engraving:
     the eye reads the line below a glyph as the lit lip of a cut. A single
     token rather than a pair, because the direction is all that changes — a
     dark card is cut into with black below, a light card with white — so a
     month flips it in one line instead of four. Used on the tarot card's
     numeral and its trail separators; nowhere with a glow already on it. */
  --tt-engrave: 0 1px 0 rgba(0, 0, 0, 0.55);
  /* THE SPECTRUM. A rainbow that has to skip half the wheel is not a rainbow
     with a gap in it — it is a shorter wheel travelled twice as slowly. These
     run scarlet -> amber -> gold -> chartreuse -> phosphor green and back down
     the same road, so the loop still closes and the eye still reads travel.

     Every stop is held between L 0.70 and L 0.90 in OKLCH. That is the whole
     trick: the first blue-free version of this gradient inherited a bronze
     and a terracotta at L 0.50, and two dark stops in a bright ramp read as
     dirt on the letterform rather than as colour. Keep new stops in the band. */
  --tt-heading-rainbow: linear-gradient(90deg, var(--tt-red), var(--tt-purple), var(--tt-yellow), var(--tt-chartreuse), var(--tt-green), var(--tt-cyan), var(--tt-pink), var(--tt-red));
  --tt-heading-rainbow-vertical: linear-gradient(180deg, var(--tt-red), var(--tt-purple), var(--tt-yellow), var(--tt-chartreuse), var(--tt-green), var(--tt-cyan), var(--tt-pink), var(--tt-red));
  --tt-heading-text-rainbow: linear-gradient(
    90deg,
    var(--tt-red),
    var(--tt-orange),
    var(--tt-blue),
    var(--tt-yellow),
    var(--tt-chartreuse),
    var(--tt-green),
    var(--tt-cyan),
    var(--tt-pink),
    var(--tt-red),
    var(--tt-orange),
    var(--tt-blue),
    var(--tt-yellow),
    var(--tt-chartreuse),
    var(--tt-green),
    var(--tt-cyan),
    var(--tt-pink),
    var(--tt-red)
  );
  --bg: var(--tt-bg);
  --surface: var(--tt-bg-alt);
  --text: var(--tt-yellow);
  --text-soft: var(--tt-cyan-soft);
  --muted: var(--tt-gray);
  --border: var(--tt-cyan);
  --border-dim: rgba(176, 214, 143, 0.24);
  --border-teal: rgba(74, 104, 66, 0.72);
  --font-mono: "JetBrains Mono", Menlo, SFMono-Regular, Consolas, "Fira Code", "Roboto Mono", "Source Code Pro", monospace;
  /* LOCKED — the text font, and it is the same one on every theme (2026-08-15,
     user instruction). Body copy is not a theme decision. A reader who arrives
     in september and leaves through february should never have re-learned how
     to read on the way; the month changes the ground, the palette, the heading
     and the ornament, and the paragraph under all of it stays put.

     Replaced --font-sans, which four sheets were re-pointing at their own text
     face (september Palatino, november Iowan Old Style, january Helvetica Neue,
     february Georgia). Those four are gone; the token was renamed as part of
     removing them so that a sheet re-pointing the old name fails loudly by
     doing nothing visible, rather than quietly winning.

     The face is chosen for uncorrected mild myopia — sharp up close, blurred at
     distance — so it is picked for what survives defocus, not for elegance at
     100%. Lucida Grande has low stroke contrast (nothing thins out and drops),
     open apertures (c/e/s/a stay open instead of blurring shut into o), a large
     x-height and generous counters. Optima, the face this replaces, fails
     exactly here: flared stems and real thick/thin modulation are the first
     thing to disappear out of focus.

     The stack is all one category — every fallback is itself a myopia face, so
     a machine missing the first pick degrades to another legible humanist and
     never to a neo-grotesque. Lucida Grande is macOS, Lucida Sans Unicode is
     Windows, Verdana is the universal floor. See
     .github/agents/style/fonts.md — the font catalogue. */
  --font-text: "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Seravek, "Segoe UI", "Avenir Next", Verdana, sans-serif;

  /* Free. The theme-expressive display slot — seven sheets re-point it.

     The tail from "Noto Sans" on is the phone. This site ships no webfont, so a
     stack listing only desktop faces resolves to the GENERIC on Android, and a
     generic is whatever the vendor mapped it to rather than a face anyone
     chose. Optima is a flared humanist sans and Android ships nothing of the
     kind — no Optima, no Avenir, no Segoe UI, no Helvetica Neue — so heading
     text there lands on Noto Sans, named here rather than reached through
     Arial's alias. Nobody on a Mac or a PC sees any difference: those entries
     are never reached. */
  --font-heading: Optima, "Avenir Next", "Segoe UI", "Helvetica Neue", "Noto Sans", Roboto, Arial, sans-serif;

  /* ---- The three category slots -----------------------------------------
     Every font added to this project is filed as cursive, stoic or myopia, and
     each category gets a live slot a sheet may spend. The catalogue of faces
     available to each — with the availability and blur notes that decide
     between them — is .github/agents/style/fonts.md. All three are FREE to
     re-point per theme; the locks on this site are --font-text above and
     --font-brand-cursive below, and nothing else. */

  /* cursive — script and hand faces. Consumed by markdown H1.

     Snell Roundhand and Apple Chancery are the iOS/macOS script faces, and they
     are the reason a markdown H1 on an iPhone is a script at all rather than
     the body sans wearing a fake italic. february.css has carried this same
     tail on its own H1 for months; this is that stack, generalised. */
  --font-cursive: "Segoe Script", "Lucida Handwriting", "Brush Script MT", "Snell Roundhand", "Apple Chancery", "Dancing Script", cursive;

  /* stoic — inscriptional, ancient, tribal, tarot. Roman stone capitals and
     engraved gothics: caps-driven, wide-tracked, no true lowercase in several
     of them. The default sheet spends this on its H1 and had been holding this
     stack in --font-cursive, which is why a category slot exists — a Trajan
     column is not a handwriting face and the two should never have shared a
     token. Herculanum, Luminari and Papyrus are in the catalogue as alternates,
     deliberately not in this shared default. */
  --font-stoic: "Trajan Pro", Cinzel, Copperplate, "Copperplate Gothic Bold", Optima, "Palatino Linotype", Palatino, "Book Antiqua", "Times New Roman", serif;

  /* myopia — the legibility category, for a sheet that wants a heading or a
     piece of chrome to read at distance. Holds the same stack as --font-text
     rather than a reference to it, the same way --font-brand-cursive holds a
     copy of the cursive stack: they are one face today and may diverge, and a
     reference would mean re-pointing this silently re-set the body copy that
     is supposed to be locked. */
  --font-myopia: "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Seravek, "Segoe UI", "Avenir Next", Verdana, sans-serif;
  /* LOCKED — the Agoramores wordmark face. Deliberately a SECOND token holding
     the same stack as --font-cursive rather than a reference to it. A theme is
     free to re-point --font-cursive for its markdown h1 (the default sheet
     spends it on an inscriptional Trajan stack), and when it did, the brand
     went with it and the classic script wordmark disappeared. The brand is not
     a theme decision: it is the one string on the site that says what this
     place is, and it is set in this script on every theme, in every mode, at
     every width. No sheet in public/themes/ may declare font-family,
     font-style or text-transform on .brand a. See
     .github/agents/style/invariants.md — "The Wordmark Is Locked". */
  /* THE LOCK SAID "AT EVERY WIDTH" AND THE PHONE WAS NOT HONOURING IT.
     Segoe Script is Windows, Lucida Handwriting is Windows, Brush Script MT is
     macOS and Windows. iOS has none of the three, so every iPhone fell through
     to the generic `cursive` — and Android, which maps `cursive` to nothing at
     all, rendered the wordmark in the body sans. The one string on the site
     that says what this place is was not a script on a phone.

     Snell Roundhand and Apple Chancery are on every iPhone and iPad; Dancing
     Script is the name Android maps `cursive` to where it maps it to anything.
     They are appended, never inserted: the desktop order is untouched, so a
     Mac still gets Brush Script MT and a PC still gets Segoe Script exactly as
     before, and only a device that has none of the three reaches the new
     entries.

     NOT FIXED, and it needs saying: a stock Android with no Dancing Script
     still has no script face to give, and the wordmark there falls to the body
     sans. Closing that needs a self-hosted @font-face, which this site has
     decided against — see the note on the stacks above. Owned by /handwriting;
     this changes the stack only and does not touch the drawn-mark question. */
  --font-brand-cursive: "Segoe Script", "Lucida Handwriting", "Brush Script MT", "Snell Roundhand", "Apple Chancery", "Dancing Script", cursive;

  /* The hyperlink hover colour, standing user instruction 2026-08-14: #FF465A,
     the RGB midpoint of scarlet (#FF2400) and hot pink (#FF69B4). Referenced by
     `a:hover`/`a:focus-visible` here, so a fourteenth theme inherits it rather
     than needing a manual edit. Resting and :visited colours are untouched;
     this is the hover/focus state only.

     NOT locked, and deliberately so — corrected 2026-08-14 after this token
     was first written as un-re-pointable. #FF465A measures 5.20–6.28:1 on the
     dark grounds but only 2.31–3.35:1 on the light ones (september #ded5ca
     2.31, august #ffd36b 2.36, default #e7dcc2 2.46, july-day #e9ecf2 2.83,
     october #e6eef6 2.86, december/february ~3.02, may/june #fff 3.35) against
     a 4.5:1 floor. No hue-preserving rose clears both families, so the value is
     **paired to the ground**: #FF465A where the ground is dark, #BD0014 (same
     354° hue, lightness 37%) where it is light — 4.56–6.62:1 there.

     A sheet therefore MAY and MUST re-point this token in any block that
     establishes a light ground, and must set --link-hover-glow with it. Pair it
     to the measured background, never to the mode name: data-mode is not a
     light/dark switch (see the switch commentary further down — the unmoded
     block is each theme's own canonical lighting, "July at night, October at
     night, January by day"), so keying off `[data-mode="day"]` gets it wrong
     for january and right for july purely by accident. */
  --link-hover: #FF4947;
  /* The glow's colour channels, as an "r, g, b" triple so alpha can vary per
     shadow layer. Tracks --link-hover and must be re-pointed alongside it, or a
     #BD0014 link on a light ground keeps a rose bloom that no longer matches. */
  --link-hover-glow: 255, 70, 90;

  /* ---------------------------------------------------------------------------
     THE FRAME'S NUMBERS, declared once for all four edges and all fourteen
     sheets. Retune them here; do not restate them at a call site, and do not
     re-declare them in a month sheet — the frame is one mark on one element and
     the only thing a month is meant to change about it is the ramp it paints
     with, which it already owns as --tt-heading-rainbow / -vertical.

     THE PITCH IS A FUNCTION OF THE WINDOW (2026-08-18, user instruction:
     "borders always need to be perfect like this. make border smaller or larger
     based on window size, do not have them ever overlap."). It was a flat 16px,
     and a flat 16px is not the same mark at both ends of the range a browser
     window actually spans: 240 teeth of confetti across a 3840px display, 24
     chunky ones across a phone. Sizing it off the window's long side keeps the
     tooth COUNT — and so the read — roughly constant instead, and clamp() stops
     it vanishing at the small end or turning into bunting at the large one.
     1.1vmax lands on 15.84px at 1440px wide, which is where the mark was tuned.

     THE SHAPE IS FIXED BY A RATIO, NOT BY A SECOND LENGTH. --frame-depth is not
     a number here at all; it is derived on the frame element as ratio × pitch,
     so a tooth is the same triangle at every window size rather than getting
     stubbier as the pitch grows. 0.4375 is 7/16 — the 7px depth against the
     16px pitch this mark started as, kept exactly.

     That ratio is also what makes a collision impossible at any size, which is
     the "do not have them ever overlap" half of the instruction. Flanks at
     45deg make a tooth 2 × depth wide at its base, so at 0.4375 a base is 0.875
     of a pitch and an eighth of a pitch of clear ground always separates one
     base from the next — on both axes, at every window size, including after
     the per-axis snap has pulled a pitch in. Above 0.5 adjacent teeth run into
     each other; that is the one number here with a hard ceiling.

     The 45deg flank is the load-bearing angle rather than any of these lengths:
     it is what makes a tooth's flank parallel to the corner diagonal, which is
     what lets two edges tile a corner square exactly instead of overlapping in
     it. Change the clamp or the ratio freely; changing the flank angle takes
     the corners with it. */
  --frame-pitch: clamp(12px, 1.1vmax, 24px);
  --frame-tooth-ratio: 0.4375;

  /* One 90deg wedge per edge, point on the OUTER edge of its band, used as a
     mask rather than as paint. Declared here so the four edge layers read as
     four positions of one motif instead of four gradients. The point sits at
     the CENTRE of the tile in the direction the band runs, which is why every
     layer in THE FRAME is offset half a tile — that offset is what moves the
     apexes onto 0 and onto each corner. */
  --frame-tooth-top: conic-gradient(from 135deg at 50% 0, rgba(0, 0, 0, 1) 0 90deg, transparent 0);
  --frame-tooth-right: conic-gradient(from 225deg at 100% 50%, rgba(0, 0, 0, 1) 0 90deg, transparent 0);
  --frame-tooth-foot: conic-gradient(from 315deg at 50% 100%, rgba(0, 0, 0, 1) 0 90deg, transparent 0);
  --frame-tooth-left: conic-gradient(from 45deg at 0 50%, rgba(0, 0, 0, 1) 0 90deg, transparent 0);
  color-scheme: dark;
}

/* ==========================================================================
   01-sky.css — THE GROUND, AND EVERYTHING BEHIND THE TEXT

   The universal reset, the body ground and its fixed scanline, then the night
   sky: two parallax star sheets, the rotating constellation disc, the three-stage
   reveal, the day/night lighting gate and the aurora band.

   ORDER AND LOCKS
   Bare `*`, `html` and `body` open this file and must stay near the top of the
   concatenation — the second bare `body` rule in 02-flyway.css is only safe while
   this file precedes it. Do not split the base star fill from the faint/bright
   pair above it, or the [data-mode="night"] rule that wins on source order.

   July paints its scenery INTO slots declared here: `.night-sky__fireworks`
   shares a positioning rule with `.night-sky__stars`, and the `.night-sky__aurora`
   base closes this file. 03-july.css only paints into them — the bases are here.

   RULES · style/sky.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }

/* The root also carries the colour-vision simulation when one is running — the
   rule is at THE COLOUR VISION FILTER, at the foot of this sheet, along with
   the reason it can be mounted nowhere else. Nothing here has to know about it;
   with no filter chosen the attribute is absent and so is the rule. */
html { min-height: 100%; scrollbar-color: var(--tt-teal) transparent; }

body {
  min-height: 100vh;
  margin: 0;
  overflow-x: hidden;
  background:
    radial-gradient(ellipse at 62% 38%, rgba(176, 214, 143, 0.02), transparent 34rem),
    linear-gradient(180deg, #010000 0%, #040101 48%, var(--tt-bg) 72%, #020000 100%);
  color: var(--text);
  font-family: var(--font-text);
  font-size: 16px;
  line-height: 1.72;
  letter-spacing: 0.01em;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  background: repeating-linear-gradient(0deg, rgba(176, 214, 143, 0.018) 0, rgba(176, 214, 143, 0.018) 1px, transparent 1px, transparent 4px);
}

.night-sky {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background:
    radial-gradient(ellipse at 50% 111%, rgba(255, 118, 117, 0.09), transparent 28%),
    radial-gradient(ellipse at 24% 108%, rgba(190, 103, 65, 0.08), transparent 24%),
    radial-gradient(ellipse at 78% 109%, rgba(176, 214, 143, 0.06), transparent 26%),
    linear-gradient(118deg, transparent 10%, rgba(176, 214, 143, 0.055) 32%, rgba(190, 103, 65, 0.16) 46%, rgba(255, 118, 117, 0.08) 52%, rgba(213, 231, 196, 0.065) 62%, transparent 86%);
}

.night-sky::before {
  content: "";
  position: absolute;
  inset: -2rem;
  opacity: 0.9;
  background-image:
    radial-gradient(circle at 18% 26%, rgba(242, 230, 210, 0.96) 0 1.1px, transparent 1.7px),
    radial-gradient(circle at 74% 16%, rgba(213, 231, 196, 0.9) 0 1px, transparent 1.55px),
    radial-gradient(circle at 42% 78%, rgba(242, 230, 210, 0.78) 0 0.9px, transparent 1.4px),
    radial-gradient(circle at 84% 58%, rgba(255, 230, 28, 0.72) 0 0.9px, transparent 1.4px),
    radial-gradient(circle at 57% 35%, rgba(242, 230, 210, 0.7) 0 0.7px, transparent 1.25px);
  background-size: 12rem 13rem, 16rem 14rem, 21rem 18rem, 27rem 24rem, 9rem 10rem;
  animation: starDrift 36s linear infinite alternate, starTwinkle 4.8s ease-in-out infinite;
  transform: translate3d(0, 0, 0);
}

.night-sky__stars, .night-sky__fireworks {
  position: absolute;
  inset: -4rem;
}

.night-sky__stars {
  z-index: 1;
  transform: translate3d(0, 0, 0);
}

.night-sky__stars--far {
  opacity: 0.72;
  background-image:
    radial-gradient(circle, rgba(242, 230, 210, 0.92) 0 0.8px, transparent 1.1px),
    radial-gradient(circle, rgba(213, 231, 196, 0.78) 0 0.7px, transparent 1.05px),
    radial-gradient(circle, rgba(242, 230, 210, 0.58) 0 0.55px, transparent 0.95px);
  background-size: 7rem 9rem, 11rem 13rem, 5rem 6rem;
  background-position: 1rem 2rem, 5rem 7rem;
  animation: farStarDrift 90s linear infinite alternate, farStarTwinkle 7s ease-in-out infinite;
}

.night-sky__stars--near {
  opacity: 0.92;
  background-image:
    radial-gradient(circle, rgba(242, 230, 210, 1) 0 1.2px, transparent 1.7px),
    radial-gradient(circle, rgba(255, 230, 28, 0.84) 0 0.95px, transparent 1.45px),
    radial-gradient(circle, rgba(213, 231, 196, 0.94) 0 1px, transparent 1.5px),
    radial-gradient(circle, rgba(242, 230, 210, 0.78) 0 0.75px, transparent 1.25px);
  background-size: 13rem 15rem, 19rem 17rem, 23rem 21rem, 8rem 9rem;
  background-position: 3rem 1rem, 12rem 8rem, 6rem 15rem;
  animation: nearStarDrift 58s linear infinite alternate, nearStarTwinkle 3.8s ease-in-out infinite;
}

/* ---------------------------------------------------------------------------
   THE CONSTELLATIONS.

   The two layers above are tiled noise, and are right to be: a sky read out of
   the corner of the eye is anonymous. This one is the part of it that has
   names — twenty-eight figures at their real right ascensions and declinations,
   built by `node scripts/build-constellations.mjs` and inlined by main.js. See
   that script for the projection; nothing here moves a star.

   IT IS POINTING THE RIGHT WAY. The chart is drawn for local sidereal time 0h
   and the bootstrap stamps --sky-rotation from the reader's own clock and
   timezone meridian, so the Dipper is where the Dipper is when the page loads.
   The turn continues from there at the sidereal rate — one revolution in
   86164 seconds, which is a sidereal day and is the only honest number for it.
   Nobody will catch it moving. That is the point: it is not an animation of a
   sky, it is a sky.

   TWO ROTATIONS, TWO PROPERTIES, ON PURPOSE. `rotate` carries the stamped
   angle and `transform` carries the turn. They are separate properties that
   compose, so the animation never has to know tonight's angle and the stamp
   never has to be re-applied. Writing both into `transform` would have one
   overwrite the other.

   THE DISC IS BIGGER THAN THE VIEWPORT, and it has to be: it is a wheel about
   the pole, and a wheel that stops inside the frame shows its rim the moment
   it turns. At 132vmax the celestial equator lands just past the left and
   right edges, which puts the circumpolar figures — the Dipper, Cassiopeia,
   Draco, Cepheus — across the page where they belong, and sweeps Orion, Leo
   and Aquila through the margins as the night runs. The mask fades what is
   left before the rim can be reached at any aspect ratio. */
.night-sky__constellations {
  position: absolute;
  z-index: 1;
  top: 50%;
  left: 50%;
  width: var(--sky-disc);
  height: var(--sky-disc);
  margin: calc(var(--sky-disc) / -2) 0 0 calc(var(--sky-disc) / -2);
  rotate: var(--sky-rotation, 0deg);
  animation: skyTurn 86164s linear infinite;
  opacity: var(--constellation-strength);
  /* Toward the rim these are low-altitude stars, and low-altitude stars are
     dim ones. The fade is the horizon murk, and it is also what means no
     aspect ratio ever finds the edge of the disc. */
  -webkit-mask-image: radial-gradient(closest-side, #000 0 46%, rgba(0, 0, 0, 0.72) 68%, rgba(0, 0, 0, 0.28) 84%, transparent 97%);
  mask-image: radial-gradient(closest-side, #000 0 46%, rgba(0, 0, 0, 0.72) 68%, rgba(0, 0, 0, 0.28) 84%, transparent 97%);

  --sky-disc: 132vmax;
  /* Every colour below is a token so a month can dress its own sky without
     touching the geometry. The defaults are the site's own cyan and bone. */
  --constellation-line: rgba(213, 231, 196, 0.5);
  --constellation-star: rgba(242, 230, 210, 0.92);
  --constellation-star-bright: rgba(238, 232, 210, 1);
  --constellation-star-faint: rgba(225, 236, 215, 0.7);
  --constellation-halo: rgba(176, 214, 143, 0.3);
  --constellation-emblem: rgba(255, 232, 186, 0.9);
  /* The lit state. The site's own yellow, which is the colour every instrument
     on the breadcrumb rail already uses for "this one is live". */
  --constellation-emblem-lit: rgba(255, 230, 28, 0.96);
  --constellation-emblem-glow: rgba(255, 230, 28, 0.55);
  --emblem-lit-strength: 0.92;
  /* How much of a star is there before anyone has gone looking for it. Not
     zero: the stars are what tell a reader there is anything here to find. */
  --constellation-rest: 0.3;
  /* And how loud the emblem gets once it has faded up. Deliberately short of
     the lines — it is a gloss on the figure, not the figure. */
  --emblem-strength: 0.62;
}

.constellations__chart {
  display: block;
  width: 100%;
  height: 100%;
}

/* ---------------------------------------------------------------------------
   THE REVEAL.

   At rest this is a star field and nothing else — real stars at their real
   places, and no lines joining them up. Nobody has ever looked at the sky and
   found the lines already drawn.

   A reader who brings the cursor near a figure draws it: the bootstrap works
   out which constellation the pointer is nearest, IN CHART SPACE, through
   whatever angle the sky has turned to, and marks it [data-near]. See the
   proximity block in main.js. One figure is lit at a time, because two are a
   diagram and one is a discovery.

   Then, and only after it has stayed there, the emblem — what the figure is a
   picture of. It comes up slowly and on a delay, so it reads as something
   surfacing rather than something switching on, and it goes away fast, because
   a picture that lingers after the cursor has left is a picture that got stuck.
   That asymmetry is why the slow transition lives on the [data-near] rule and
   the fast one on the resting rule, rather than one transition doing both. */
.constellation__line {
  fill: none;
  stroke: var(--constellation-line);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* Drawn, not faded: the dash runs the length of every subpath so the lines
     travel out from the figure's first star instead of appearing all at once.
     --line-span is generous — a single number has to cover the Dipper and
     Draco both, and an over-long dash simply finishes early. */
  stroke-dasharray: var(--line-span, 900);
  stroke-dashoffset: var(--line-span, 900);
  opacity: 0;
  transition: stroke-dashoffset 260ms ease, opacity 240ms ease;
}

.constellation[data-near] .constellation__line {
  opacity: 1;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 900ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 200ms ease;
}

.constellation__stars {
  opacity: var(--constellation-rest);
  transition: opacity 420ms ease;
}

.constellation[data-near] .constellation__stars {
  opacity: 1;
  transition: opacity 260ms ease;
}

.constellation__stars circle { fill: var(--constellation-star); }
.constellation__stars--faint circle { fill: var(--constellation-star-faint); }

/* The halo on the first-magnitude stars is a wide translucent stroke with the
   fill painted back over it, not a drop-shadow. A filter here would be a
   full-viewport blur region re-resolved behind a transform animation, which is
   a real cost on a phone for a glow nobody would miss. */
.constellation__stars--bright circle {
  fill: var(--constellation-star-bright);
  stroke: var(--constellation-halo);
  stroke-width: 6;
  paint-order: stroke;
}

.constellation__emblem {
  opacity: 0;
  transition: opacity 320ms ease;
}

.constellation__emblem path {
  fill: none;
  stroke: var(--constellation-emblem);
  /* Divided by the placement scale, so a bear scaled to 2.4 and a dolphin
     scaled to 0.9 are drawn with the same weight of line. Without this the
     large emblems would come out as fat as their scale factor. */
  stroke-width: calc(2.6 / var(--emblem-scale, 1));
  stroke-linecap: round;
  stroke-linejoin: round;
}

.constellation[data-near] .constellation__emblem {
  opacity: var(--emblem-strength);
  transition: opacity 1700ms ease 420ms;
}

/* ---------------------------------------------------------------------------
   THE THIRD STAGE — THE FIGURE GOES GOLD, AND BECOMES A DOOR.

   Three things happen in order, and the order is the whole point. The lines
   draw. The emblem surfaces. Then, only for a reader who has stayed with it,
   the emblem warms to gold, lights, and turns into a link to that
   constellation's page in the vault.

   [data-lit] is set by main.js after the emblem has finished arriving — the
   dwell is timed against the transition above, not guessed. Nothing here fires
   on a cursor that swept past.

   THE GOLD IS THE AFFORDANCE, and it is the only one. This obeys the site's
   standing rule that no link advertises itself at rest: the emblem spends its
   whole first two seconds looking like what it is, a drawing, and announces
   itself as a link only under what is functionally a long hover. A reader who
   does not want the page never sees an invitation to it. */
.constellation[data-lit] .constellation__emblem path {
  stroke: var(--constellation-emblem-lit);
  transition: stroke 900ms ease;
}

.constellation[data-lit] .constellation__emblem {
  opacity: var(--emblem-lit-strength);
  /* Divided by the placement scale for the same reason the stroke is: the glow
     is drawn in the emblem's own coordinates, and an emblem scaled to 2 would
     otherwise get twice the halo. */
  filter: drop-shadow(0 0 calc(6px / var(--emblem-scale, 1)) var(--constellation-emblem-glow));
  transition: opacity 900ms ease, filter 900ms ease;
}

/* THE ONE PLACE THE SKY TAKES A CLICK.

   .night-sky sets pointer-events: none over the entire layer so that a
   background never eats a click meant for the page. This is the single
   exception, and it is scoped as narrowly as it can be: one anchor, on one
   figure, only while that figure is lit. The moment the cursor leaves,
   [data-lit] goes and the sky is inert again. */
.constellation__link { pointer-events: none; }

/* The cursor is the other half of the promise the gold makes. It is stamped on
   the root rather than on the anchor because over the card the anchor is not
   what the pointer is touching — main.js decides, by the same test that decides
   the click, and it only ever fires with a figure lit and the cursor inside its
   emblem. `!important` because it has to beat the text cursor of whatever the
   reader is actually hovering over, which is usually a paragraph. */
html[data-sky-link], html[data-sky-link] body { cursor: pointer !important; }

.constellation[data-lit] .constellation__link {
  pointer-events: auto;
  cursor: pointer;
}

/* THE EMBLEM STAYS THE RIGHT WAY UP.

   The stars turn with the sky, because they are the sky. The picture does not:
   an upside-down bear is a shape nobody recognises, and recognising it is the
   entire job. --sky-counter is the negative of whatever angle the layer has
   reached, written by the same handler that finds the nearest figure — so it
   is refreshed on every pointer move, and between moves the sky has turned by
   a quarter of a degree a minute, which nobody has ever noticed.

   `fill-box` puts the origin at the emblem's own centre rather than the chart's,
   which is the difference between the bear turning on the spot and the bear
   orbiting Polaris. */
.constellation__emblem-turn {
  transform-box: fill-box;
  transform-origin: center;
  transform: rotate(var(--sky-counter, 0deg));
}

/* WHICH LIGHTING GETS A SKY.

   Stars are the night — the same rule the month sheets keep for their own star
   fields, and it bites harder here, because a named constellation over a
   daylit ground does not read as quiet decoration, it reads as a mistake.

   The five months whose canonical lighting is daylight are listed by name
   because an unmoded <body> carries no data-mode to test: april, january,
   lava-lamp, march and november all author their NIGHT as the override, so
   their base state is a lit sky and the figures come off. `display` rather than
   opacity, so a hidden chart is off the compositor instead of turning
   invisibly behind a bright page.

   The [data-mode="night"] rule is last on purpose: it ties the specificity of
   the five and wins on source order, so any of them switched to night by the
   reader gets its constellations back. */
body[data-theme-id="april"] .night-sky__constellations,
body[data-theme-id="january"] .night-sky__constellations,
body[data-theme-id="lava-lamp"] .night-sky__constellations,
body[data-theme-id="march"] .night-sky__constellations,
body[data-theme-id="november"] .night-sky__constellations,
body[data-mode="day"] .night-sky__constellations {
  display: none;
}

body[data-mode="night"] .night-sky__constellations {
  display: block;
}

.night-sky__aurora {
  position: absolute;
  z-index: 2;
  left: -2%;
  right: -2%;
  bottom: -1px;
  height: clamp(4.5rem, 12vh, 8.5rem);
  background: linear-gradient(180deg, #0d0605, #030101 72%);
  clip-path: polygon(0 100%, 0 68%, 3% 68%, 3% 42%, 6% 42%, 6% 74%, 9% 74%, 9% 55%, 12% 55%, 12% 26%, 15% 26%, 15% 63%, 19% 63%, 19% 48%, 23% 48%, 23% 76%, 27% 76%, 27% 34%, 30% 34%, 30% 59%, 34% 59%, 34% 18%, 37% 18%, 37% 68%, 41% 68%, 41% 45%, 45% 45%, 45% 72%, 49% 72%, 49% 31%, 53% 31%, 53% 59%, 57% 59%, 57% 39%, 61% 39%, 61% 69%, 65% 69%, 65% 23%, 69% 23%, 69% 61%, 73% 61%, 73% 49%, 77% 49%, 77% 75%, 81% 75%, 81% 35%, 85% 35%, 85% 64%, 89% 64%, 89% 46%, 93% 46%, 93% 70%, 97% 70%, 97% 54%, 100% 54%, 100% 100%);
  box-shadow: 0 -2rem 5rem rgba(255, 118, 117, 0.08);
}

/* ==========================================================================
   02-flyway.css — THE PASSAGE LAYER, END TO END

   The gate, the migration map and its deliberately theme-independent palette, the
   phase tokens, continent, grounds, home state, corridors, labels and birds, the
   step-back that dims the sky while the map is open, and the in-body migration
   card and phenology chart that read it.

   ORDER AND LOCKS
   Three equal-specificity pairs are decided by source order and must not be
   separated: the corridor trace and lit rules, and the two label pairs. Never cut
   inside the @supports block — it nests two @keyframes. The triple
   [data-flyways] quiet-down block must stay inside main.css so it still outranks
   the month sheets that load after it; it is the rule documented in
   style-theme.agent.md's Scenery Slots table, so do not restate it elsewhere.
   The closing phone query must stay after the card and list rules it overrides.

   RULES · style-theme.agent.md (Scenery Slots)
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   THE PASSAGE LAYER — daytime scenery.

   By night the ground element is the sky. By day it is the continent under it,
   and what is crossing it: the United States, Canada, the Caribbean and
   northern Mexico, with Ohio picked out, and the corridors that carry birds
   through Ohio drawn over the top.

   IT USED TO BE THE FOUR USFWS ADMINISTRATIVE FLYWAYS, WHICH IS WHY EVERYTHING
   HERE IS STILL CALLED `flyways__`. The class names are historical. They now
   mean "the passage layer", and the layer is built for four taxa rather than
   one — birds behind every page, and bats, butterflies and dragonflies through
   the `<!-- migration -->` marker. Renaming four hundred lines of tuned
   selectors to say so would risk the tuning to buy a better word, so the word
   stayed. The four flyway species lists were not thrown away either; they are
   archived at the foot of data/migration/birds.mjs.

   The geometry is generated, not drawn. Every coordinate in it is a real
   lon/lat put through an Albers conic; see scripts/lib/passage-geo.mjs and
   scripts/build-migration.mjs. Nothing below moves a coastline. This file
   decides only what the season does to it.

   WHAT THE SEASON DOES.

   body[data-migration] carries one of four phases and --migration-intensity
   carries how hard that phase is running, both stamped by the server and
   re-derived from the reader's clock. Together they give the layer a year:

     northbound  Feb 15 - May 31   dashes climb, corridors lit, north warming
     breeding    Jun 1  - Jul 14   corridors nearly still, the north lit
     southbound  Jul 15 - Nov 30   dashes fall, corridors lit, south warming
     wintering   Dec 1  - Feb 14   corridors nearly still, the south lit

   Every corridor is authored north to south in the generated file, which is
   what makes the reversal a one-line affair: the sign of the dash animation
   and the direction the birds ride are the whole difference between April and
   October. No rule here needs to know which corridor it is looking at.

   The intensity ramps rather than steps, so the layer is not four fixed
   pictures with three jumps between them — mid-May is visibly past the peak
   and mid-March is visibly short of it.
   --------------------------------------------------------------------------- */

/* ASKED FOR, NOT IMPOSED.

   This was first built to replace the daytime ground outright, which was the
   wrong call: every month has spent real effort on its own daylight, and a
   continent laid over all twelve of them takes that away wholesale. Day is day.
   The map appears when the reader asks for it, by the bird on the instrument
   rail, and body[data-flyways="on"] is that ask.

   It is deliberately not conditioned on the lighting. The passage is happening
   at four in the morning too, and a reader who has gone to the dark side has
   not stopped being interested in it. Every colour below comes from the
   month's own tokens, so the layer arrives dressed for whichever lighting it
   lands in. */
.flyways { display: none; }

body[data-flyways="on"] .flyways {
  display: block;
  position: absolute;
  z-index: 1;
  inset: 0;
}

/* The map keeps its whole self on screen — `meet`, not `slice`. A background
   that crops Alaska off a narrow viewport is a background that has stopped
   being a map of anything. */
.flyways__map {
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* THE ONE NUMBER THAT KEEPS THIS READABLE.

   Everything below is scaled by --flyway-ink, and --flyway-ink is small. This
   layer sits behind running text, and the text wins every argument: the
   continent lands near a twelfth of full strength and the busiest corridor
   near a fifth. It is meant to be noticed on the second look, not the first,
   and no seasonal state is permitted to push it past that. --migration-intensity
   modulates within this ceiling; it never raises it. */
/* ONE PALETTE, EVERY MONTH. DELIBERATELY OFF THE THEME TOKENS.

     This layer used to dress itself in --tt-red / --tt-gray / --tt-gold so it
     would wear whatever month it landed in. That was coherent and it was hard
     to read: the map changed colour twelve times a year, so nothing you
     learned about reading it in August still applied in January, and a reader
     comparing two months was comparing two colour schemes as well as two
     seasons. The map is a chart now, and a chart holds still.

     TEMPORARY BY INTENT. To hand the layer back to the months, the only edit
     is this block — restore --flyway-* to var(--tt-red) / var(--tt-gray) /
     var(--tt-gold), and the label rule below to fill var(--tt-cyan) with a
     stroke of var(--bg). Nothing else in the layer reads a theme token.

     AND IT BUYS THE SEASON ITS COLOUR BACK. Off the theme tokens the obvious
     scheme — spring green, autumn amber, winter blue — was unusable, because
     those names are palette SLOTS rather than colours: August by day puts
     --tt-green at #b0431f, a rust red, and --tt-blue on violet, so all four
     seasons came out four shades of one hue. Fixed values have no such
     problem, so the season now carries the hue everyone already expects, and
     the form cues that were doing the whole job on their own — dash direction,
     dash density, which ground is lit — become confirmation rather than the
     only evidence.

   Every value below is mid-value and mid-saturation on purpose: this layer
   opens over near-white January and near-black August alike, and these have to
   hold on both grounds without a light and a dark variant.

   Declared on BODY rather than on .flyways because the bird on the instrument
   rail is not inside the map it opens, and the gauge and the chart have to
   agree about what colour October is. One declaration, both readers. */
body {
  --flyway-passage: #7f7870;
  --flyway-rest: #7f7870;
  --flyway-ground: #b8862f;
  --flyway-land: #89837d;
  /* The breadcrumb strip's resting ink, and its own token rather than
     --flyway-rest, which the map's quiet corridors also read: the strip wants
     one flat grey on a card and the map wants a value that can carry a hairline
     over the continent.

     SET BY ARITHMETIC, NOT BY TASTE, AND IT IS A CROSSOVER RATHER THAN A
     PREFERENCE. The card this sits on is dark by night and cream by day, so
     the ink has two grounds pulling in opposite directions and every step
     darker buys the day face exactly what it costs the night one. #767068 is
     where the two curves meet — the flattest profile any single value can have
     across the faces the strip actually opens on:

        3.68:1  default night, rgba(35,23,20,.72) over #130c0b -> #1f1411
        3.81:1  default day,   rgba(244,229,192,.9) over sky   -> #e9e4c0
        4.29:1  june / may night, #000
        3.97:1  june / may day,  #f2e6d2 vellum
        3.88:1  august night,  rgba(36,17,13,.62) over #0a0605

     All five clear the 3:1 WCAG 1.4.11 floor for a non-text mark, and the
     spread is 0.6 of a stop. This replaced #a9a39a, which measured 7.2:1 by
     night and 1.94:1 by day — a mark that was shouting on one face and all
     but gone on the other. Re-run those five before touching this value, and
     do not lighten it back towards the night face alone. */
  --flyway-mark: #767068;
}

/* The four seasons, in the colours the seasons actually have. Passage months
   take a saturated hue; the two resting phases take a quieter version of the
   hemisphere they are resting in, so the map still says "north, and still" in
   June without shouting it. */
body[data-migration="northbound"] {
  --flyway-passage: #4d9c4b;   /* new growth, going up */
  --flyway-ground: #59a659;
}

body[data-migration="breeding"] {
  --flyway-passage: #568e56;
  --flyway-ground: #4d9c4b;    /* the boreal north, occupied */
}

body[data-migration="southbound"] {
  --flyway-passage: #cc7220;   /* turning leaf, coming down */
  --flyway-ground: #c98a2c;
}

body[data-migration="wintering"] {
  --flyway-passage: #8a7961;
  --flyway-ground: #8f7f5d;    /* the tropics, occupied */
}

.flyways {
  --flyway-ink: 0.34;
  --flyway-pulse: var(--migration-intensity, 0.5);
  --flyway-hue: var(--flyway-rest);
  color: var(--flyway-ground);
}

/* A corridor in passage takes the season's hue. Out of passage it drops to the
   neutral — still drawn, just nobody on it. */
body[data-migration="northbound"] .flyways,
body[data-migration="southbound"] .flyways {
  --flyway-hue: var(--flyway-passage);
}

/* --- the continent ------------------------------------------------------ */

/* Fill and stroke both, at different weights: the fill gives the silhouette,
   the hairline gives Hudson Bay, the Great Lakes and the Gulf of California
   an edge to be read by. Those three are what make the shape unmistakable at
   this opacity, and a fill alone loses them. */
.flyways__land {
  fill: var(--flyway-land);
  fill-opacity: calc(0.34 * var(--flyway-ink));
  stroke: var(--flyway-land);
  stroke-opacity: calc(0.75 * var(--flyway-ink));
  stroke-width: 1.2;
}

/* Cuba, Hispaniola, Jamaica, Puerto Rico and the shoulder of South America.
   Lighter than the mainland because they are landfall rather than subject —
   they exist here to give the Atlantic flyway something to arrive at. */
.flyways__land--outlying {
  fill-opacity: calc(0.22 * var(--flyway-ink));
  stroke-opacity: calc(0.5 * var(--flyway-ink));
}

/* --- the grounds -------------------------------------------------------- */

/* Where the birds are when they are not travelling. These are the layer's
   seasonal tell: in June the boreal forest is lit and the tropics are dark, in
   January it is the other way round, and the corridors between them are quiet
   in both. Radial gradients with a transparent stop, so they read as regions
   with no edge rather than as circles drawn on a map. */
/* WHICH GROUND IS LIT IS DATA, NOT A SELECTOR PER GROUND.

   This used to name every region: `.flyways__ground--winter-gulf`, and a rule
   for each phase it appeared in. That worked for exactly one map with exactly
   three regions. The generated file now stamps `data-lit` and `data-trace` on
   each region with the phases it belongs to, so these eight rules cover any
   taxon with any number of grounds — birds have five, bats three — and adding
   one is a line in data/migration/<taxon>.mjs and no CSS at all. */
.flyways__ground {
  opacity: 0;
  transition: opacity 900ms ease;
}

/* Arriving beats departing. During a passage the destination is lit at full
   strength and the ground being left keeps a trace, which is the difference
   between a map of where birds are and a map of where they are going.

   Trace is declared FIRST so that lit wins on equal specificity, in case a
   dataset ever lists the same phase in both. */
body[data-migration="northbound"] .flyways__ground[data-trace~="northbound"],
body[data-migration="breeding"] .flyways__ground[data-trace~="breeding"],
body[data-migration="southbound"] .flyways__ground[data-trace~="southbound"],
body[data-migration="wintering"] .flyways__ground[data-trace~="wintering"] {
  opacity: calc(0.32 * var(--flyway-ink));
}

body[data-migration="northbound"] .flyways__ground[data-lit~="northbound"],
body[data-migration="breeding"] .flyways__ground[data-lit~="breeding"],
body[data-migration="southbound"] .flyways__ground[data-lit~="southbound"],
body[data-migration="wintering"] .flyways__ground[data-lit~="wintering"] {
  opacity: calc(0.9 * var(--flyway-ink));
}

/* --- Ohio ---------------------------------------------------------------- */

/* THE SUBJECT OF THE MAP, AND THE ONE THING ON IT DRAWN IN ITS OWN COLOUR.

   The old map was of a continent and had no subject; this one is of a state,
   and every corridor on it is here because it crosses Ohio. Picking the state
   out is what turns five lines that happen to converge into a claim about a
   place. It takes the ground colour rather than the passage colour because it
   is a ground — somewhere birds are — and it sits above the coastline and
   below the corridors so the lines cross it rather than stopping at it. */
.flyways__home {
  fill: var(--flyway-ground);
  fill-opacity: calc(0.5 * var(--flyway-ink));
  stroke: var(--flyway-ground);
  stroke-opacity: calc(1.35 * var(--flyway-ink));
  stroke-width: 1.1;
  transition: fill-opacity 900ms ease, stroke-opacity 900ms ease;
}

/* --- the corridors ------------------------------------------------------ */

/* Two strokes on identical geometry: a soft wide band for the corridor's
   extent, a crisp dashed line for the passage down the middle of it. */
/* Narrower than it was, because there are more of them and they all meet over
   one state. At 26 the five bands merged into a single blot across the Great
   Lakes and the map lost the thing it was drawn to show. */
.flyways__band {
  fill: none;
  stroke: var(--flyway-hue);
  stroke-width: 17;
  stroke-linecap: round;
  opacity: calc(var(--flyway-ink) * (0.06 + 0.16 * var(--flyway-pulse)));
  transition: opacity 900ms ease;
}

.flyways__line {
  fill: none;
  stroke: var(--flyway-hue);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 16 13;
  opacity: calc(var(--flyway-ink) * (0.2 + 0.55 * var(--flyway-pulse)));
  /* The pace IS the intensity: a passage at its peak runs a shade under half a
     minute end to end, a quiet one takes three times as long. Duration rather
     than distance, so the dash spacing stays put and only the speed changes. */
  animation: flywayDrift calc((26 - 18 * var(--flyway-pulse)) * 1s) linear infinite;
  transition: opacity 900ms ease;
}

/* SOUTH IS THE PATHS' OWN DIRECTION. Every corridor is authored north to
   south, so a dashoffset walking negative walks the way the path runs, and the
   fall passage needs no more than that. */
@keyframes flywayDrift {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: -29; }
}

/* Spring is the same animation with the sign flipped — one declaration, four
   corridors, because they were all drawn the same way round. 29 is the dash
   period (16 + 13); a whole period is what makes the loop seamless. */
body[data-migration="northbound"] .flyways__line {
  animation-name: flywayDriftNorth;
}

@keyframes flywayDriftNorth {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: 29; }
}

/* Out of passage the corridors go to a fine dotted trace. They do not vanish:
   the flyways are still there in June, there is just nothing moving down them,
   and a map that deletes them for six weeks would be lying about the geography
   rather than reporting the season. */
body[data-migration="breeding"] .flyways__line,
body[data-migration="wintering"] .flyways__line {
  stroke-dasharray: 2 17;
  stroke-width: 2;
}

/* --- which birds -------------------------------------------------------- */

/* A corridor with nothing named on it is a swoosh. Each one is pinned and
   captioned with the flyway's name and the species that is actually moving
   down it in the current phase — the guilds are staggered by months, so the
   Atlantic reads Red Knot in May and Broad-winged Hawk in September rather
   than carrying one bird all year.

   These are the only part of the layer allowed to be crisp. Everything else is
   scenery and stays under --flyway-ink; a caption that cannot be read is not a
   caption, so the text sits at full strength.

   PALE INK ON A DARK HALO, FIXED, IN EVERY MONTH. This used to be the page's
   own ink over the page's own ground, which meant the captions inverted twice
   a day and read differently in all thirteen themes. The map-label treatment
   is the one that needs neither variant: light glyphs carried on a dark
   outline stay legible over near-white January, near-black August, a lit
   ground, a corridor and a coastline alike — it is how a place name survives
   on a satellite photograph, and for the same reason. paint-order puts the
   halo behind the glyph so the outline never eats into the letterform. */
.flyways__wayname,
.flyways__species {
  font-family: var(--font-mono);
  fill: #f2e6d2;
  paint-order: stroke;
  stroke: #181411;
  stroke-width: 3.6;
  stroke-linejoin: round;
}

.flyways__wayname {
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* The flyway's name takes the season's hue while the species stays neutral:
     the corridor is the thing being coloured, the bird on it is the caption. */
  fill: var(--flyway-passage);
  stroke-width: 3.9;
}

.flyways__species {
  font-size: 14px;
  font-style: italic;
}

.flyways__label-pin {
  fill: var(--flyway-hue);
  opacity: 0.8;
}

/* All four phases ship; the season reveals one. display rather than opacity so
   the three that are off take no paint and cannot be selected or read out. */
.flyways__species { display: none; }

body[data-migration="northbound"] .flyways__species[data-phase="northbound"],
body[data-migration="breeding"] .flyways__species[data-phase="breeding"],
body[data-migration="southbound"] .flyways__species[data-phase="southbound"],
body[data-migration="wintering"] .flyways__species[data-phase="wintering"] {
  display: block;
}

/* Below the tablet breakpoint the continent is a few hundred pixels wide and
   four captions across it are a hedge. The map keeps its corridors and its
   lit grounds; the names go. */
@media (max-width: 800px) {
  .flyways__labels { display: none; }
}

/* --- the birds ---------------------------------------------------------- */

/* Four chevrons per corridor, riding the same geometry the corridor is drawn
   from — offset-path takes the path as a value, and the generated file hands
   over each corridor as a --flyway-* custom property so this stylesheet and
   that <path d> can never drift apart.

   Everything about them is inside @supports. Where offset-path is missing they
   simply never appear, and the corridors carry the movement on their own. */
.flyways__bird {
  display: none;
}

@supports (offset-path: path("M0 0")) {
  body[data-migration="northbound"] .flyways__bird,
  body[data-migration="southbound"] .flyways__bird {
    display: block;
    fill: none;
    stroke: var(--flyway-hue);
    stroke-width: 2.2;
    stroke-linecap: round;
    stroke-linejoin: round;
    opacity: calc(var(--flyway-ink) * (0.25 + 0.75 * var(--flyway-pulse)));
    /* auto banks the chevron into the turn, so a bird rounding the Gulf leans
       the way it is going instead of sliding sideways. */
    offset-rotate: auto;
    animation: flywayFlight calc((30 - 14 * var(--flyway-pulse)) * 1s) linear infinite;
    /* --i is stamped per bird by the generator. Four birds, quarter-cycle
       apart, so each corridor carries a strung-out skein rather than a pulse. */
    animation-delay: calc(var(--i) * (30 - 14 * var(--flyway-pulse)) * -0.25s);
  }

  body[data-migration="northbound"] .flyways__bird {
    animation-name: flywayFlightNorth;
  }

  /* ONE RULE, ANY NUMBER OF CORRIDORS.

     This was four rules naming four hardcoded flyways, which is why the map
     could only ever have four. The generated file now wraps each corridor's
     marks in a group carrying --flyway-path, custom properties inherit, and
     the count of corridors becomes a question for the dataset rather than for
     this stylesheet. */
  .flyways__bird { offset-path: var(--flyway-path); }

  /* Fading in and out at the ends keeps a bird from popping into existence in
     the Arctic and blinking out over Colombia. */
  @keyframes flywayFlight {
    0% { offset-distance: 0%; opacity: 0; }
    12%, 88% { opacity: calc(var(--flyway-ink) * (0.25 + 0.75 * var(--flyway-pulse))); }
    100% { offset-distance: 100%; opacity: 0; }
  }

  @keyframes flywayFlightNorth {
    0% { offset-distance: 100%; opacity: 0; }
    12%, 88% { opacity: calc(var(--flyway-ink) * (0.25 + 0.75 * var(--flyway-pulse))); }
    100% { offset-distance: 0%; opacity: 0; }
  }
}

/* --- the map is the ground while it is open ------------------------------ */

/* THE HALF THE FIXED PALETTE DID NOT FIX.

   Giving the layer one palette made its INK the same in every month. It did
   not make the PICTURE the same, because the picture is the ink plus whatever
   is behind it, and what is behind it is the month's own ambient scenery. In
   August that is two full-viewport sheets of lava-lamp wax at 0.68 and 0.5,
   drifting, in colours louder than anything the map is allowed to use — a
   chart drawn at --flyway-ink 0.34 simply loses to it, and the reader sees
   August with four captions floating on it. September rains over it, October
   and February put their own weather through it. Only the near-white months
   ever read correctly, which is exactly the report: the month still displays
   on top of the bird layer.

   So while the lamp is on, the month's decoration steps back and the map is
   the ground. Note what is NOT touched: `.night-sky`'s own gradient stays, so
   the month keeps its ground colour and the site keeps its one shared
   background — this subtracts decoration, it does not add a surface. And
   nothing here is conditioned on the month, which is the point: one rule, one
   result, all thirteen themes.

   NOT A JULY EXCEPTION. The skyline is dimmed with every other month's ground
   element and is neither hidden nor recoloured; it comes back at full strength
   the moment the lamp goes off. Pointer fireworks are left alone entirely.

   SPECIFICITY, DELIBERATELY. Month sheets reach these same elements at up to
   (0,3,2) — body[data-theme-id="x"][data-mode="y"] .night-sky__aurora::after —
   and they load after this file, so a natural (0,2,1) here would lose in every
   month that has a day/night pair. The attribute is repeated to lift the
   prefix to (0,4,1); each rule below therefore lands at (0,4,2) or (0,5,1) and
   clears the whole field. Opacity on .night-sky__aurora composites its own
   ::before/::after with it, so those need no rule of their own. */
body[data-flyways="on"][data-flyways="on"][data-flyways="on"] {
  /* One number for the whole step-back, so the balance between map and month
     is tuned here and nowhere else. */
  --flyway-quiet: 0.12;
}

body[data-flyways="on"][data-flyways="on"][data-flyways="on"] .night-sky::before,
body[data-flyways="on"][data-flyways="on"][data-flyways="on"] .night-sky::after,
body[data-flyways="on"][data-flyways="on"][data-flyways="on"] .night-sky__stars,
body[data-flyways="on"][data-flyways="on"][data-flyways="on"] .night-sky__constellations,
body[data-flyways="on"][data-flyways="on"][data-flyways="on"] .night-sky__aurora {
  opacity: var(--flyway-quiet);
  /* The motion goes with the brightness. A chart is a thing you stop and read,
     and drifting wax behind it is worse than bright wax behind it. This is a
     removal of animation, so it needs no reduced-motion counterpart — the
     reduced-motion path is already quieter than this one. */
  animation: none;
  transition: opacity 900ms ease;
}

/* ---------------------------------------------------------------------------
   THE MIGRATION BLOCK — the readable half.

   `<!-- migration -->` in a page renders this: what is moving today in words,
   a phenology chart with a line on today, and a key. It is in the body of the
   page rather than behind it, which is the entire point — the scenery layer
   above is drawn at a twelfth of full strength on purpose, and a reader who
   wants to know whether the Blackpolls are through cannot get that from a
   watermark.

   WHY IT DOES NOT WEAR THE MONTH. Same argument as the map: the chart is a
   chart. Green is northbound and amber is southbound in January and in August,
   because a reader who learns to read it in May must not have to learn again in
   September. The four hues are the map's own, declared on body above, so the
   scenery and the chart agree about what colour October is.

   The chart SVG itself is generated and committed; only the today line and the
   prose above it are per-request. See scripts/build-migration.mjs.
   --------------------------------------------------------------------------- */

.passage {
  margin: 2rem 0;
  padding: 1.1rem 1.25rem 1.25rem;
  border: 1px solid color-mix(in srgb, var(--tt-gray, #89837d) 32%, transparent);
  border-radius: 10px;
  background: color-mix(in srgb, var(--bg, #18130a) 82%, transparent);
}

.passage--missing {
  font-family: var(--font-mono);
  font-size: 0.9rem;
  opacity: 0.75;
}

.passage__title {
  margin: 0 0 0.75rem;
  font-size: 1.05rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.passage__lede { margin: 0 0 0.75rem; }
.passage__subhead {
  margin: 1.1rem 0 0.5rem;
  font-size: 0.82rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  opacity: 0.75;
}

/* A grid rather than a list of lines: the species names align, so the eye can
   run down them, and the qualifier sits in its own column instead of trailing
   off at whatever width the name happened to be. */
.passage__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: minmax(11rem, max-content) 1fr;
  gap: 0.18rem 1rem;
  font-size: 0.92rem;
}

.passage__item { display: contents; }

.passage__species {
  font-weight: 600;
  /* The leg is carried by a rule on the parent, so the two directions are
     distinguishable without a legend at this size. */
  border-left: 3px solid transparent;
  padding-left: 0.5rem;
}

.passage__item[data-leg="spring"] .passage__species { border-left-color: #4d9c4b; }
.passage__item[data-leg="fall"] .passage__species { border-left-color: #cc7220; }
/* Neither direction, because a season has none. Same slate as the bar. */
.passage__item[data-leg="season"] .passage__species { border-left-color: #80776c; }

.passage__detail { opacity: 0.72; font-size: 0.88rem; }
.passage__more { margin: 0.5rem 0 0; font-size: 0.85rem; opacity: 0.65; }

.passage__figure { margin: 1.25rem 0 0; }

/* THE CHART CANNOT USEFULLY SHRINK, SO IT SCROLLS.

   At phone width a 1000-unit chart puts the species names at about four
   pixels. Handing a reader a legible-looking picture they cannot actually read
   is worse than handing them one they have to push sideways. tabindex makes
   the scroller reachable from the keyboard, which is required the moment a
   region scrolls. */
.passage__scroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  border-radius: 6px;
}

.passage__scroll:focus-visible {
  outline: 2px solid var(--tt-cyan, #a8ae87);
  outline-offset: 2px;
}

.passage-chart__svg {
  display: block;
  width: 100%;
  min-width: 760px;
  height: auto;
  font-family: var(--font-mono);
}

.passage-chart__rule {
  stroke: currentColor;
  opacity: 0.16;
  stroke-width: 0.7;
}

.passage-chart__month {
  fill: currentColor;
  opacity: 0.6;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-anchor: middle;
}

.passage-chart__guild {
  fill: currentColor;
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.78;
}

/* Zebra striping at 2% is deliberately almost invisible. Its job is to keep the
   eye on one row across seven hundred pixels of gap between a name and its
   bars, not to be seen. */
.passage-chart__stripe { fill: currentColor; opacity: 0.028; }
.passage-chart__row:nth-of-type(even) .passage-chart__stripe { opacity: 0; }

.passage-chart__name { fill: currentColor; font-size: 12px; }
.passage-chart__offmap { fill: #cc7220; font-weight: 700; }

.passage-chart__bar--spring { fill: #4d9c4b; }
.passage-chart__bar--fall { fill: #cc7220; }
/* The present-bands are the quiet ones. A species is in Ohio for four months
   and in passage for four weeks, and if both were drawn at full strength the
   chart would read as though the summer were the event. */
.passage-chart__bar--summer { fill: #568e56; opacity: 0.4; }
.passage-chart__bar--winter { fill: #8a7961; opacity: 0.4; }

.passage-chart__peak--spring { fill: #b0e5af; }
.passage-chart__peak--fall { fill: #f5c68a; }

/* IN FLOWER. Its own hue on purpose: a bloom is neither northbound green nor
   southbound orange, and painting it either would tell a reader the plant was
   going somewhere. Magenta is the one strong colour on the chart's wheel that
   neither passage bar has claimed, and it survives both lightings.

   A bloom row draws ONE bar, so this is the only fill on it — there is no
   present-band to hold back from and no second peak to distinguish. */
.passage-chart__bar--bloom { fill: #be4b4b; }
.passage-chart__peak--bloom { fill: #f9bcb8; }

/* A SEASON BAR HAS NO PEAK RULE BECAUSE IT DRAWS NO PEAK, and that absence is
   the point rather than an omission — see the season branch in
   scripts/build-migration.mjs. Every other bar on this chart has a pale tick
   in it saying where the thing is at its best; a legal season has no such day,
   and a tick would be read as one.

   Slate rather than a season colour, deliberately. Green, orange and magenta
   all mean "something is happening in the natural world" on this chart, and a
   hunting season is not a natural event — it is a rule about one. The bar is
   the most neutral thing on the wheel for exactly that reason, and it is the
   only bar drawn with a visible edge, so a four-arc goose row reads as four
   separate permissions rather than one smeared band. */
.passage-chart__bar--season {
  fill: #80776c;
  stroke: #a9a094;
  stroke-width: 0.6;
}

.passage-chart__today line {
  stroke: var(--tt-gold, #e8c46a);
  stroke-width: 1.4;
  stroke-dasharray: 3 3;
}

.passage-chart__today-mark { fill: var(--tt-gold, #e8c46a); }

.passage__key {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.1rem;
  margin-top: 0.7rem;
  font-size: 0.8rem;
  opacity: 0.8;
}

.passage__swatch { display: inline-flex; align-items: center; gap: 0.38rem; }

.passage__swatch::before {
  content: "";
  width: 0.85rem;
  height: 0.5rem;
  border-radius: 2px;
  background: currentColor;
}

.passage__swatch[data-key="spring"]::before { background: #4d9c4b; }
.passage__swatch[data-key="fall"]::before { background: #cc7220; }
.passage__swatch[data-key="summer"]::before { background: #568e56; opacity: 0.55; }
.passage__swatch[data-key="winter"]::before { background: #8a7961; opacity: 0.55; }
.passage__swatch[data-key="bloom"]::before { background: #be4b4b; }
.passage__swatch[data-key="season"]::before { background: #80776c; }
.passage__swatch[data-key="today"]::before {
  width: 0.25rem;
  height: 0.85rem;
  background: var(--tt-gold, #e8c46a);
}

.passage__note { flex: 1 1 18rem; opacity: 0.72; }

@media (max-width: 620px) {
  .passage { padding: 0.9rem 0.85rem 1rem; }
  .passage__list { grid-template-columns: 1fr; gap: 0.05rem; }
  .passage__detail { padding-left: 0.5rem; padding-bottom: 0.3rem; }
}

/* ==========================================================================
   03-july.css — JULY BY DAY — THE ONE MONTH THAT LIVES IN main.css

   July's daylight palette, the deuteranopia link stroke that depends on it, the
   overcast sky that hides the stars, and the pointer-firework layer. There is no
   july.css: July is the base every other month overrides.

   ORDER AND LOCKS
   Keep these three in order. The deuteranopia rule reads --link-hover-glow
   declared just above it and re-types the generic link glow by hand, so a links
   partial emitted after it would silently drop the accessibility stroke. The
   palette's doubled attribute selector is what keeps the thirteen theme sheets
   order-independent — do not weaken it.

   Fireworks and the city skyline are July-only and are never seasonally adapted.

   RULES · style/july.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   JULY BY DAY.

   July is the :root base and has no theme sheet, so its counterpart lighting
   lives here rather than in /assets/themes — that is the only reason this
   block is in the structural file, and it is the only palette in it besides
   :root itself.

   HOW THE SWITCH IS BUILT, once, for all thirteen themes.

   Each theme's canonical lighting stays in its unmoded block: body with no
   data-mode is July at night, October at night, January by day. That is what
   the sheets already paint, what the server sends, and what a reader with JS
   off keeps for good — the switch is progressive enhancement and its absence
   costs a preference, never a broken page. One extra block per theme supplies
   the OTHER lighting, so base plus counterpart covers both and no theme needs
   its rules written twice.

   Selectors carry both attributes — body[data-theme-id="july"][data-mode="day"]
   at (0,2,1) over the base's (0,1,1) — so the counterpart wins on specificity
   and the sheets stay order-independent, which matters because every theme
   sheet is loaded on every page.

   Tokyo Terminal in daylight is the same terminal with the signs off and the
   overcast down on the city: the neon hues survive, dropped to values that
   carry ink weight on concrete, so a heading is still cyan-into-pink and not a
   different theme wearing July's name. The skyline stays — it is July's ground
   element and the one place the city is drawn — and the stars go, because the
   stars are the night.
   --------------------------------------------------------------------------- */
body[data-theme-id="july"][data-mode="day"] {
  /* Ground goes light here, so the hover colour and its glow go with it —
     #FF465A measures 2.83:1 on #e9ecf2, #BD0014 measures 5.59:1. This is the
     only light ground defined in this file; the rest live in public/themes/. */
  --link-hover: #BC0900;
  --link-hover-glow: 189, 0, 20;
  --tt-bg: #f2e6d2;
  --tt-bg-alt: #dbd5d1;
  --tt-cyan: #2c3623;
  --tt-cyan-soft: #4c5641;
  --tt-yellow: #7a5c00;
  --tt-orange: #a8430b;
  --tt-chartreuse: #46730a;
  --tt-green: #336932;
  --tt-pink: #c21816;
  --tt-purple: #763a21;
  --tt-blue: #584125;
  --tt-teal: #415a39;
  --tt-gray: #59544f;
  --tt-gold: #6a6320;
  --tt-red: #b91717;
  --tt-breadcrumb-text: #aa1613;
  --tt-breadcrumb-glow: rgba(185, 23, 23, 0.2);
  --zodiac-glow: rgba(170, 22, 19, 0.55);
  --border-dim: rgba(44, 54, 35, 0.22);
  --border-teal: rgba(65, 90, 57, 0.5);
  /* Cut the other way: on a pale card the lit lip of an engraved stroke is
     white below the glyph, not black. */
  --tt-engrave: 0 1px 0 rgba(242, 230, 210, 0.8);
  background:
    radial-gradient(ellipse at 62% 38%, rgba(65, 90, 57, 0.05), transparent 34rem),
    linear-gradient(180deg, #f2e6d2 0%, #f2e6d2 48%, var(--tt-bg) 72%, #ece4d8 100%);
  color: var(--tt-cyan);
  color-scheme: light;
}

/* JULY BY DAY — colour-blindness fallback (2026-08-14, task #38). The resting
   prose-link colour here is --tt-yellow, #7a5c00 (dark olive), and the hover
   swing is --link-hover, #BD0014 (dark red). To normal vision those read as
   clearly different hues, but a Brettel-style LMS deuteranopia simulation
   collapses them to near-identical dark olive tones (#666600 vs #6c6c00,
   Delta-E76 ~3 — effectively invisible). Protanopia keeps them apart (~23),
   so this is a deuteranopia-specific gap. A currentColor text-shadow stroke
   adds a shape/edge cue that does not depend on hue discrimination, so it
   survives the simulation, and — unlike font-weight — does not reflow text.
   Layered onto the generic glow rather than replacing it. */
body[data-theme-id="july"][data-mode="day"] a:hover,
body[data-theme-id="july"][data-mode="day"] a:focus-visible {
  text-shadow:
    0 0 8px rgba(var(--link-hover-glow), 0.86), 0 0 16px rgba(var(--link-hover-glow), 0.64), 0 0 28px rgba(var(--link-hover-glow), 0.34),
    -1px -1px 0 currentColor, 1px -1px 0 currentColor, -1px 1px 0 currentColor, 1px 1px 0 currentColor;
}

/* The scanline survives the flip — it is the CRT, not the dark. It only has to
   change sign: a light line over black becomes a dark line over concrete. */
body[data-theme-id="july"][data-mode="day"]::before {
  background: repeating-linear-gradient(0deg, rgba(44, 54, 35, 0.03) 0, rgba(44, 54, 35, 0.03) 1px, transparent 1px, transparent 4px);
}

/* Overcast rather than no-light-pollution black, with the horizon glow turned
   down to the haze a city puts into a grey afternoon. */
body[data-theme-id="july"][data-mode="day"] .night-sky {
  background:
    radial-gradient(ellipse at 50% 111%, rgba(185, 23, 23, 0.06), transparent 28%),
    radial-gradient(ellipse at 24% 108%, rgba(118, 58, 33, 0.05), transparent 24%),
    radial-gradient(ellipse at 78% 109%, rgba(65, 90, 57, 0.05), transparent 26%),
    linear-gradient(180deg, rgba(242, 230, 210, 0.5) 0%, transparent 46%);
}

/* No stars, no Milky Way, no twinkle. Hidden rather than faded, because a
   daylight sky with faint stars in it is a mistake, not a subtlety — and
   `display: none` on the layers is also the cheapest thing this switch can do
   to a low-end machine: three animated layers stop existing. */
body[data-theme-id="july"][data-mode="day"] .night-sky::before,
body[data-theme-id="july"][data-mode="day"] .night-sky__stars {
  display: none;
}

/* The skyline stays — it is July's ground element and it is the city. By day
   it is haze-grey concrete against the overcast instead of a black cut-out
   over neon, and the glow above it goes. The clip-path is untouched: the same
   city, differently lit. */
body[data-theme-id="july"][data-mode="day"] .night-sky__aurora {
  background: linear-gradient(180deg, #c4beb8, #a89f98 72%);
  box-shadow: none;
}

.night-sky__fireworks {
  position: fixed;
  z-index: 3;
  inset: 0;
}

.mouse-firework {
  position: absolute;
  width: 1px;
  height: 1px;
  contain: layout style;
}

.mouse-firework__spark, .mouse-firework__smoke {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  pointer-events: none;
}

.mouse-firework__spark {
  width: 2px;
  height: 8px;
  margin: -4px 0 0 -1px;
  border-radius: 50%;
  background: var(--spark-color);
  box-shadow: 0 0 3px #f2e6d2, 0 0 7px var(--spark-color), 0 0 12px var(--spark-color);
  opacity: 0;
  animation: mouseFireworkSpark 5.2s linear var(--spark-delay) both;
}

.mouse-firework__trails {
  position: absolute;
  left: -200px;
  top: -200px;
  width: 400px;
  height: 400px;
  overflow: visible;
  pointer-events: none;
}

.mouse-firework__trail {
  fill: none;
  stroke: rgba(222, 215, 203, 0.3);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  filter: blur(1.8px);
  opacity: 0;
  transform-origin: center;
  animation: mouseFireworkTrail 10.8s ease-out var(--trail-delay) both;
}

.mouse-firework__smoke {
  width: 42px;
  height: 34px;
  margin: -17px 0 0 -21px;
  border-radius: 50%;
  background: radial-gradient(ellipse at 46% 42%, rgba(242, 230, 210, 0.58) 0 8%, rgba(235, 228, 216, 0.42) 22%, rgba(167, 161, 154, 0.22) 48%, rgba(83, 76, 70, 0.08) 67%, transparent 78%);
  filter: blur(5px);
  opacity: 0;
  animation: mouseFireworkSmoke 14.2s cubic-bezier(0.16, 0.55, 0.3, 1) var(--smoke-delay) both;
}

/* ==========================================================================
   04-frame.css — THE STRUCK WINDOW FRAME, ALL FOUR EDGES

   The stacking floor for header, main and footer, the retired-header-strip
   history and the geometric proof, the single body[data-theme-id]::after that
   draws all four struck edges, its @supports tooth snap, and its removal in print.

   ORDER AND LOCKS
   Hard order dependency inside this file: the mask rule, then the @supports tooth
   snap, then the @media print removal. All three re-declare the same six custom
   properties and `content` on the identical selector at identical specificity, so
   all three must stay here in this order. Never hoist the print rule into a shared
   print partial emitted early. The mask/-webkit-mask pairs must survive minifying.

   The site-wide rainbow BORDER SWEEP is documented as part of the frame but does
   not live here — it is in 15-home-and-settings.css, because it must follow every
   rule it repaints.

   RULES · style/frame.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

header, main, footer { position: relative; z-index: 1; }

header {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  /* Declared as a token because .tag-row cancels it exactly — the chips are
     hung off the foot of the breadcrumb card and must touch it. Retune the gap
     here and the row stays seated; hardcode it in two places and it does not. */
  --header-stack-gap: 0.85rem;
  gap: var(--header-stack-gap);
  padding: clamp(1rem, 2.5vw, 1.8rem);
  overflow: hidden;
}

/* ---------------------------------------------------------------------------
   THE HEADER STRIP IS RETIRED, AND THE FRAME BELOW IS WHERE IT WENT
   (2026-08-18, user instruction: "borders always need to be perfect like this.
   make border smaller or larger based on window size, do not have them ever
   overlap.", answered on scope: all fourteen sheets, not just the front door.)

   What stood here was `header::before` — a 2px rule across the top of <header>,
   painted in whatever ramp the month had put in --tt-heading-rainbow, animated
   on a 5s scanGlow and bloomed with a box-shadow. The default sheet stopped
   generating it on 2026-08-16 and put a four-edge frame in its place; that
   frame is now every sheet's, so the strip is retired at the source rather than
   switched off fourteen times over.

   THREE REASONS IT HAD TO GO, and none of them is about how it looked:

     LOCKED. It was absolute inside <header>, so it scrolled off the screen
     while the other three edges stayed pinned to the window. One edge in the
     document and three in the viewport is not a frame.

     MATCHED. Two elements means two independent tooth phases, and nothing makes
     a phase started at the top of the header agree with one started at the left
     of the window. Teeth only register at a corner if all four edges are
     counted off the same origin, which means one element.

     NO OVERLAP. The strip ran the full width at the top of the page and the
     frame's head runs the full width of the window. At scroll 0 that is the
     same band of pixels, marked twice.

   The month sheets' own `header::before` rules — height, glow, animation
   timing, one per sheet and a few per lighting — went with it in the same
   sweep. Do not restore any of them as a fix for a header that looks unmarked:
   the mark is still there and it belongs to the frame. A month that wants a
   different border re-points --tt-heading-rainbow / --tt-heading-rainbow-
   vertical, which is what the frame paints itself with.

   NOTE FOR A FUTURE SHEET: nothing here is animated, and that is the one thing
   the strip did that the frame does not. A full-viewport element cannot carry a
   5s background-position loop or a box-shadow bloom without repainting the
   whole window on every frame and reading as a lit sign around the page. If a
   month wants motion at its edge, that is a conversation, not a box-shadow.
   --------------------------------------------------------------------------- */

/* ---------------------------------------------------------------------------
   THE FRAME — ALL FOUR EDGES, LOCKED TO THE WINDOW, MITRED AT THE CORNERS

   ONE ELEMENT, FOUR EDGES. A position:fixed pseudo-element on <body> at
   inset:0, carrying head, foot and both rails as four background layers under
   four mask layers. Every edge is pinned to the WINDOW; nothing here is in the
   document, so nothing scrolls away from anything else.

   It also has to be ONE element for the corners to work. Tooth phase is counted
   from the element's own box, so four edges on one box are counted off one
   origin; four edges on four boxes are four independent phases that only agree
   by luck.

   HOW THE TEETH ARE CUT. Not by painting the ground over a gradient, which is
   what the old strip did and was legal there because the header sat on one flat
   colour. This element covers the whole window and every sheet runs a vertical
   ramp behind it, so painted teeth would show as ghost blocks over most of the
   screen. Instead each edge is masked by a conic gradient: a 90deg wedge with
   its point on the OUTER edge of its band and its base on the inner one, which
   puts the flanks at 45deg over --frame-depth.

   WHY THE CORNERS COME OUT EXACT. Take the top-left. Both flanks of every tooth
   run at 45deg, so they are parallel to the corner's own diagonal, and:

     the head's tooth with its apex at x = 0 covers exactly {x <= y}
     the left rail's tooth with its apex at y = 0 covers exactly {y <= x}

   Those two half-teeth tile the depth x depth corner square between them. They
   share the diagonal and nothing else — union is the whole square, intersection
   is a line of zero width. So the corner is solid, the pattern turns it, and
   the two edges cannot double-draw. No clip-path, no mitre polygon, no corner
   block: the geometry does it, PROVIDED an apex lands exactly on each corner.
   Note what the proof does NOT depend on: how big the teeth are. Both bands are
   the one --frame-depth and both flanks are 45deg, so the square resolves the
   same way at 5px and at 24px — which is what lets the pitch move with the
   window at all.

   WHICH IS WHAT --frame-teeth-h / -v ARE FOR. A pitch used raw puts an apex at
   the origin corner and lands wherever it lands at the far one. So the count is
   snapped per axis: divide the viewport by the pitch and round UP to a whole
   number of teeth. Round up rather than to-nearest so the count is never 0 on a
   narrow window; max(2, …) holds it at two, which is the smallest count the
   offset below can be expressed against. The two axes snap independently, so a
   tall window runs its rails a fraction tighter than its head — sub-pixel, and
   the alternative is a seam somewhere along an edge instead.

   THE TILE IS A PERCENTAGE, THE COUNT IS WHAT vw AND vh ARE FOR. The count has
   to come from 100vw / 100vh, because a rule cannot read its own element's
   width and a percentage in a custom property has no box to resolve against at
   that point. But 100vw is not the element: behind a CLASSIC scrollbar it
   overshoots the frame's own box by the scrollbar's width, and a tile sized off
   it drifts by up to a whole tooth across the run — the last tooth on the head
   lands clear of the right rail's first, which is a corner that double-draws.
   So vw/vh decide the COUNT, an integer that a 15px error cannot move, and the
   tile itself is calc(100% / count) — a percentage of the mask's own
   positioning area, which IS the element's box, scrollbar or no scrollbar.
   (--frame-pitch-h / -v survive as the same number in px, kept because
   --frame-depth needs a length to take its ratio of.)

   The half-tile offset is a percentage for the same reason. Every layer is
   offset half a tile against its own anchor, so the apexes fall on 0 rather
   than mid-tile — the conic's point sits at the CENTRE of its tile, and the
   corner needs it at the edge. background-position percentages resolve against
   (area - tile), not against the area, so half a tile is not -50%: with N teeth
   the tile is area/N, the travel is area(N-1)/N, and the offset that comes out
   is -50% / (N - 1).

   The four background layers do overlap in the four corner squares, and that
   one is deliberate and invisible: every sheet's ramp is the month's red at
   both ends, so whichever layer paints a corner paints it the same colour.
   Insetting them to remove the overlap would leave each corner's far triangle
   with no paint under its mask, which is a notch — a worse defect than an
   unobservable one. A sheet whose ramp does NOT close on the colour it opens
   with will show that corner in one of the two, which is the one thing to look
   at when adding a fifteenth.

   z-index 6 puts it over the content (header/main/footer are 1, the scanline is
   5) and under the celestial tooltip, the key help and the world clock at 60
   and 80, all of which have business reaching the edge of the screen.
   pointer-events is none: it covers the entire viewport and must never take a
   click meant for the page.
   --------------------------------------------------------------------------- */
body[data-theme-id]::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 6;
  pointer-events: none;

  /* Unsnapped fallback. The @supports block below snaps all six to the
     viewport; these are what an engine without round() paints, and what the six
     names mean:

       --frame-pitch-h / -v   one tooth, in px, per axis. Only --frame-depth
                              reads them — a ratio needs a length.
       --frame-tile-h / -v    the same tooth as a percentage of this element's
                              own box, which is what the mask is tiled at.
       --frame-offset-h / -v  half a tooth, in whatever unit its tile is in,
                              negated: the phase that puts an apex on 0. */
  --frame-pitch-h: var(--frame-pitch);
  --frame-pitch-v: var(--frame-pitch);
  --frame-tile-h: var(--frame-pitch);
  --frame-tile-v: var(--frame-pitch);
  --frame-offset-h: calc(var(--frame-pitch) / -2);
  --frame-offset-v: calc(var(--frame-pitch) / -2);

  /* The depth is the tooth's shape, not a second free length: one ratio of the
     pitch, so the triangle is the same triangle at 12px and at 24px. It takes
     the SMALLER of the two axes' pitches because it is a single number serving
     both bands — the corner square is depth x depth and stops being square the
     moment the two disagree — and because the base it implies (2 x depth) has
     to clear the tighter of the two pitches for the teeth on that axis not to
     touch. */
  --frame-depth: calc(min(var(--frame-pitch-h), var(--frame-pitch-v)) * var(--frame-tooth-ratio));

  /* Head, right rail, foot, left rail — clockwise from the top, the order every
     other four-sided thing in CSS is written in. The horizontal ramp runs along
     head and foot, the vertical one down the rails, and both are the sheet's
     own: a month re-points those two tokens and the frame is repainted in its
     colours without touching anything here. */
  background-image:
    var(--tt-heading-rainbow),
    var(--tt-heading-rainbow-vertical),
    var(--tt-heading-rainbow),
    var(--tt-heading-rainbow-vertical);
  background-position: left top, right top, left bottom, left top;
  background-size:
    100% var(--frame-depth),
    var(--frame-depth) 100%,
    100% var(--frame-depth),
    var(--frame-depth) 100%;
  background-repeat: no-repeat;

  /* Four mask layers, composited by the default `add`. The union of the four
     tooth fields IS the frame, so `add` is the correct operator. Do not reach
     for mask-composite: one composite mode applies down the whole layer list,
     and intersecting four separate edges leaves nothing at all on screen. */
  -webkit-mask-image:
    var(--frame-tooth-top),
    var(--frame-tooth-right),
    var(--frame-tooth-foot),
    var(--frame-tooth-left);
  -webkit-mask-position:
    var(--frame-offset-h) top,
    right var(--frame-offset-v),
    var(--frame-offset-h) bottom,
    left var(--frame-offset-v);
  -webkit-mask-size:
    var(--frame-tile-h) var(--frame-depth),
    var(--frame-depth) var(--frame-tile-v),
    var(--frame-tile-h) var(--frame-depth),
    var(--frame-depth) var(--frame-tile-v);
  -webkit-mask-repeat: repeat-x, repeat-y, repeat-x, repeat-y;
  mask-image:
    var(--frame-tooth-top),
    var(--frame-tooth-right),
    var(--frame-tooth-foot),
    var(--frame-tooth-left);
  mask-position:
    var(--frame-offset-h) top,
    right var(--frame-offset-v),
    var(--frame-offset-h) bottom,
    left var(--frame-offset-v);
  mask-size:
    var(--frame-tile-h) var(--frame-depth),
    var(--frame-depth) var(--frame-tile-v),
    var(--frame-tile-h) var(--frame-depth),
    var(--frame-depth) var(--frame-tile-v);
  mask-repeat: repeat-x, repeat-y, repeat-x, repeat-y;
}

/* THE SNAP. A whole number of teeth per edge, so an apex lands on all four
   corners rather than only on the two the tiling is anchored from.

   The count is the only thing 100vw / 100vh are trusted with, and everything
   the mask is actually sized and phased by is a percentage of this element's
   own box — see the scrollbar argument above.

   max(2, …) is a floor, not a taste: --frame-offset-h / -v divide by
   (count - 1), so a one-tooth edge would divide by zero, and a custom property
   that is invalid at computed-value time takes the whole mask down with it. A
   window narrow enough to reach it is a window with no page in it, but the
   guard costs nothing.

   Wrapped in @supports because it needs round() and length-by-length division;
   without them the block above stands as written and the pitch is whatever the
   clamp gave, unsnapped: corners register at the top-left only, which is still
   a frame. Engines missing mask-image go further back and get four solid bands
   in the ramp — no teeth, still a border. */
@supports (width: calc(100vw / round(up, 100vw / 16px, 1))) {
  body[data-theme-id]::after {
    --frame-teeth-h: max(2, round(up, 100vw / var(--frame-pitch), 1));
    --frame-teeth-v: max(2, round(up, 100vh / var(--frame-pitch), 1));
    --frame-pitch-h: calc(100vw / var(--frame-teeth-h));
    --frame-pitch-v: calc(100vh / var(--frame-teeth-v));
    --frame-tile-h: calc(100% / var(--frame-teeth-h));
    --frame-tile-v: calc(100% / var(--frame-teeth-v));
    --frame-offset-h: calc(-50% / (var(--frame-teeth-h) - 1));
    --frame-offset-v: calc(-50% / (var(--frame-teeth-v) - 1));
  }
}

/* A frame pinned to the window has no meaning on paper, where there is no
   window — it would print as a border on every sheet. */
@media print {
  body[data-theme-id]::after { content: none; }
}

/* ==========================================================================
   05-brand.css — THE WORDMARK AND THE PAGE TITLE — BOTH LOCKED

   The two locked type objects standing above the card: the cursive agoraMores
   wordmark with its oversized initial and blinking underscore, and the page title
   with its clipped gradient — which is deliberately NOT a heading.

   ORDER AND LOCKS
   Indivisible on both halves. `.brand a` is written twice and the two must not
   land in different files. The tripled `.page-title__link` at (0,4,0) and
   --heading-tint are calibrated against sheets that load after main.css;
   position in the order is free, the specificity is what defends them.

   A hand-drawn replacement mark is not style's work — route to
   handwriting.agent.md. If the thing that "lost its colour" is the page title,
   it is not a heading; do not go looking in 08-headings.css.

   RULES · style/brand.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

.brand a {
  color: var(--tt-yellow);
  /* LOCKED — see --font-brand-cursive in :root. Cursive script, italic, mixed
     case, negative tracking. A theme may re-colour the wordmark; it may not
     re-letter it. */
  font-family: var(--font-brand-cursive);
  font-style: italic;
  font-weight: 700;
  line-height: 1.03;
  letter-spacing: -0.045em;
  text-shadow: 0 0 10px rgba(255, 230, 28, 0.62), 0 0 28px rgba(255, 230, 28, 0.28), 0 0 46px rgba(190, 103, 65, 0.18);
}

.brand a {
  display: inline-block;
  font-size: clamp(2.8rem, 9vw, 5.75rem);
  white-space: nowrap;
  word-spacing: 0 !important;
  font-variant-ligatures: none;
  font-kerning: none;
  text-decoration: none;
  transition: color 160ms ease, text-shadow 160ms ease, transform 160ms ease;
  animation: brandFloat 4s ease-in-out infinite;
}

.brand {
  align-self: flex-start;
  text-align: left;
}

.brand-wordmark {
  display: inline-block;
  white-space: nowrap;
  word-spacing: 0 !important;
}

/* LOCKED — the wordmark opens on a lowercase `a`, not a versal capital, and that
   `a` is drawn at the height the capital stood at. Brush Script MT (the face the
   stack resolves to off Windows) measures a cap `A` at 0.664em against a lowercase
   `a` at 0.390em, so 1.7 puts the small letter's crown back on the cap line: same
   size as the `A` it replaced, same baseline, same header height. Do not "correct"
   this to 1em — a lowercase initial at text size is the bug this rule exists to
   prevent.

   `line-height: 0` is what keeps the enlarged glyph out of the line-box maths:
   any positive value re-enters the line at 1.7x scale and pushes the whole header
   — and everything under it — down about twenty pixels, and `display:
   inline-block` does the same by re-seating the baseline. Both were measured.

   A DRAWN MARK WAS TRIED HERE ON 2026-08-14 AND REVERTED. If you are about to
   replace these letters with an SVG, read `.github/agents/handwriting.agent.md`
   first: it holds the wiring, the geometry, the measurements of this face, and
   what the drawn version got wrong. */
.brand-initial {
  font-size: 1.7em;
  line-height: 0;
  text-transform: lowercase;
}

/* LOCKED — the blinking cursor. The underscore after the wordmark is part of
   the wordmark: it is what makes the title read as something still being typed
   rather than as a finished logotype, and it has been on this site since the
   first header. It blinks on every theme, in every mode. A sheet may re-colour
   it; a sheet may not replace the glyph, stop the blink, or swap the animation
   for a static mark. The only thing allowed to still it is
   prefers-reduced-motion, which is handled in the shared block below.
   See .github/agents/style/invariants.md — "The Wordmark Is Locked". */
.brand a::after {
  content: "_";
  display: inline-block;
  margin-left: 0.08em;
  color: var(--tt-pink);
  font-family: var(--font-mono);
  font-size: 0.55em;
  font-style: normal;
  text-shadow: 0 0 12px var(--tt-breadcrumb-glow);
  animation: terminalBlink 1.1s steps(2, end) infinite;
}

.brand a:hover {
  color: var(--tt-yellow);
  transform: translateY(-1px);
  text-shadow: 0 0 12px rgba(255, 230, 28, 0.9), 0 0 34px rgba(255, 230, 28, 0.44), 0 0 72px rgba(255, 118, 117, 0.2);
}

/* THE PAGE TITLE STANDS ABOVE THE CARD, AND IT IS NOT A HEADING (2026-08-16,
   user instruction).

   The header now reads down in three steps: the wordmark says which site this
   is, the title says which page, and the card under them is the instrument
   panel for both. The title used to be the article's first h1, which made the
   name of a page look like the first division of it — see the note on
   renderPageTitle() in main.js, and stripTitleHeading() for the other half of
   the instruction: a lone h1 in a note IS this, so it is no longer drawn twice.

   IT IS NOT A HEADING, AND THAT IS THE INSTRUCTION, NOT AN OVERSIGHT. Nothing
   in the h1–h6 ladder names the page any more; the ladder is for dividing it.
   Do not "fix" this element back into a heading, and do not give it a heading
   role. It is a div rather than a p for a cascade reason — see the note on
   renderPageTitle() in main.js before changing the tag.

   It sits on THE CARD'S footprint — 64rem, centred — rather than the header's,
   the same bargain `.tag-row` strikes at the other end, so the three parts of
   the header stack on one axis instead of three.

   It is set one step down from the wordmark on purpose. The old h1 ran to
   4.75rem, which was right for the largest thing in <main> and wrong for
   something seated directly under a 5.75rem mark: two display sizes within a
   rem of each other read as a collision rather than as a hierarchy. */
.page-title {
  width: 100%;
  max-width: 64rem;
  margin: 0 auto;
  text-align: center;
  font-family: var(--font-cursive);
  font-style: italic;
  font-size: clamp(2rem, 5.5vw, 3.6rem);
  line-height: 1.15;
  text-transform: uppercase;
  overflow-wrap: break-word;
  /* The heading paint, kept in step with `main h1` by hand because this
     element is deliberately outside that selector. Every value here is read
     from a token a theme re-declares, so the title is lettered in the month
     it is read in without any sheet naming it. */
  background: var(--tt-heading-text-rainbow);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  text-shadow: 0 0 12px rgba(176, 214, 143, 0.24);
  animation: headingRainbowFlow 5.4s linear infinite;
  animation-delay: -0.7s;
  /* Tier 0 of the heading ladder — the identity step, declared rather than
     inherited. Four sheets (january, march, november, october) open their
     heading `filter` with `var(--heading-tint)` and keep an off-register plate
     after it; an undefined var() there invalidates the whole declaration and
     would strike the plate off the title alone. See "Headings Step Through One
     Colour By Level" in .github/agents/style/invariants.md. */
  --heading-tint: hue-rotate(0deg) saturate(1) contrast(1);
}

/* The title is a door when the page has an other side — see
   resolveTitlePairSlug() in main.js. A note's title opens the tag page that
   gathers its neighbours; a tag page's title opens the note the tag is named
   for. A page with no counterpart is lettered as a plain div and neither of
   these selectors matches it, so nothing about it changes.

   THE ANCHOR IS NESTED INSIDE THE DIV, never in place of it. `.page-title` is
   painted by clipping a gradient to its own text on most sheets, and that clip
   covers a descendant's glyphs as long as the descendant paints no colour of
   its own — so a nested <a> is lettered BY the title, while an <a> standing in
   for the div would forfeit the cascade reason the div exists (see
   renderPageTitle() in main.js) and hand every sheet that colours a link the
   chance to repaint the largest element in the header.

   THE ONE THING A LINK HERE MUST INHERIT IS ITS FILL, AND link-camouflage.css
   CANNOT GIVE IT. That sheet holds every resting <a> in its surroundings with
   `color: inherit` plus `-webkit-text-fill-color: currentColor`, which is
   exact everywhere on the site except an element whose fill and whose colour
   are two different values — and this title is the only such element there is.
   `.page-title` inherits body ink into `color` and is lettered from a separate
   fill, so `currentColor` resolves to the body's cream and the resting title
   link would be the one link on the site announcing itself, in the largest
   type on the page. `inherit` takes the parent's FILL: transparent where a
   gradient is clipped to the glyphs, solid ink on the sheets that override the
   clip, correct on both without either being named.

   WHICH IS WHY THESE SELECTORS ARE STACKED. Camouflage's resting rule is
   `!important` at (0,3,1) and the month sheets that flatten links reach
   (0,3,2); three repetitions of the class land at (0,4,0), which clears both,
   and the hover pair at (0,5,0) clears its own resting rule in turn. This is
   the idiom main.css already uses on .celestial-chip and link-camouflage.css
   uses on the press state; it is not decoration, and shortening it puts the
   resting title back into body-copy cream.

   THE REVEAL IS COLOUR, WHICH IS THE SITE'S OWN. An underline is ruled out by
   the standing no-underline rule, and a glow cannot be authored here without
   overriding the hover treatment every month writes for itself — so hover and
   keyboard focus put --link-hover into the fill, where it lands on a glyph
   whatever the sheet was doing before it. `:not(:active)` steps aside for
   link-camouflage.css's press sweep, which co-occurs with :hover under a
   mouse and is the louder of the two by design; without the guard this rule
   would outrank the press and the title would be the one link on the site that
   does not sweep when it is held down. Nothing else changes on hover: same
   size, same weight, same face. */
.page-title .page-title__link.page-title__link.page-title__link {
  -webkit-text-fill-color: inherit !important;
}

.page-title .page-title__link.page-title__link.page-title__link:hover:not(:active),
.page-title .page-title__link.page-title__link.page-title__link:focus-visible:not(:active) {
  -webkit-text-fill-color: var(--link-hover) !important;
}

/* ==========================================================================
   06-search.css — THE COLLAPSED MAGNIFIER BAR

   The search bar end to end: its seven sizing tokens, the framed field, the
   magnifier submit, the invisible-but-focusable input, the :has() focus ring, the
   typeahead panel, and the two queries that retune the bar.

   ORDER AND LOCKS
   One component, one file — every block reads the seven tokens declared in the
   `.site-search` rule at the top. Do not cut inside the @supports block. The two
   media queries re-declare custom properties on `.site-search` at equal
   specificity and must stay last; doubled-class rules must stay after their
   single-class bases.

   This file beats the base button and field in 09-elements.css on specificity,
   not order — except `.site-search__more`, which ties `.sitemap-link` and stays
   flat only because 09 is emitted later.

   RULES · style/search-ui.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   Site search — pinned to the top-right corner of the viewport.

   The form is a direct child of <body> and deliberately NOT inside <header>.
   <header> sets `position: relative; z-index: 1`, which is a stacking context,
   and `overflow: hidden` for the neon strip; a fixed bar living inside it could
   never paint above <main>, which shares z-index 1 and comes later in the
   document, and the old fix (lifting the whole header to z-index 20 while the
   panel was open) also pushed the header out from under the CRT scanline. At
   body level the bar takes z-index 4 outright: above .night-sky (0) and above
   header/main/footer (1), still below body::before (5) so the scanline keeps
   running across it. role="search" still makes it a landmark — a top-level one
   now rather than one nested in the banner.

   Rest is the bare magnifier and nothing else — no keyline, no fill, no visible
   input, no placeholder. The glass is also the only submit control there is:
   the "Search" pill it used to sit beside is gone, because Enter already
   submits the form and a labelled pill next to an icon that means the same
   thing is one control too many. The frame belongs to the open control, not
   to the resting affordance: a bordered, filled square parked in the corner
   read as a box ruled down the right-hand side of the page, which is exactly
   what the collapsed state is meant not to be.
   :hover, :focus-within and the `.site-search--open` class the script sets
   while suggestions are showing all resolve to the SAME open state, so the
   keyboard reaches exactly what the pointer reaches. That invariant is
   load-bearing, and it is why every rule below is written as a three-selector
   list rather than a single :hover.

   Open unrolls the bar leftwards out of the right edge of the screen. The form
   is right-anchored and only its width animates, so the magnifier — anchored to
   the right of the field, not the left — stays nailed under the pointer while
   the slab grows out from under it. Nothing translates, so the icon never
   drifts off the spot the reader aimed at.
   --------------------------------------------------------------------------- */
.site-search {
  /* One inset drives the corner offsets and the panel's viewport clamp, so the
     panel can never reach further off-screen than the bar itself. */
  --search-inset: clamp(0.55rem, 1.4vw, 1.05rem);
  /* The resting square: its width AND the bar's height in both states, so the
     collapsed affordance is square and opening never changes the bar's height. */
  --search-rest: 2.6rem;
  /* The drawn glass. Every length in the icon is a ratio of this, so it is the
     one number that resizes the magnifier — see the geometry note below. At
     0.56rem the icon was a 9px speck sitting in a 36px square, far lighter
     than the gear and the switch it shares a header with; 0.86rem fills the
     square properly and matches their visual weight. */
  --search-lens: 0.86rem;
  --search-open: 21rem;
  /* Centre of the resting square, measured from the field's padding edge — the
     2px of keyline comes off because absolutely positioned children resolve
     their offsets against the padding box, not the border box. The glass is
     laid out against this in both states; padding never moves a padding edge,
     so the open state's field padding below cannot shift the icon. */
  --search-centre: calc((var(--search-rest) - 2px) / 2);
  /* Right-hand strip the glass owns. The field claims it as padding once open,
     which is what keeps the query text from running underneath the icon. */
  --search-gutter: calc(var(--search-rest) - 0.25rem);
  /* How far the outermost focus ring paints past the field's border box: the
     orange outline's 7px offset plus its own 1px width. Keep it in step with
     that rule or the clearance below stops being true. */
  --search-ring: 8px;

  position: fixed;
  /* The top offset adds the ring back so it is the ORANGE LINE, not the field's
     keyline, that --search-inset holds clear of the top of the screen. header's
     rainbow strip is pinned to y=0 at 2px tall, and at the narrow end of the
     inset clamp the ring was landing within a pixel of it — a focus ring that
     grazes the strip reads as one bleeding into the other. The right offset is
     left alone: nothing is pinned to that edge for the ring to touch. */
  top: calc(var(--search-inset) + var(--search-ring));
  right: var(--search-inset);
  z-index: 4;
  width: var(--search-rest);
  max-width: calc(100vw - var(--search-inset) * 2);
  margin: 0;
  /* Width is a layout property, but the bar is out of flow and its subtree is
     four small boxes, so the reflow is confined to itself — nothing on the page
     moves. Reduced-motion flattens this along with every other transition. */
  transition: width 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.site-search:hover,
.site-search:focus-within,
.site-search--open { width: var(--search-open); }

/* The field is the framed control: the fill and keyline moved here off the
   input, so the input and the magnifier read as one slab rather than two. It
   is a form control, not a new page surface — the fill is
   opaque ONCE OPEN, so page text scrolling under the 21rem slab never ghosts
   through it. At rest there is no slab to ghost into, so both the fill and the
   keyline sit this state out and the magnifier floats bare in the corner.

   The resting border is `transparent`, not `0`. It has to keep occupying its
   1px so the box geometry is byte-identical in both states: --search-centre is
   measured as (--search-rest - 2px) / 2 against the field's PADDING box, and
   zeroing the border would widen that padding box by 2px and slide the glass
   1px sideways the instant the bar opened. The spec for this bar is that the
   glass does not move — so the border stays in the layout and only stops
   painting. The open rule below repaints it by colour alone.

   `overflow: hidden` is load-bearing now that the slab collapses: it is what
   stops the input's caret and the tail of its placeholder from ghosting outside
   the square while the width transition is still running. It clips descendants
   only — the field's own outline and box-shadow paint outside it untouched,
   which is why the focus ring further down is routed to the field rather than
   left on the controls inside it, where the clip would eat it.

   Rest carries no padding at all: the input has to reach the keyline on every
   side so a tap anywhere inside the collapsed square lands on it. The inset
   that holds the query text clear of the glass arrives with the open state
   instead, as --search-gutter. */
.site-search__field {
  position: relative;
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
  height: var(--search-rest);
  padding: 0;
  overflow: hidden;
  border: 1px solid transparent;
  border-radius: 0.6rem;
  background: transparent;
  color: var(--muted);
  transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease,
    padding-right 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* The open state now owns the frame outright — it is the only place the field's
   keyline and fill are ever painted. Both are restored by value alone, never by
   the `border` shorthand: the base rule keeps `border-style: solid` alive under
   a transparent colour precisely so this rule can light it without touching the
   box geometry the glass is positioned against. All three triggers share it, so
   the keyboard gets the same frame the pointer does. */
.site-search:hover .site-search__field,
.site-search:focus-within .site-search__field,
.site-search--open .site-search__field {
  padding-right: var(--search-gutter);
  border-color: var(--border);
  background: var(--surface);
  color: var(--tt-red);
  /* Plain rgba first as the fallback for engines without color-mix(); the
     themed line below replaces it wherever color-mix() parses. --tt-red is the
     one accent every month sheet guarantees is a real saturated colour. */
  box-shadow: 0 0 18px rgba(255, 118, 117, 0.3), 0 14px 34px rgba(0, 0, 0, 0.45);
  box-shadow: 0 0 18px color-mix(in srgb, var(--tt-red) 34%, transparent), 0 14px 34px rgba(0, 0, 0, 0.45);
}

/* THE MAGNIFIER IS THE SUBMIT BUTTON. There is no "Search" pill any more —
   Enter submits the form, everybody knows that, and the glass is the click path
   for the reader whose Enter key is broken or who simply aims at the icon,
   because an icon in a search bar is what you aim at. Drawing the glass on this
   button's own pseudo-elements rather than on the field's is what makes the
   thing you can see and the thing you can press the same element.

   It is absolutely positioned over the right-hand square instead of sitting in
   the flex row, so it cannot push the input around and its box is untouched by
   the width animation. `right: 0` with a width of (--search-rest - 2px) lands
   its right edge on the field's PADDING edge and its centre exactly on
   --search-centre — the same point the icon was already drawn against — so
   every offset below stayed byte-identical when the glass was re-parented, and
   the icon did not move a pixel.

   POINTER-EVENTS CARRIES THE REST STATE. At rest the button is inert, so a
   click or tap on the collapsed square falls through to the transparent input
   underneath, focuses it and rolls the bar open exactly as before — a tap on a
   closed bar must never fire a search. It becomes clickable in the same three
   states that open the bar; since the pointer has to be over the square for
   :hover to be true at all, there is no dead frame between the bar opening and
   the icon taking the click, and the glass does not move while that happens.
   The tab order is untouched — pointer-events has no bearing on it, and tabbing
   to the button opens the bar through :focus-within on the way in. */
.site-search__submit {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  width: calc(var(--search-rest) - 2px);
  pointer-events: none;
}

/* Every paint the generic `button` rules hand it is wrong here: a pill keyline,
   the shared rainbow border-image, a cyan ink that would fight the field's own
   colour transition, and a :hover lift that would jog the icon a pixel off the
   spot the reader aimed at. The class is doubled to (0,2,0) for the same reason
   .site-search__input is — ten month sheets dress bare `button` at (0,1,2).
   `color: inherit` is the load-bearing declaration: it is what keeps the glass
   riding the field's single --muted → --tt-red transition now that it is drawn
   a level further down. Hover is a separate doubled rule rather than folded in
   above because October's `body[…] button:hover` reaches (0,2,2), which clears
   a plain (0,2,0) but not the (0,3,0) a doubled class plus :hover makes. */
.site-search__submit.site-search__submit {
  padding: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: inherit;
  box-shadow: none;
}

.site-search__submit.site-search__submit:hover {
  color: inherit;
  box-shadow: none;
  transform: none;
}

.site-search:hover .site-search__submit,
.site-search:focus-within .site-search__submit,
.site-search--open .site-search__submit { pointer-events: auto; }

/* Magnifier, drawn from two pseudo-elements rather than a glyph or an image, so
   it needs no font coverage and no request. Both take currentColor, which the
   button inherits from the field, so the field's single colour transition
   lights the whole icon at once.

   ONE NUMBER SIZES IT. Every length below is a ratio of --search-lens, so the
   glass grows or shrinks from that single declaration and stays in proportion
   — the ring, its stroke, the handle's length, thickness and radius, and both
   offsets. It was drawn at 0.56rem, which put a 9px lens in a 36px square and
   read as a speck next to the instruments in the card; the ratios are the ones
   that drawing established, so scaling the token changes the size and nothing
   else about the shape.

   Anchored to the RIGHT — the edge the bar grows out of — so the glass holds
   still in the corner and the slab unrolls from beneath it. The offsets centre
   the drawn icon in the resting square: the lens box starts the icon and the
   rotated handle ends it, so the icon's midpoint is 0.79 lens-widths from
   either end. Measured back from --search-centre, that puts the lens's right
   edge at 0.209 lens-widths and the handle box's right edge at 0.8875. The
   handle still pivots about its own left edge, which `right` positions just as
   well as `left` did — the box is placed first, then rotated.

   The offsets resolve against the button's padding box, which is the full
   (--search-rest - 2px) square described above: same right edge and same height
   as the field's padding box, so these numbers mean what they always meant.
   Neither pseudo needs `pointer-events: none` any more — the button behind them
   is the hit target now, and it inherits down. */
.site-search__submit::before,
.site-search__submit::after {
  content: "";
  position: absolute;
  top: 50%;
}

.site-search__submit::before {
  right: calc(var(--search-centre) - var(--search-lens) * 0.209);
  width: var(--search-lens);
  height: var(--search-lens);
  margin-top: calc(var(--search-lens) * -0.75);
  border: max(1.5px, calc(var(--search-lens) * 0.167)) solid currentColor;
  border-radius: 50%;
}

.site-search__submit::after {
  right: calc(var(--search-centre) - var(--search-lens) * 0.8875);
  width: calc(var(--search-lens) * 0.607);
  height: max(1.5px, calc(var(--search-lens) * 0.232));
  margin-top: calc(var(--search-lens) * 0.286);
  border-radius: calc(var(--search-lens) * 0.125);
  background: currentColor;
  transform: rotate(45deg);
  transform-origin: left center;
}

/* The global input rule caps every field at 30rem, adds a label gap and paints
   its own frame; the search bar is a header control wearing the field's frame,
   so it opts out of all of it. `border: 0` also zeroes the shared rainbow
   border-image further down the sheet — border-image never paints against
   border-style: none.

   At rest the input is transparent but fully present: it fills the collapsed
   square, so a tap on the glass lands on the input, focuses it, and opens the
   bar through :focus-within. That is the whole touch story — no toggle button
   and no JS. `opacity` is the only property that can do it; `display: none` and
   `visibility: hidden` both drop the input out of the tab order AND out of hit
   testing, which is exactly what must not happen here.

   Vertical padding is 0 on purpose. `align-items: stretch` hands the input the
   field's full inner height and a single-line text control centres its own
   value box inside that, so the type sits centred in the square at rest and in
   the bar when open without a magic number to keep in step with --search-rest.

   Horizontal padding stays small and constant in both states — clearing the
   glass is the field's --search-gutter's job, not the input's, so no layout
   property on the input has to animate. */
.site-search__input {
  flex: 1 1 auto;
  width: auto;
  min-width: 0;
  margin-top: 0;
  padding: 0 0.35rem 0 0.75rem;
  border: 0;
  border-radius: 0.45rem;
  background: transparent;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.02em;
  opacity: 0;
  transition: opacity 160ms ease;
}

/* Ten month sheets dress `body[data-theme-id="…"] input` with a fill, and
   October and November add a border on top of that — all at (0,1,1), which
   outranks a bare `.site-search__input` at (0,1,0). The input already sits
   inside a control carrying its own frame, so that paints a second box within
   the slab; inside the collapsed square it would draw a box around the
   magnifier as the input faded up. Doubling the class to (0,2,0) restores the
   one-slab-not-three intent described above, without pushing search structure
   down into a month sheet. `color` is deliberately left out: months should keep
   tinting the query text with their own ink. */
.site-search__input.site-search__input {
  border: 0;
  background: transparent;
  box-shadow: none;
}

.site-search:hover .site-search__input,
.site-search:focus-within .site-search__input,
.site-search--open .site-search__input { opacity: 1; }

.site-search__input::placeholder {
  color: var(--muted);
  opacity: 1;
  transition: color 180ms ease;
}

.site-search:hover .site-search__input::placeholder,
.site-search:focus-within .site-search__input::placeholder,
.site-search--open .site-search__input::placeholder { color: var(--text-soft); }

/* Chrome and Safari draw their own clear affordance on type="search"; it lands
   on the wrong side of the theme palette, and Escape already clears the field. */
.site-search__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }

/* The focus ring moves out to the field so it frames the whole slab instead of
   landing on top of the field's own keyline. That matters more now the field
   clips its overflow: a ring on the input — or on the magnifier button, which
   is why it is in here too — would be swallowed by that clip, while the field's
   own outline paints outside it untouched.

   @supports gates the `outline: none` behind :has(), so an engine that cannot
   match the field selector keeps each control's own ring rather than losing the
   focus indicator entirely. The doubled class is for January, which sets
   `input:focus-visible { outline }` at (0,2,1) and `button:focus-visible` at
   (0,2,2); (0,3,0) takes both back without the month having to know the search
   bar exists. */
@supports selector(:has(*)) {
  .site-search__input.site-search__input:focus-visible,
  .site-search__submit.site-search__submit:focus-visible { outline: none; }

  .site-search__field:has(.site-search__input:focus-visible),
  .site-search__field:has(.site-search__submit:focus-visible) {
    outline: 1px solid var(--tt-yellow);
    outline-offset: 3px;
  }

  /* Third keyline, one ring further out, on the same trigger as the yellow so
     the two arrive and leave together. It has to live on the FORM: an element
     gets exactly one outline and the field has already spent its own, while a
     pseudo-element ring would be eaten by the field's overflow clip. The form's
     border box is the field's border box — the panel and the status line are
     both out of flow, so the form wraps the field and nothing else — which
     keeps all three lines concentric at either width. The radius is here for
     the same reason: the form carries no border or fill, so it exists purely to
     round this ring to match the two inside it. 1px like the others, and the
     same 3px gap the yellow leaves: yellow lands at 3–4px, orange at 7–8px. */
  .site-search:has(.site-search__input:focus-visible),
  .site-search:has(.site-search__submit:focus-visible) {
    border-radius: 0.6rem;
    outline: 1px solid var(--tt-orange);
    outline-offset: 7px;
  }
}

/* Anchored to the bar's right edge so it grows leftwards and can never run off
   the right of the screen, and clamped to the same inset the bar uses. It is
   inside the fixed form, so it is measured against the viewport too — 70vh of
   list always fits below a bar that is pinned near the top.

   Sized off --search-open rather than off `100%`, which used to be the same
   number and no longer is: `100%` now resolves against the bar's animating
   width, so the panel would have re-laid-out on every frame of the roll-out and
   would have opened 2.25rem wide if it were ever asked to show from the
   collapsed state. Reading the target width instead makes the panel identical
   whichever state it opens from. The 19rem floor still applies below 760px,
   where the open bar is narrower than the list wants to be, and the max-width
   clamp keeps both cases on screen. */
.site-search__panel {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  left: auto;
  z-index: 1;
  width: max(var(--search-open), 19rem);
  max-width: calc(100vw - var(--search-inset) * 2);
  max-height: min(70vh, 30rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid var(--border-dim);
  border-radius: 0.5rem;
  background: var(--bg);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}

.site-search__panel[hidden] { display: none; }

.site-search__option {
  display: block;
  padding: 0.6rem 0.8rem;
  border-bottom: 1px solid var(--border-dim);
  color: var(--text);
  cursor: pointer;
}

.site-search__option:last-of-type { border-bottom: 0; }

/* Hover and keyboard selection are the same state, so arrowing through the list
   looks identical to pointing at it. */
.site-search__option:hover,
.site-search__option.is-active {
  background: var(--surface);
  color: var(--tt-pink);
  animation: none;
  text-shadow: none;
}

.site-search__option-title {
  display: block;
  font-weight: 700;
  line-height: 1.25;
}

/* The /search chip, shrunk to suggestion scale. It is inside the title span and
   never an anchor — the whole row is already one link to the page, and the tag
   page is one hop from there. `font-weight: 400` undoes the title's 700. */
.site-search__option-tag {
  margin-left: 0.4rem;
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--border-dim);
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 0.7rem;
  letter-spacing: 0.03em;
  text-transform: lowercase;
  color: var(--muted);
}

.site-search__option-path {
  display: block;
  margin-top: 0.15rem;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
}

.site-search__option-snippet {
  display: block;
  margin-top: 0.3rem;
  color: var(--text-soft);
  font-size: 0.8rem;
  line-height: 1.4;
}

.site-search__empty {
  margin: 0;
  padding: 0.75rem 0.8rem;
  color: var(--muted);
  font-size: 0.84rem;
}

.site-search__more {
  display: block;
  width: 100%;
  border: 0;
  border-top: 1px solid var(--border-dim);
  border-radius: 0;
  padding: 0.6rem;
}

/* Announces result counts to screen readers; the panel already shows them. */
.site-search__status {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The wordmark is `white-space: nowrap` at clamp(2.8rem, 9vw, 5.75rem), so it
   claims a bigger share of the line the narrower the viewport gets. Only the
   open width steps down here — the resting square is already the smallest the
   bar can be and never crowds the brand at any width, which is what retired the
   old 520px breakpoint: the phone case and the desktop case are now the same
   case. The open bar is still clamped so it cannot run past the viewport. */
@media (max-width: 760px) {
  .site-search { --search-open: min(17rem, calc(100vw - var(--search-inset) * 2)); }
}

/* Touch and pen have no hover, so the open state arrives through :focus-within
   alone: a tap lands on the transparent input filling the collapsed square,
   focuses it, and rolls the bar out. On these pointers the square is a button
   in everything but name, so it grows to 44px — the smallest comfortable tap
   target — and --search-centre and --search-gutter follow it automatically. */
@media (hover: none) {
  .site-search { --search-rest: 2.75rem; }
}

/* ==========================================================================
   07-article.css — THE ARTICLE COLUMN AND THE INDEX FLOATING IN IT

   The main and .markdown-page measure, then the .page-toc geometry, the
   clear-right rules for blocks that do not reflow, the rainbow rail, the
   click-only summary and the high-specificity title override.

   ORDER AND LOCKS
   The shell leads the file because the float is measured against it. The 700px
   query must stay after `.page-toc` (same specificity), and
   `.markdown-page .page-toc h2.page-toc__title` must stay after the base title
   rule and keep all three declarations that undo the heading text-clip.

   RULES · style/article.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

main {
  width: min(100% - 2rem, 1080px);
  margin: clamp(1.25rem, 3vw, 2.25rem) auto 3rem;
  padding: clamp(1.1rem, 3.2vw, 2.6rem);
}

.markdown-page {
  width: min(100%, 56rem);
  margin-inline: auto;
}

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

.markdown-page > :last-child,
.markdown-page > .markdown-page__sections > :last-child { margin-bottom: 0; }

/* The wrapper is a box main.js added for THE COLUMN LADDER and nothing else.
   It contributes no spacing of its own — the sections inside it keep the
   margins they had as direct children of the article. */
.markdown-page__sections { margin: 0; padding: 0; }

.markdown-page > h1 {
  margin-top: 2.6rem;
}

/* No left indent — these lists are centred; see PROSE LISTS ARE DEAD CENTRE. */
.markdown-page > ul, .markdown-page > ol {
  margin: 0.7rem 0 1.6rem;
  padding-left: 0;
}

.markdown-page > ul > li, .markdown-page > ol > li {
  padding-block: 0.14rem;
}

.markdown-page img {
  border: 1px solid var(--border-dim);
  border-radius: 0.45rem;
}

/* THE INDEX SITS ON THE RIGHT (2026-08-18, user instruction). It is the first
   thing in the article and it is floated out of the column's left-to-right
   flow, so the page opens with its own shape hung in the top-right corner and
   the prose running beside it rather than starting under it.

   A float rather than a grid column: the article is a flat stream of markdown
   blocks with no wrapper to make a column out of, and a float is the one
   mechanism that lets an arbitrary number of those blocks flow past a taller
   element without any of them knowing it is there. It also means the shut
   index costs the page almost nothing — the reader's first paragraph closes
   the gap beside it — and the open one simply takes more of that gutter.

   The left rail on ::before now does a second job: with prose to its left it is
   the rule between the page and its index, not merely a mark on the block. That
   is why it stays on the left edge rather than following the block across. */
.page-toc {
  --toc-pad: clamp(1rem, 2.5vw, 1.5rem);
  position: relative;
  float: right;
  /* Wide enough that a two-level heading rarely wraps, narrow enough that the
     prose beside it keeps a readable measure inside the 56rem article. */
  width: clamp(14rem, 26%, 19rem);
  margin: 0 0 1.4rem 2rem;
  padding: var(--toc-pad);
  padding-left: calc(var(--toc-pad) + 0.55rem);
  border: 0;
  border-image: none;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}

/* A float is out of flow, so an index longer than the page it indexes would
   hang below the article and into the footer. The article closes over it. */
.markdown-page::after {
  content: "";
  display: block;
  clear: both;
}

/* Related is the one block that must not sit beside a SHUT index. Both are
   collapsed <details> carrying a display heading, and two headings sharing a
   line at two sizes and two alignments read as an accident rather than as a
   column — the more so because `.related__heading` is set flush right and would
   land a hand's width from the index title.

   Open, the index is a tall rail and there is no such collision: Related reads
   as the first thing in the column beside it, and clearing here would instead
   strand the whole page under a 350px column of nothing. So the clear is tied
   to the state that needs it and nothing else. */
.markdown-page > .page-toc:not([open]) + .related {
  clear: right;
}

/* A float shortens LINE boxes, not block boxes, so anything that cannot reflow
   its content into a narrower measure would draw straight under the index
   rather than beside it. These are those things: a table's border box ignores
   the float outright, and a map canvas, a filtered table and a migration
   passage are all fixed-height widgets sized from their own container. They
   step below the index instead of colliding with it. Prose, lists and headings
   are not in this list and must not be added to it — they wrap, which is the
   whole reason the index is a float. */
.markdown-page table,
.markdown-page .table-filter,
.markdown-page .map-app,
.markdown-page .passage {
  clear: right;
}

.page-toc::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 3px;
  background: var(--tt-heading-rainbow-vertical);
  box-shadow: none;
  pointer-events: none;
  z-index: 1;
}

/* On a phone there is no right hand side to move to: a 14rem index beside a
   440px article leaves the prose a measure of three or four words. The float is
   dropped and the index goes back to a full-width block at the head of the
   page, which is where it was before it moved. */
@media (max-width: 700px) {
  .page-toc {
    float: none;
    width: auto;
    margin: 0 0 2rem;
  }
}

.page-toc > * {
  position: relative;
  z-index: 2;
}

.page-toc .page-toc__title {
  margin: 0 0 0.75rem;
  color: var(--tt-cyan);
  font-size: 1rem;
  font-family: var(--font-mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.page-toc ol {
  margin: 0.25rem 0 0;
  padding-left: 1.35rem;
}

.page-toc li {
  margin: 0.24rem 0;
}

.page-toc a {
  font-family: var(--font-mono);
  font-size: 0.86rem;
}

/* CLICK ONLY (2026-08-16, user instruction). `.page-toc` is a native
   `<details>` emitted without `open`, so the index is shut on arrival and the
   reader's own click is the one thing that unrolls it. This is deliberately
   NOT the `.heading-section` machinery below: that system keeps every
   <details> structurally open and collapses the body in CSS so a nested
   section stays in the hover path, and nothing here is nested or hovered. A
   pointer crossing the top of the page on its way into the prose must not
   unroll the index under it.

   The native marker goes the same way `.heading-section__summary` and
   `.related__summary` send theirs: no triangle anywhere on this site. */
.page-toc__summary {
  list-style: none;
  cursor: pointer;
}

.page-toc__summary::-webkit-details-marker {
  display: none;
}

/* A shut index carries no interior spacing. The title's 0.75rem is the gap
   before a list that is not on screen. */
.markdown-page .page-toc:not([open]) h2.page-toc__title {
  margin-bottom: 0;
}

/* THE TITLE IS AN H2 AND MUST NOT LOOK LIKE ONE — the same problem the Related
   heading solves, one tier down. A rendered Markdown h2 is a section of the
   note; this is the sign over the note's index.

   SPECIFICITY, DELIBERATELY. Eight month sheets restyle `main h2` at up to
   (0,2,3), which beats the `.page-toc .page-toc__title` rule above at (0,2,0)
   and would hand the index title a 2.55rem display face. The type is therefore
   carried here at (0,3,1), which no month sheet reaches.

   AND THE FILL HAS TO BE HANDED BACK. `main h1, main h2, …` paints every
   heading by clipping --tt-heading-text-rainbow to the glyphs with
   `-webkit-text-fill-color: transparent`, and that fill beats a `color` on any
   selector — so the `color` above does nothing at all until the background is
   cleared, the clip returned to border-box, and the fill restored. Three
   declarations, not one.

   `color` IS CARRIED HERE TOO, for the same reason the type is: a month sheet
   painting `main h2` in its display colour would otherwise take the index title
   with it. The `.page-toc .page-toc__title` rule above keeps the value as
   documentation of the default; this is the copy that actually lands. A sheet
   that wants its own has to reach past (0,3,1) — which is exactly what the
   default sheet now does at (0,4,2) to keep its gold. */
.markdown-page .page-toc h2.page-toc__title {
  margin: 0 0 0.75rem;
  color: var(--tt-cyan);
  background: none;
  -webkit-background-clip: border-box;
  background-clip: border-box;
  -webkit-text-fill-color: currentColor;
  animation: none;
  text-shadow: none;
  font-family: var(--font-mono);
  font-style: normal;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

/* The month sheets hang a decorative mark off a heading; the index title is
   furniture and takes the rail on the left instead. */
.markdown-page .page-toc h2.page-toc__title::after,
.markdown-page .page-toc h2.page-toc__title::before {
  content: none;
}

/* The nested lists are the page's own heading depth, so an entry sits at the
   indent its section does. `.page-toc ol` above already carries the step. */
.page-toc__list {
  list-style: decimal;
}

.page-toc__body > .page-toc__list {
  margin-top: 0;
}

/* An entry minted only to keep a skipped heading level legal — an h2 straight
   to an h4 — is structure, not a line the reader should see. */
.page-toc__item--spacer {
  list-style: none;
}

.page-toc__link {
  color: var(--text-soft);
}

.page-toc__link:hover,
.page-toc__link:focus-visible {
  color: var(--tt-pink);
}

/* ==========================================================================
   08-headings.css — EVERY RENDERED HEADING — RAIL, REVEAL AND TIER

   The .heading-section rail and its reveal, the 0fr-to-1fr body transition and the
   doubled collapsed class, the :has() column ladder, then the site-wide heading
   face, the per-level tier and tint filter, and the heading rhythm.

   ORDER AND LOCKS
   Two pins, and one of them is a known latent bug rather than a design.

   The prefers-reduced-motion block must stay exactly between the column ladder and
   the bare `footer` rule: its `main h1…h6 { animation: none }` TIES the rainbow
   rule ~30 lines later and currently LOSES. Moving motion rules to the end of the
   sheet is therefore a rendering change, not a refactor. It is flagged, not fixed.
   The bare `footer` rule rides along for the same reason, though it belongs with
   the frame's page measure.

   Do not simplify the doubled --collapsed class, and do not split the tier
   declarations from the tint filter. The .heading-section structure is locked,
   rail included — never use a native closed <details> here.

   RULES · style/headings.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

.heading-section {
  position: relative;
  margin: 0.45rem 0;
}

/* The section rail. A locked, user-sanctioned part of the heading structure:
   it marks the active heading path, so it is the one left-edge accent headings
   are allowed. Anything else painting a heading's left edge is a bug. */
.heading-section::before {
  content: "";
  position: absolute;
  top: 0.45rem;
  bottom: 0;
  left: 0;
  width: 3px;
  background: var(--tt-heading-rainbow-vertical);
  box-shadow: 0 0 10px rgba(176, 214, 143, 0.28);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms ease;
}

.heading-section--active::before,
.heading-section:hover::before,
.heading-section:focus-within::before {
  opacity: 1;
}

/* Level-1 sections are full-bleed (width: 100vw; margin-left: calc(50% - 50vw)
   further down this sheet), so `left: 0` would pin this rail to the edge of the
   VIEWPORT rather than the content column — a bar down the far edge of the
   window, detached from the heading it marks. Match the inner column's own
   geometry instead: it is min(100% - 2rem, 56rem) centred, where 100% resolves
   to 100vw, so its left edge is max(1rem, (100vw - 56rem) / 2). The max() takes
   over below a 58rem viewport, where the column becomes 100vw - 2rem.
   Do not "simplify" this back to `left: 0`. */
.heading-section--level-1::before {
  left: max(1rem, calc(50vw - 28rem));
}

/* No padding under the summary. The gap beneath a rendered heading was built
   from three stacked paddings — this one, the trigger's own bottom padding and
   the body-inner's top padding — so zeroing any single one left the other two
   still holding the heading off its content. All three are zero now; see the
   note on .heading-section__body-inner. */
.heading-section__summary {
  list-style: none;
  padding-bottom: 0;
}

.heading-section__summary::-webkit-details-marker {
  display: none;
}

/* Bottom padding is 0 — the heading sits directly on the content below it.
   The 0.5rem top padding stays: that is the heading's lead-in from whatever
   preceded it, not spacing beneath it. */
.heading-section__trigger {
  position: relative;
  margin: 0;
  padding: 0.5rem 2rem 0 0;
  border: 0;
  border-bottom: 0 !important;
  border-image: none !important;
  cursor: pointer;
  transition: color 160ms ease, text-shadow 160ms ease;
}

.markdown-page .heading-section__trigger {
  background: var(--tt-heading-text-rainbow);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  text-shadow: 0 0 12px rgba(176, 214, 143, 0.24);
  animation: headingRainbowFlow 5.4s linear infinite;
}

.heading-section--level-1 > .heading-section__summary > .heading-section__trigger { animation-delay: -0.7s; }

.heading-section--level-2 > .heading-section__summary > .heading-section__trigger { animation-delay: -1.9s; }

.heading-section--level-3 > .heading-section__summary > .heading-section__trigger { animation-delay: -3.1s; }

.heading-section--level-4 > .heading-section__summary > .heading-section__trigger { animation-delay: -4.2s; }

.heading-section--level-5 > .heading-section__summary > .heading-section__trigger { animation-delay: -2.6s; }

.heading-section--level-6 > .heading-section__summary > .heading-section__trigger { animation-delay: -1.3s; }

/* THE WRAPPER IS NAMED HERE TOO. Top-level sections used to be direct children
   of the article; main.js now gathers the run of them into
   `.markdown-page__sections` so THE COLUMN LADDER has a box to column (see
   wrapTopLevelSections()). `> .heading-section` stopped matching the moment it
   did, and these four rules — the stagger that stops every top-level heading
   flowing its rainbow in lockstep — would have gone quietly dead. Counting
   still works inside the wrapper: every one of its children is a
   `details.heading-section`, so `nth-of-type` counts exactly the sections. */
.markdown-page > .heading-section:nth-of-type(4n + 1) > .heading-section__summary > .heading-section__trigger,
.markdown-page__sections > .heading-section:nth-of-type(4n + 1) > .heading-section__summary > .heading-section__trigger { animation-delay: -0.4s; }

.markdown-page > .heading-section:nth-of-type(4n + 2) > .heading-section__summary > .heading-section__trigger,
.markdown-page__sections > .heading-section:nth-of-type(4n + 2) > .heading-section__summary > .heading-section__trigger { animation-delay: -1.6s; }

.markdown-page > .heading-section:nth-of-type(4n + 3) > .heading-section__summary > .heading-section__trigger,
.markdown-page__sections > .heading-section:nth-of-type(4n + 3) > .heading-section__summary > .heading-section__trigger { animation-delay: -2.9s; }

.markdown-page > .heading-section:nth-of-type(4n) > .heading-section__summary > .heading-section__trigger,
.markdown-page__sections > .heading-section:nth-of-type(4n) > .heading-section__summary > .heading-section__trigger { animation-delay: -4.1s; }

.heading-section__body {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows 240ms ease, opacity 180ms ease;
}

/* No left border here — the section's left rail is the .heading-section::before
   bar, and left borders are reserved for list anchors.

   Top padding is 0: this box is the "element that follows the heading", so its
   leading padding is spacing beneath the heading. The 0.8rem bottom padding is
   the section's own trailing gap before the next sibling and stays. */
.heading-section__body-inner {
  position: relative;
  min-height: 0;
  overflow: hidden;
  padding: 0 0 0.8rem clamp(0.45rem, 1.6vw, 1rem);
}

/* The first real element inside a section drops its top margin too. Paragraphs
   already have none, but lists, tables, pre, blockquote, hr and a nested
   .heading-section all carry one, and any of those landing first would put the
   gap straight back. (0,2,0) — :first-child counts as a class — so this also
   outranks .markdown-page > ul/ol at (0,1,1). */
.heading-section__body-inner > :first-child {
  margin-top: 0;
}

.heading-section--active > .heading-section__body,
.heading-section:hover > .heading-section__body,
.heading-section:focus-within > .heading-section__body {
  grid-template-rows: 1fr;
  opacity: 1;
}

.heading-section--active > .heading-section__summary > .heading-section__trigger,
.heading-section:hover > .heading-section__summary > .heading-section__trigger,
.heading-section:focus-within > .heading-section__summary > .heading-section__trigger {
  padding-left: clamp(0.55rem, 1.6vw, 0.9rem);
}

/* A CLICK COLLAPSES A HEADING (2026-08-15, user instruction). The hover reveal
   above is untouched — pointer and focus still open a section, and that is
   still the locked default behaviour. This is the one state that outranks it:
   a click on the heading marks its section `--collapsed`, and the body then
   stays shut under `:hover`, `:focus-within` and `--active` alike. Without
   beating all three, the very pointer that did the clicking would roll the
   section straight back open and the click would look like it did nothing.
   Clicking the heading again clears it.

   The class is repeated to carry the rule at (0,3,0), one step above the three
   open rules at (0,2,0). Do not "simplify" it back to a single class.
   `main.js` owns the toggle and persists the collapsed set per page. */
.heading-section--collapsed.heading-section--collapsed > .heading-section__body {
  grid-template-rows: 0fr;
  opacity: 0;
}

.heading-section--level-1 > .heading-section__summary > .heading-section__trigger {
  margin-top: 0;
  padding-top: 0;
  padding-bottom: 0;
  font-size: clamp(2.5rem, 7vw, 4.75rem);
}

.heading-section--level-1 {
  width: 100vw;
  margin-top: 0;
  margin-bottom: 0;
  margin-left: calc(50% - 50vw);
}

.heading-section--level-1 > .heading-section__summary {
  padding-bottom: 0;
}

.heading-section--level-1 > .heading-section__summary,
.heading-section--level-1 > .heading-section__body > .heading-section__body-inner {
  width: min(calc(100% - 2rem), 56rem);
  margin-inline: auto;
}

.heading-section--level-2 > .heading-section__summary > .heading-section__trigger { font-size: clamp(1.7rem, 4vw, 2.55rem); }

.heading-section--level-3 > .heading-section__summary > .heading-section__trigger { font-size: clamp(1.22rem, 2.4vw, 1.58rem); }

/* THE COLUMN LADDER (2026-08-18, user instruction). A heading level says how
   many columns ITS SECTIONS LIE IN, and the sections themselves are what goes
   in the columns — not the prose inside one. h1 is always a single column,
   h2 can take two, h3 three, h4 four, AND THERE THE LADDER STOPS: h5 and h6
   are four as well, so a document that outlines six deep cannot keep halving a
   measure that is already about thirty characters wide. Four is the desktop
   lock; it does not grow with the window, because the article is capped at
   56rem and a fifth column would be shredding the text, not setting it.

   THE COUNT IS A CEILING, NOT A QUOTA. It takes two h2s to make two columns
   and three h3s to make three, so a page that runs one h1, one h2 and one h3
   is a single column the whole way down — there is nothing to stand beside
   anything. The used count is therefore min(level, how many siblings are
   actually there, 4): two h3s give two columns, three give three, and a
   fourth joins the three rather than opening a column the level is not
   entitled to.

   IT IS COUNTED IN CSS, WITH `:has()`, AND THE SPECIFICITY DOES THE PICKING.
   `:has(> .level-3 ~ .level-3)` is "two or more h3 children"; adding a third
   `~ .level-3` asks for three. Each link in that chain is another class, so
   the three-child rule outranks the two-child rule by exactly one point and
   the four-child rule outranks both — the largest count that matches is the
   one that wins, with no ordering to maintain and nothing for main.js to
   compute. Do not "simplify" the chains into `:has(> .level-3):has(> .level-3)`;
   that is the same selector twice and matches a single child.

   THE CONTAINER IS THE BOX THE SIBLINGS SHARE, AND THERE ARE TWO OF THEM.
   `renderInteractiveTokens` in main.js nests a heading and everything under it
   in a `.heading-section` <details>, so a run of sibling h3s are children of
   their h2's `.heading-section__body-inner`. The OUTERMOST run has no such
   parent — `stripTitleHeading` takes a note's lone h1 away to letter the page
   with, so a page's top-level sections are usually h2s — and main.js therefore
   wraps that run in `.markdown-page__sections`. See wrapTopLevelSections() for
   why the article itself could not be the container: it also holds the floated
   index, which stops floating the moment it becomes a grid item.

   h1 STILL NEEDS NO RULE. It is one column by the instruction, and a note that
   keeps several h1s (so none was stripped) has them full-bleed at `width:
   100vw` — a level-1 section matches nothing here and stays in a single
   full-width track, which is the one place it can still bleed.

   THE PROSE TAKES THE FULL MEASURE. Loose text under a heading always comes
   before that heading's subsections — the renderer cannot interleave them — so
   the intro spans every track and the columns start beneath it. A section is
   one grid item and is never sliced down the middle by a column boundary.

   A COLUMN NEVER GETS NARROWER THAN 11rem, AND THAT IS THE MOBILE ANSWER. The
   track expression below carries that floor, so four columns fit inside the
   56rem article and a phone falls to one or two without a media query firing
   at all. The query at the end of the block writes the instruction down
   anyway: three is the ceiling on a skinny display, h4-h6 included. */
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-2 ~ .heading-section--level-2),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-3 ~ .heading-section--level-3),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-4 ~ .heading-section--level-4),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-5 ~ .heading-section--level-5),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-6 ~ .heading-section--level-6) {
  --section-columns: 2;
}

:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-3 ~ .heading-section--level-3 ~ .heading-section--level-3),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-4 ~ .heading-section--level-4 ~ .heading-section--level-4),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-5 ~ .heading-section--level-5 ~ .heading-section--level-5),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-6 ~ .heading-section--level-6 ~ .heading-section--level-6) {
  --section-columns: 3;
}

:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-4 ~ .heading-section--level-4 ~ .heading-section--level-4 ~ .heading-section--level-4),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-5 ~ .heading-section--level-5 ~ .heading-section--level-5 ~ .heading-section--level-5),
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-6 ~ .heading-section--level-6 ~ .heading-section--level-6 ~ .heading-section--level-6) {
  --section-columns: 4;
}

/* One declaration for every container that earned a count above. A container
   that earned nothing keeps the `1` fallback, which is a single full-width
   track — exactly the layout the page had before this rule.

   GRID, NOT `column-count`, AND THAT IS NOT A STYLE PREFERENCE. This box is the
   grid item of the reveal animation: `.heading-section__body` transitions
   `grid-template-rows` from 0fr to 1fr, so `.heading-section__body-inner` is
   height-clamped for the whole of that transition and flat at zero while the
   section is shut. A multicol box with a clamped height is a FRAGMENTAINER: it
   fills the height it is given, then keeps opening columns off to the right
   instead of getting taller. Measured on a shut section, eight h4s laid
   themselves out 6761px wide inside an 864px box and were clipped to nothing
   by the `overflow: hidden` above. Grid does not fragment — a clamped grid
   container simply overflows downward and clips, which is what every other
   block in this box already does.

   THE TRACK EXPRESSION IS THE `column-width` GUARD, REBUILT IN GRID. Multicol
   gets responsive behaviour free: `columns: 11rem 4` means "four, but never
   narrower than 11rem". `repeat(auto-fit, minmax(max(FLOOR, EVEN-SHARE), 1fr))`
   is that same rule written for grid. Where the box is wide, EVEN-SHARE — the
   width one of N tracks would take — is the larger, and exactly N tracks fit.
   Where it is narrow, the 11rem FLOOR is the larger and fewer tracks fit, down
   to one on a phone. The count is a ceiling either way and no breakpoint has
   to know the number.

   THE OUTER `min(100%, ...)` IS NOT DECORATION. A section nested four deep can
   be narrower than the 11rem floor itself — measured at 169px of content — and
   a floor wider than the box gives one track wider than the box, which then
   hangs 7px out of its own container with nothing to clip it. Capping the
   track at 100% makes the floor a preference rather than a demand: it holds
   wherever it fits and yields where it does not. */
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections) {
  /* THE RESET IS LOAD-BEARING. `--section-columns` is a custom property and
     custom properties INHERIT, so without this a section that earned two
     columns would hand that two down to every subsection under it — a lone h3
     inside a two-column h2 would come out in two columns of its own, having
     matched none of the `:has()` rules. Declaring the floor here at (0,2,0)
     re-anchors every container to one; the `:has()` rules above sit at (0,4,0)
     and higher and still win wherever they actually match. */
  --section-columns: 1;
  --section-column-min: 11rem;
  --section-column-gap: clamp(0.9rem, 2vw, 1.6rem);
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(
      min(
        100%,
        max(
          var(--section-column-min),
          calc(
            (100% - (var(--section-columns) - 1) * var(--section-column-gap))
            / var(--section-columns)
          )
        )
      ),
      1fr
    )
  );
  column-gap: var(--section-column-gap);
  row-gap: 0;
  /* Sections keep their own height rather than being stretched to the tallest
     one in the row — a stretched section would run its left rail past the end
     of its own text. */
  align-items: start;
}

/* The intro paragraphs, lists, tables and figures that sit above a section's
   subsections take the full measure; the subsections then flow into the tracks
   beneath them. The renderer cannot interleave the two — everything loose under
   a heading is emitted before the first subsection — so this is a rule about
   the top of the box, not a rule that has to fight for placement. */
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections) > :not(.heading-section) {
  grid-column: 1 / -1;
}

/* TWO RULES THAT KEEP A NARROW TRACK FROM BEING BLOWN OPEN, AND BOTH ARE
   NEEDED. A grid item's automatic minimum size is `min-width: auto`, which
   means its MIN-CONTENT width — the longest unbreakable word in it — is a
   floor the item will not go below. Under the old block layout a long word
   simply overhung its line; as a grid item it widens the box itself, and
   measurement across the vault found 40 blocks on one page sticking 3-15px out
   of their own container the moment the tracks came in. `min-width: 0` takes
   that floor away.

   With the floor gone the word has to be allowed to break, or it goes back to
   overhanging — "Merged Segment (.review-card)" was measured 167px past its
   section at desktop width and 228px on a phone. `break-word` is the last
   resort it says it is: it fires only where a word cannot fit whole, so
   ordinary prose wraps exactly as it did before, and `pre` is untouched
   because it keeps `white-space: pre` and its own `overflow-x: auto`. */
:is(.markdown-page .heading-section__body-inner, .markdown-page__sections) {
  overflow-wrap: break-word;
}

:is(.markdown-page .heading-section__body-inner, .markdown-page__sections) > * {
  min-width: 0;
}

/* THE SKINNY-DISPLAY CEILING. Three columns at most, h4-h6 included, and a
   narrower minimum so two or three are reachable there instead of the 11rem
   floor above clamping everything to one. 700px is the breakpoint the index
   float and the related grid already turn on, so the article changes shape
   once rather than three times. */
@media (max-width: 700px) {
  :is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-4 ~ .heading-section--level-4 ~ .heading-section--level-4 ~ .heading-section--level-4),
  :is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-5 ~ .heading-section--level-5 ~ .heading-section--level-5 ~ .heading-section--level-5),
  :is(.markdown-page .heading-section__body-inner, .markdown-page__sections):has(> .heading-section--level-6 ~ .heading-section--level-6 ~ .heading-section--level-6 ~ .heading-section--level-6) {
    --section-columns: 3;
  }

  :is(.markdown-page .heading-section__body-inner, .markdown-page__sections) {
    --section-column-min: 10rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .heading-section__body,
  .heading-section__body-inner,
  .heading-section__trigger,
  main h1,
  main h2,
  main h3,
  main h4,
  main h5,
  main h6 {
    transition: none;
  }

  .markdown-page .heading-section__trigger,
  .page-title,
  main h1,
  main h2,
  main h3,
  main h4,
  main h5,
  main h6 {
    animation: none;
    background-position: 50% 0;
  }
}

footer {
  width: min(100% - 2rem, 1080px);
  margin: 0 auto;
  padding: 1.5rem 0 2rem;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.06em;
  text-align: center;
}

/* Every heading is set in caps, at every level and on every theme (user
   instruction). The default sheet already lettered its h1 this way; the rule
   lives here instead so h2–h6 cannot fall out of step when a theme is swapped.
   On a markdown page the heading IS .heading-section__trigger — main.js emits
   it as an h1–h6 carrying that class — so this selector reaches both halves of
   the site without a second rule. Tracking is left to the theme sheets: caps
   want more of it, and how much is a per-face decision. */
main h1, main h2, main h3, main h4, main h5, main h6 {
  font-family: var(--font-heading);
  line-height: 1.15;
  text-transform: uppercase;
  background: var(--tt-heading-text-rainbow);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  text-shadow: 0 0 12px rgba(176, 214, 143, 0.24);
  animation: headingRainbowFlow 5.4s linear infinite;
}

/* THE PER-LEVEL HEADING TINT (2026-08-15, user instruction). Every level is the
   same colour one step further along: h1 rests on whatever ink its theme
   chose, and each level below rotates the hue a little further and quietens it
   a little more. Both steps are linear in `--heading-tier`, so the difference
   between two headings IS the distance between their levels — h1 and h3 differ
   twice as much as h1 and h2, and h1 and h6 five times as much. That
   proportion is the instruction; the size of one step is a taste dial.

   IT IS A FILTER, DELIBERATELY, AND THAT IS WHY IT WORKS ON ALL FOURTEEN
   SHEETS. A heading's colour is a theme decision made fourteen different ways
   — a gradient clipped to the glyphs on most months, a flat
   `-webkit-text-fill-color` on `default`, `may`, `june` and `lava-lamp` — so a
   per-level `color` would have to be written into every sheet and kept in step
   there forever, which is exactly how a heading rule falls out of sync when a
   theme is swapped. A filter composes on top of whatever the sheet painted.
   No palette token moves: Red Color Bias still holds at full strength, since
   the tokens still lead red and only h2–h6 shift what they do with them.

   `contrast()` rather than `brightness()` because it is the one function that
   still separates the achromatic sheets. It pivots on mid-grey, so it steps
   `default`'s white headings DOWN and `may`/`june`'s day-mode black headings
   UP; a brightness multiply leaves black at black and those two sheets would
   have shipped with no ladder at all.

   The `main hN` selectors reach both halves of the site in one rule: on a
   markdown page the heading IS `.heading-section__trigger`, emitted by main.js
   as an h1–h6 inside `<main>`.

   THE LADDER IS HANDED OUT AS `--heading-tint`, AND THAT IS THE PART A THEME
   HAS TO HONOUR. Four sheets — january, march, november, october — already
   draw their headings' off-register plate or hard shadow with a `filter` of
   their own at (0,2,0)/(0,3,0), and `filter` does not accumulate across rules:
   the winning declaration replaces the whole list. Those four sheets silently
   dropped the ladder when it was written as a bare `filter` here, so each of
   their heading rules now opens with `var(--heading-tint)` and keeps its own
   drop-shadow after it. Tint first, plate second, on purpose — a drop-shadow
   listed BEFORE the tint gets hue-rotated with everything else, and november's
   sienna and october's void are chosen colours, not tintable ones.
   **Any new theme rule that declares `filter` on a heading must carry
   `var(--heading-tint)` as its first function**, or that sheet loses the
   ladder and nothing on the page will say so. */
main h1 { --heading-tier: 0; }
main h2 { --heading-tier: 1; }
main h3 { --heading-tier: 2; }
main h4 { --heading-tier: 3; }
main h5 { --heading-tier: 4; }
main h6 { --heading-tier: 5; }

main h1, main h2, main h3, main h4, main h5, main h6 {
  --heading-tint:
    hue-rotate(calc(var(--heading-tier, 0) * 9deg))
    saturate(calc(1 - var(--heading-tier, 0) * 0.05))
    contrast(calc(1 - var(--heading-tier, 0) * 0.03));
  filter: var(--heading-tint);
}

/* Every heading's bottom margin is 0 so it sits tight against the content under
   it. Top margins are untouched — those separate a heading from what came
   before and are not spacing "beneath" anything. h4/h5/h6 are given an explicit
   margin-bottom because otherwise they inherit the UA sheet's 1.33em/1.67em/
   2.33em block margins and would keep a gap nothing in this file declared. */
main h1 {
  margin: 0;
  font-size: clamp(2.5rem, 7vw, 4.75rem);
  font-family: var(--font-cursive);
  font-style: italic;
  animation-delay: -0.7s;
}

main h2 { margin: 2.4rem 0 0; font-size: clamp(1.7rem, 4vw, 2.55rem); animation-delay: -1.9s; }

main h3 { margin: 1.8rem 0 0; font-size: clamp(1.22rem, 2.4vw, 1.58rem); animation-delay: -3.1s; }

main h4 { margin-bottom: 0; animation-delay: -4.2s; }

main h5 { margin-bottom: 0; animation-delay: -2.6s; }

main h6 { margin-bottom: 0; animation-delay: -1.3s; }

/* The other half of the gap: whatever follows a heading drops its own top
   margin. At (0,0,2) this beats the element-level defaults further down the
   file (p, ul, ol, blockquote, pre, table, hr) without reaching component
   headings such as .hero-card h2 or .calendar-agenda__heading, which set their
   own margins at class weight. */
main h1 + *,
main h2 + *,
main h3 + *,
main h4 + *,
main h5 + *,
main h6 + * {
  margin-top: 0;
}

/* Margin collapse reopens the gap for lists. `li` carries a 0.24rem top margin
   and a `ul`/`ol` has no top border or padding to stop it, so the first item's
   margin escapes through the list's top edge and pushes the whole list down —
   3.84px measured — even though the list's own margin-top is already 0. Zero it
   on the first item, in exactly the two places a list can sit directly beneath
   a heading. Scoped rather than a blanket `li:first-child` so list spacing
   everywhere else (after a paragraph, inside a nested list) is untouched. */
.heading-section__body-inner > :first-child > li:first-child,
main h1 + * > li:first-child,
main h2 + * > li:first-child,
main h3 + * > li:first-child,
main h4 + * > li:first-child,
main h5 + * > li:first-child,
main h6 + * > li:first-child {
  margin-top: 0;
}

/* ==========================================================================
   09-elements.css — THE BARE TAGS A NOTE RENDERS INTO

   Paragraphs, the three-rule link order, the shared focus ring, hr, lists with
   their locked dead-centre system, blockquote, code, pre and tables — plus the
   base field and pill button that every component's armour exists to cancel.

   ORDER AND LOCKS
   The most order-locked file in the sheet.

   Keep `a` → `a:hover` → `a:visited` adjacent and in THIS order; reordering to
   LVHA changes what a visited link looks like under the pointer. Keep the list
   centring, the component reset and the .heading-section re-apply together and in
   that order — the re-apply wins only by coming last. The base `button` and
   `input` rules must precede every component partial after this one, and
   `.site-search__more` in 06-search.css depends on this file being emitted later.

   LINKS ARE NEVER UNDERLINED, in any state. Resting camouflage is enforced from
   public/link-camouflage.css — OUTSIDE this partition, and linked after every
   theme sheet. A live underline is nearly always a :hover/:focus-visible rule
   here or in a month sheet, not a failure of that file.

   RULES · style/prose.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

p { margin: 0 0 1rem; color: var(--text); }

strong, b { color: var(--tt-chartreuse); font-weight: 800; }

em, i { color: var(--tt-green); }

mark { background: transparent; color: var(--tt-gold); text-shadow: 0 0 8px rgba(227, 233, 174, 0.38); }

a {
  color: var(--text);
  text-decoration: none;
  text-underline-offset: 0.22em;
  transition: color 140ms ease, text-shadow 140ms ease;
}

a:hover, a:focus-visible {
  color: var(--link-hover);
  text-shadow: 0 0 8px rgba(var(--link-hover-glow), 0.86), 0 0 16px rgba(var(--link-hover-glow), 0.64), 0 0 28px rgba(var(--link-hover-glow), 0.34);
  animation: linkHotpinkGlow 1.05s ease-in-out infinite alternate;
}

a:visited {
  color: var(--tt-purple);
  text-shadow: 0 0 3px rgba(8, 5, 2, 0.95), 0 0 10px rgba(190, 103, 65, 0.72), 0 0 18px rgba(8, 5, 2, 0.86);
}

a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
  outline: 1px solid var(--tt-yellow);
  outline-offset: 3px;
}

img, video, svg { max-width: 100%; height: auto; }

hr { border: 0; border-top: 1px solid var(--border-dim); margin: 2rem 0; }

ul, ol { padding-left: 1.25rem; }

li { margin: 0.24rem 0; }

ol > li::marker { color: var(--tt-orange); }

/* No marker on an unordered list, in any state. The four-point navigation star
   that used to appear on hover is gone at the user's instruction (2026-08-14);
   `list-style: none` covers the native marker and there is no ::before to
   replace it. Do not reintroduce either one from a month sheet. */
ul { list-style: none; }

/* ---------------------------------------------------------------------------
   PROSE LISTS ARE DEAD CENTRE (2026-08-14, user instruction)

   Every list in rendered Markdown centres — the text and, for an ordered list,
   the numeral with it. Three declarations do the work and all three are needed:

   - `text-align: center` — the obvious one, and on its own not enough.
   - `padding-left: 0` removes the indent gutter. Centring inside a box still
     inset 1.25rem from the left centres against the wrong axis, so the text
     lands visibly right of the paragraph above it and reads as not centred.
   - `list-style-position: inside` pulls the ordered marker into the first line
     box. Left `outside`, the numeral hangs in the gutter and stays flush-left
     while only the text centres around it. The number is the thing the
     instruction is specifically about, so it has to sit inside the centred line.

   Scoped to lists with NO class, which is the discriminator that does not
   drift: the Markdown renderer emits bare <ul>/<ol>, and every embedded
   component — calendar, quiz, related block, table of contents, folder and
   sitemap listings — emits a classed one. Those are UI, not prose; they carry
   the left-bordered anchors and their own geometry, and must stay left.
   Naming component roots instead was the first attempt and was already wrong:
   the calendar's root is `.calendar-app`, not `.calendar`.
   --------------------------------------------------------------------------- */
.markdown-page ul:not([class]),
.markdown-page ol:not([class]) {
  padding-left: 0;
  padding-right: 0;
  list-style-position: inside;
  text-align: center;
}

/* Restated as the longhand, at this weight, on purpose. `ul { list-style: none }`
   above already covers it — but that is a (0,0,1) shorthand, and now that markers
   sit `inside`, anything that revives one puts a disc in the middle of the text
   instead of out in a gutter where it used to be easy to miss. */
.markdown-page ul:not([class]) {
  list-style-type: none;
}

/* No `li` rule here on purpose. `text-align` inherits, so an item already takes
   whichever alignment its own list settled on — and a separate `li` selector
   outranks the reset below, which would centre the items of a bare list nested
   inside a component even while the list itself stayed left.

   A component may hold a bare nested list of its own. `text-align` and
   `list-style-position` both inherit, so that list has to be handed back its
   left alignment explicitly — matching on "no class" alone would catch it. */
.markdown-page [class] ul:not([class]),
.markdown-page [class] ol:not([class]) {
  text-align: left;
  list-style-position: outside;
}

/* …except a heading section, which is the wrapper every rendered Markdown
   heading gets — so on any page with a heading, EVERY prose list is inside one
   and the reset above would otherwise undo the whole thing. This rule ties the
   reset on specificity (0,3,1 each) and wins on source order, so it must stay
   below it. Nesting depth does not matter; a descendant combinator matches at
   any depth. `padding-left` is not restated because the reset never touched it. */
.markdown-page .heading-section ul:not([class]),
.markdown-page .heading-section ol:not([class]) {
  text-align: center;
  list-style-position: inside;
}

/* ---------------------------------------------------------------------------
   AIRY UNORDERED ENTRIES (2026-08-16, user instruction)

   Markerless prose bullets ran together at the 0.24rem `li` margin — with no
   glyph to mark where one entry ends, the only separator left is the gap, so
   it has to be big enough to read as one. 1.1rem is close to paragraph
   spacing: each entry lands as its own block.

   Adjacent-sibling margins collapse, so the 1.1rem bottom absorbs the next
   item's 0.24rem top and the gap is 1.1rem, not 1.34rem. The last item's
   margin collapses through the list's bottom edge into the `ul`'s own 1em
   margin, so the run-out below the list grows by 0.1rem and no more.

   Unordered only, and scoped with the same "no class" discriminator and the
   same reset/re-apply pair as the centring above — a component's own bare
   nested list is UI, keeps its tight rhythm, and a heading section is the
   wrapper every rendered heading gets, so prose inside one has to be handed
   the spacing back. Order matters: the reset and the re-apply tie on
   specificity (0,3,2) and the later rule wins.
   --------------------------------------------------------------------------- */
.markdown-page ul:not([class]) > li {
  margin-bottom: 1.1rem;
}

.markdown-page [class] ul:not([class]) > li {
  margin-bottom: 0.24rem;
}

.markdown-page .heading-section ul:not([class]) > li {
  margin-bottom: 1.1rem;
}

/* Quotes are marked by colour and the soft inset glow, not a left rule —
   left borders belong to list anchors only. */
blockquote {
  margin: 1.25rem 0;
  padding: 0.7rem 1rem;
  color: var(--tt-green);
  box-shadow: inset 1rem 0 1.4rem -1.2rem rgba(163, 255, 140, 0.45);
}

code, pre, kbd { font-family: var(--font-mono); }

code {
  border: 1px solid rgba(176, 214, 143, 0.2);
  border-radius: 0.28rem;
  padding: 0.12rem 0.36rem;
  color: #f2e6d2;
  background: rgba(49, 36, 32, 0.5);
  font-size: 0.9em;
}

pre {
  overflow-x: auto;
  margin: 1.25rem 0;
  padding: 1rem;
  border: 1px solid var(--border-dim);
  border-radius: 0.45rem;
  background: rgba(8, 5, 2, 0.48);
}

pre code { display: block; padding: 0; border: 0; background: transparent; }

table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1.25rem 0; }

th, td { padding: 0.52rem 0.75rem; border-bottom: 1px solid var(--border-dim); text-align: left; }

th { color: var(--tt-bg); background: var(--tt-chartreuse); font-weight: 900; }

td { color: var(--text-soft); }

label { color: var(--text-soft); font-family: var(--font-mono); font-size: 0.86rem; }

input, textarea, select, button { font: inherit; }

input, textarea, select {
  width: min(100%, 30rem);
  margin-top: 0.35rem;
  padding: 0.62rem 0.7rem;
  border: 1px solid var(--border-dim);
  border-radius: 0.4rem;
  background: rgba(8, 5, 2, 0.5);
  color: var(--text);
}

button, .sitemap-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border-dim);
  border-radius: 999px;
  background: transparent;
  color: var(--tt-cyan);
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}

button { padding: 0.62rem 1rem; }

button:hover, .sitemap-link:hover {
  border-color: var(--tt-cyan);
  color: var(--tt-cyan);
  box-shadow: 0 0 18px rgba(176, 214, 143, 0.22);
  transform: translateY(-1px);
}

/* ==========================================================================
   10-breadcrumb.css — THE BREADCRUMB BAR AS A TAROT CARD

   The time bar, then the card itself: the frameless container, the corner pips and
   compass sweeps, the face column and --seal-size, the zodiac seal, the terminal
   sweep, the crumb trail and separators, and the reset loop button.

   ORDER AND LOCKS
   Do not cut inside the opening banner comment. Do not separate the two
   `.breadcrumb-sep { color: … !important }` rules — they are identical
   specificity and only source order makes the separators gold. The duplicated
   background-image is a colour-mix fallback and must not be deduplicated.
   Depends on the base button rules in 09-elements.css preceding it.

   The TAG CHIP ROW seats on the foot of this card but is pinned by source order
   into 18-note-foot.css.

   RULES · style/breadcrumb.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

.time-bar {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 0.75rem;
  padding: 0.5rem 1rem;
  border: 1px solid var(--tt-teal);
  border-radius: 0.4rem;
  color: var(--tt-breadcrumb-text);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-shadow: 0 0 10px var(--tt-breadcrumb-glow);
}

/* ---------------------------------------------------------------------------
   The breadcrumb bar as a tarot card.

   Tarot grammar mapped onto the parts the bar already had, so nothing new is
   painted and no second background appears (the bar is the one standing
   exception to the single-shared-background rule):

     double frame  the nav's own border, plus an inset keyline drawn on ::after
                   with a stud at each of its corners
     card numeral  the Roman month-day and hour-minute, set at the head of the
                   card between two flanking rules, the way a trump carries its
                   number
     illustration  the astrolabe dial and the lunar/zodiac chips, the card's
                   central image
     title banner  the breadcrumb trail, ruled off along the card's foot where a
                   tarot card carries its name
     instruments   the gear and the day/night switch, each let into a socket in
                   the right margin — fittings in the card, not controls on it

   The card is meant to look STRUCK, not decorated, and the difference is
   almost entirely in the rules and the type. Every rule that is not a frame
   tapers to nothing at its ends, so it reads as a stroke that ran out of
   pressure rather than a divider that stops somewhere for no reason; and the
   two pieces of type that name the card — the numeral at the head, the
   separators between the names at the foot — carry --tt-engrave, one offset
   shadow that reads as the lit lip of a cut. What glows is the lettering. What
   is cut stays cut.

   Every colour resolves from month tokens — --tt-red anchors the frame and the
   numeral — so each theme redraws the card in its own ink instead of a
   hardcoded palette. Nothing here animates: ::before keeps the existing
   terminal sweep and this adds no new motion, so the first frame is complete.
   --------------------------------------------------------------------------- */
.breadcrumb-nav {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  justify-content: center;
  gap: 0.9rem;
  width: 100%;
  /* A card is an object, not a full-bleed strip: cap the width and centre it. */
  max-width: 64rem;
  margin-inline: auto;
  /* Padding leaves the gutter the corner pips sit in. */
  padding: 0.95rem 1.15rem;
  /* NO FRAME. THE CARD IS STRUCK, NOT BOXED — DO NOT PUT ONE BACK.
     This carried a 2px keyline, a 1px inset ring, and a second ruled frame on
     ::after. Three nested rectangles around an astrolabe read as a widget in a
     panel; the whole point of the card is that it reads as an object with
     things cut INTO it. What says "card" now is the corner pips, the tapered
     rules, and the engraving on the type — all of which are marks on a face,
     not edges around one. `border: 0` here is load bearing, and so is keeping
     .breadcrumb-nav off the rainbow border-image list further down: that list
     paints a gradient border on everything in it, and a border-image needs a
     border width to show, so re-adding either one alone puts the box back. */
  border: 0;
  border-radius: 0.9rem;
  background: rgba(8, 5, 2, 0.76);
  /* The bloom stays — it is light coming off the card, with no offset and no
     spread, so it never tightens into an edge the way a hairline does. */
  box-shadow: 0 0 18px rgba(176, 214, 143, 0.12);
  overflow: hidden;
  position: relative;
  isolation: isolate;
}

/* The corner marks. What is left of the second frame once the frame is gone:
   four struck points, painted once and never animated. A tarot card marks its
   corners; it does not need a rule joining them, and the four marks alone hold
   the rectangle in the eye better than a drawn edge did.

   Each point now carries a quarter arc swung around it — the compass mark an
   engraver leaves where the dividers stood. It is deliberately an ARC and not
   a bracket: two short straight strokes at a corner are a fragment of a frame
   and the eye finishes the rectangle for you, which is the one thing this card
   must never do. A curve cannot be finished into a box. The arcs sit at r=10px
   on a card a thousand pixels wide, so what reads is four struck corners, not
   a rounded edge with the sides missing.

   TWO `background-image` DECLARATIONS ON PURPOSE. The first is the pips alone
   and is the fallback; the second adds the arcs and is the only one using
   color-mix(). An engine that cannot parse color-mix() drops the whole second
   declaration and keeps exactly the corners this card had before — rather than
   dropping one layer and taking the pips down with it. `background-size` and
   `background-position` may safely stay at eight values either way: a layer
   list shorter than the value list simply ignores the excess. */
.breadcrumb-nav::after {
  content: "";
  position: absolute;
  z-index: 0;
  inset: 0.4rem;
  pointer-events: none;
  border-radius: inherit;
  /* The sweep radius, declared once. The four arcs read it instead of stating
     10px eight times, so the phone block below can shrink the mark by changing
     one number — a 10px sweep on a 340px card is proportionally three times
     the mark it is on a 1000px one, and that is the width at which four corner
     arcs start to read as a rounded frame. */
  --card-corner-r: 10px;
  /* The sweep is secondary to the point it was swung from, so it is inked well
     below it. Gold at full strength outshines the red pip on a dark card, which
     inverts the hierarchy and makes four bright curves at the corners read as
     the rounded frame this card spent three rectangles getting rid of. */
  --card-corner-ink: color-mix(in srgb, var(--tt-gold) 34%, transparent);
  background-image:
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent);
  background-image:
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent),
    radial-gradient(circle at left top, transparent 0 var(--card-corner-r), var(--card-corner-ink) var(--card-corner-r) calc(var(--card-corner-r) + 0.9px), transparent calc(var(--card-corner-r) + 0.9px)),
    radial-gradient(circle at right top, transparent 0 var(--card-corner-r), var(--card-corner-ink) var(--card-corner-r) calc(var(--card-corner-r) + 0.9px), transparent calc(var(--card-corner-r) + 0.9px)),
    radial-gradient(circle at left bottom, transparent 0 var(--card-corner-r), var(--card-corner-ink) var(--card-corner-r) calc(var(--card-corner-r) + 0.9px), transparent calc(var(--card-corner-r) + 0.9px)),
    radial-gradient(circle at right bottom, transparent 0 var(--card-corner-r), var(--card-corner-ink) var(--card-corner-r) calc(var(--card-corner-r) + 0.9px), transparent calc(var(--card-corner-r) + 0.9px));
  background-repeat: no-repeat;
  /* The arc tiles only need to be wide enough to hold the radius; the gradient,
     not the tile, decides how big the arc is. */
  background-size:
    4px 4px, 4px 4px, 4px 4px, 4px 4px,
    22px 22px, 22px 22px, 22px 22px, 22px 22px;
  /* Each arc's origin is offset to the pip's own centre — the pip tile starts
     3px in and is 4px across — so the dividers read as having stood on the
     point rather than beside it. */
  background-position:
    left 3px top 3px,
    right 3px top 3px,
    left 3px bottom 3px,
    right 3px bottom 3px,
    left 5px top 5px,
    right 5px top 5px,
    left 5px bottom 5px,
    right 5px bottom 5px;
}

/* The card face: numeral at the head, illustration in the middle, title banner
   at the foot. The two instruments are seated in the margin beside it. */
.breadcrumb-main {
  /* How big the seal is struck. Declared here rather than on the seal itself
     because two things are now cut to it — the seal, and the eye set in the
     triangle's incircle — and a second element measured in a number copied off
     the first is a second element that drifts the first time either moves. */
  --seal-size: 9.6rem;

  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  flex: 1 1 auto;
  min-width: 0;
}

/* THE CARD'S SEAL. Today's sky, struck across the face at the size a trump's
   emblem is struck — large enough that the card is legibly an occult object
   from across the room, faint enough that nothing sitting on it has to fight
   it.

   THIS USED TO BE A CHARACTER, AND THAT WAS THE BUG. It was
   `content: var(--card-sign)` on this pseudo-element, fed the western sign's
   glyph from renderCelestialRow. U+2648–U+2653 carry Emoji_Presentation=Yes,
   so Apple and Google glyph sets drew ♈–♓ as full-colour emoji: the card had
   a rounded sticker tile sitting in the middle of it, at 5.6rem, ignoring
   `color`, `opacity` and the engrave shadow — because a colour font ignores
   all three. The comment that stood here warned about being "one step away
   from a sticker on it" at all times; it had already taken the step. The rest
   of the card had been fixed (main.js appends U+FE0E to the dial's glyph ring
   and to every chip glyph); the emblem was the one place that never reached.

   So the seal is CUT now, from numbers, exactly like the astrolabe, the gear
   and the trail loop — see buildCardSigilSvg. A stroked figure in
   `currentColor` inherits the card's gold, takes the engraving weights below,
   and cannot be handed to a colour-emoji renderer because there is no font in
   it at all.

   The opacity is the whole restraint budget. It is higher than the 0.07 the
   glyph carried because line work at 0.07 is not faint, it is absent: a
   filled character and a 1px stroke do not read at the same alpha. It stays
   inked in --tt-gold, the card's engraving ink, so it reads as part of the
   plate; only the arc over today's thirty degrees takes --tt-red, the same
   way the dial gives today's sign the anchor colour. */
.breadcrumb-main > .tarot-card__sigil {
  position: absolute;
  z-index: 0;
  top: 50%;
  left: 50%;
  width: var(--seal-size);
  height: var(--seal-size);
  opacity: 0.15;
  color: var(--tt-gold);
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  transform: translate3d(-50%, -50%, 0);
}

.tarot-sigil { display: block; width: 100%; height: 100%; overflow: visible; }

/* The ecliptic band, and the field and core inside it. All unfilled: the card
   shows through the seal the way it shows through the astrolabe's rings, which
   is the difference between a figure cut into a plate and a shape laid on it. */
.tarot-sigil .sg-ring { fill: none; stroke: currentColor; stroke-width: 1; }

.tarot-sigil .sg-ring--outer { stroke-width: 1.8; }

.tarot-sigil .sg-ring--faint { stroke-width: 0.8; opacity: 0.6; }

/* The decans — 36 ten-degree marks. Every third lands on a sign border and is
   struck the full depth of the band, which is what turns a ring of ticks into
   a ring of twelve readable sectors. */
.tarot-sigil .sg-tick { stroke: currentColor; stroke-width: 0.8; opacity: 0.55; }

.tarot-sigil .sg-tick--sign { stroke-width: 1.3; opacity: 0.9; }

/* Today's thirty degrees. The only red on the seal and the only heavy stroke,
   so the one thing the eye finds in the figure is the reading it carries. */
.tarot-sigil .sg-arc { fill: none; stroke: var(--tt-red); stroke-width: 4.6; }

/* Today's element as the alchemical triangle. */
.tarot-sigil .sg-element {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linejoin: round;
}

.tarot-sigil .sg-element-bar { stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }

/* Everything on the face sits above the emblem. The card's own children are
   already lifted by .breadcrumb-nav > *, but that rule stops at the column;
   its contents need their own step or the numeral and the trail would be
   painted underneath the glyph. */
.breadcrumb-main > * { position: relative; z-index: 1; }

.breadcrumb-nav::before {
  content: "";
  position: absolute;
  z-index: 0;
  inset: 0 auto 0 -28%;
  width: 28%;
  pointer-events: none;
  background: linear-gradient(90deg, transparent, rgba(176, 214, 143, 0.22), rgba(255, 118, 117, 0.16), transparent);
  transform: skewX(-18deg) translate3d(0, 0, 0);
  animation: terminalSweep 5.5s ease-in-out infinite;
}

.breadcrumb-nav > * { position: relative; z-index: 1; }

/* Title banner along the card's foot. The rule above it is the banner edge; the
   trail inside stays ordinary clickable links, only set in the small, spaced,
   uppercase key a card name is cut in. */
.breadcrumbs {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 1.2rem;
  /* One row, always. The card's height is a property of the card, not of how
     far the reader has walked — see the cap in the bootstrap for the other
     half of this. `nowrap` is what makes it structural rather than hopeful: a
     sixth crumb could not push a second row even if one arrived. */
  flex-wrap: nowrap;
  gap: 0.08rem 0.04rem;
  margin-top: 0.1rem;
  padding-top: 0.58rem;
  color: var(--tt-breadcrumb-text);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.1em;
  text-align: center;
  text-transform: uppercase;
  text-shadow: 0 0 10px var(--tt-breadcrumb-glow);
}

/* The banner edge, and the reason it is a pseudo-element rather than the
   border-top it used to be: a border runs the full width at full weight and
   ends abruptly at both corners, which reads as a table rule. The banner on a
   struck card is a stroke — heaviest under the name, gone before it reaches
   the frame — so it tapers from both ends and never touches the keyline. It
   cannot be an ordinary child: the trail's markup is written by the bootstrap
   with innerHTML, which would wipe it. A pseudo-element survives that, and it
   is taken out of the flex flow so it does not become an eighth crumb.
   :empty is unaffected by pseudo-elements, so a page with no trail yet still
   drops the whole banner rather than leaving a floating rule.

   It is a DOUBLE rule now: a long stroke with a short one under it, the way an
   engraved plate rules off a title. The pair is what makes the foot read old —
   a single hairline is a divider, two unequal hairlines are an inscription —
   and it costs one extra gradient layer on a pseudo-element that was already
   painting. The shorter rule is pulled well inside the longer one so the two
   taper out at different points and the banner comes to a stop twice instead
   of once. */
.breadcrumbs::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  pointer-events: none;
  background-image:
    linear-gradient(90deg, transparent, var(--tt-red) 22%, var(--tt-red) 78%, transparent),
    linear-gradient(90deg, transparent 31%, var(--tt-red) 44%, var(--tt-red) 56%, transparent 69%);
  background-repeat: no-repeat;
  background-size: 100% 1px, 100% 1px;
  background-position: left top, left bottom;
  opacity: 0.42;
}

.breadcrumbs a, .breadcrumbs a:visited, .breadcrumbs a:active, .breadcrumb-sep {
  color: var(--tt-breadcrumb-text) !important;
  font-family: var(--font-mono);
  text-shadow: 0 0 10px var(--tt-breadcrumb-glow);
}

/* On a page with no trail yet there is no name to cut, so the banner and its
   rule stay off the card rather than leaving an empty ruled band. */
.breadcrumbs:empty { display: none; }

/* A name is cut to fit the plate; the plate is not cut to fit the name.
   `overflow-wrap: anywhere` used to live here, which let a single unbroken
   token — the kind a slug becomes when a title has no spaces in it — break
   mid-word down as many rows as it needed and drag the card's height with it.
   An ellipsis is the honest version of the same compromise: the reader can see
   the name has been shortened, and the row it sits in cannot move.

   `min-width: 0` is the load-bearing half. These are flex items in a nowrap
   row, and a flex item's default `min-width: auto` refuses to shrink below its
   content — so without this the ellipsis would never trigger and the row would
   simply overflow instead. With it, five crumbs share the width they have and
   each gives up as much as it must. */
.breadcrumbs a {
  flex: 0 1 auto;
  min-width: 0;
  max-width: 11rem;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* No underline on hover, standing user rule (2026-08-14): a hyperlink never
   carries one, in any state. The crumb still announces itself on hover through
   colour alone — a:hover's hotpink bloom (or a month's own override) already
   applies here, since nothing above pins colour past :active. */
.breadcrumbs a:hover { text-decoration: none; }

/* The separators must not be the thing that gets squeezed. They are one
   character wide and they are the grammar of the trail; a compressed ">" is
   unreadable in a way a shortened name is not. */
.breadcrumb-sep { flex: 0 0 auto; }

/* The separators are inscription, not text. The names on either side glow;
   the marks between them are cut into the banner instead — smaller, set back
   to the card's own gold rather than the trail's ink, and carrying the
   engraving shadow in place of the glow the rule above hands every child of
   the banner. A tarot card's title is lettered; the marks that divide it are
   struck, and the difference is what stops the foot of the card reading as one
   undifferentiated run of pink. */
.breadcrumb-sep {
  margin: 0 0.42rem;
  color: var(--tt-gold) !important;
  font-size: 0.86em;
  opacity: 0.68;
  text-shadow: var(--tt-engrave);
}

/* The mark that stands where the trimmed steps were. Same ink as the other
   separators because it is one — it divides the walked path from the part of
   it still in storage — but it leads the banner instead of dividing it, so it
   loses the leading margin. */
.breadcrumb-sep--elided { margin-left: 0; }

/* ---- The loop that clears the trail ---------------------------------------
   Struck at the end of the banner, after the last name. It wipes the trail and
   takes the banner with it — the row goes empty, :empty above hides it, and the
   card loses its whole foot rather than keeping a ruled band with nothing
   lettered on it.

   Tripled to (0,3,0) for the reason the instruments are: this is a <button>
   inside the card, and any month that writes a generic
   body[data-theme-id="…"] button rule reaches it. Two used to be enough; the
   day/night attribute put a second attribute in every one of those selectors
   and took them to (0,2,2), which beats a doubled class. The arithmetic, and
   the rule for when this count has to go to four, is written out once on the
   chips' ANCHOR PROOFING block. It also declines the site's rainbow
   border-image outright — the loop has no frame, it is engraving, and a
   border-image would draw a rectangle round a round mark and ignore the
   radius while doing it.

   It is inked in the card's gold like the separators rather than the trail's
   pink: the names in the banner are lettered and glow, everything struck
   between and after them is cut into the metal instead. */
.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.02rem;
  height: 1.02rem;
  margin-left: 0.5rem;
  padding: 0;
  border: 0;
  border-image: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tt-gold);
  opacity: 0.66;
  cursor: pointer;
  /* Short, and colour/opacity/transform only — inside the interaction band. */
  transition: color 160ms ease, opacity 160ms ease;
}

.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset:hover,
.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset:focus-visible {
  color: var(--tt-breadcrumb-text);
  opacity: 1;
}

/* A round outline on a round mark reads as the loop itself catching the light,
   which is the same argument that lets the two instruments take a ring while
   the zodiac chips may not. */
.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 2px;
}

.breadcrumb-reset__svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  transition: transform 160ms ease;
}

/* The arc is stroked and the head is filled, so the mark reads as one drawn
   line that thickens into a point rather than as two shapes. */
.breadcrumb-reset .br-loop {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.1;
  stroke-linecap: round;
}

.breadcrumb-reset .br-head {
  fill: currentColor;
  stroke: none;
}

/* The loop turns a little under the pointer — the same idea as the gear
   catching a tooth, and on a loop of arrow the turn is the whole promise. */
.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset:hover .breadcrumb-reset__svg,
.breadcrumb-reset.breadcrumb-reset.breadcrumb-reset:focus-visible .breadcrumb-reset__svg {
  transform: rotate(-34deg);
}

/* ==========================================================================
   11-instruments.css — THE RIGHT-HAND RAIL AND THE CONTROLS IN IT

   The rail and the shared frameless .instrument base, then the gear, the keycap,
   the language globe, and the tarot eye that opens the colour-vision simulation.

   ORDER AND LOCKS
   The .instrument base must precede every instrument in this file AND the flyway
   lamp and day/night switch later in the sheet. Keep the gear's :visited before
   its :hover, and keep `.tarot-eye.is-filtering .te-eye { animation: none }`
   after the is-awake blink — both are equal-specificity ties.

   The globe belongs to the language layer and the eye to colour vision; source
   order keeps them here. The flyway lamp is the same rail and is in
   13-flyway-lamp.css, separated only by source order.

   RULES · style/instruments.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   The instruments seated in the card's right margin.

   What stood here was a rail of thirteen theme swatches — a control panel
   bolted to the side of a tarot card, taking a second column every time a
   theme was added, and stating the whole palette on a face that is supposed to
   state one reading. The palette moved to /settings. Two instruments stay: the
   gear that opens the workshop, and the day/night switch.

   They are set INTO the face, not laid on it — and with no frame at all. The
   sockets used to be a hairline plus an inner shadow, which was two more
   rectangles on a card that has just had three taken off it. What is left is
   the inner shadow alone: a recess is a shadow with no edge, which is exactly
   how a recess in real metal reads, and it costs the card nothing. The gear
   floats free of any box; only the switch keeps a groove, because a track is
   what makes a switch a switch. Nothing here animates; both instruments are
   complete on the first frame and only move under the pointer.
   --------------------------------------------------------------------------- */
.instrument-rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  flex: 0 0 auto;
  align-self: center;
  padding-left: 0.2rem;
}

/* The socket both instruments are let into.

   Tripled to (0,3,0) throughout, for the reason the zodiac chips are: the rail
   is MIXED. The gear is an <a href="/settings"> and the switch is a <button>,
   and every generic rule a month writes for either reaches them — october's
   body[data-theme-id="october"] button, the seven sheets that flatten
   body[data-theme-id="…"] a with -webkit-text-fill-color. With the day/night
   attribute in play those are (0,2,2), which beats a doubled class outright.
   Three clears it; the full arithmetic, and the rule for when it has to go to
   four, is written out once on the chips' ANCHOR PROOFING block below.
   Element-qualified selectors are the wrong fix here for the same reason they
   are wrong there: they would have to be written twice, once per element, and
   would rot the next time one of these two changes element. */
.instrument.instrument.instrument {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  /* No frame, on either instrument. The site paints every structural border
     with the header strip's rainbow as a border-image and `button` is on that
     list, so this has to say so explicitly rather than just declining to set
     one: a border-image needs a width, and `border: 0` removes both at once. */
  border: 0;
  border-image: none;
  background: transparent;
  color: var(--tt-gold);
  box-shadow: none;
  cursor: pointer;
  /* Short, transform/opacity only, and inside the 120–240ms interaction band. */
  transition: color 160ms ease, transform 160ms ease;
}

.instrument.instrument.instrument:hover { color: var(--tt-breadcrumb-text); }

.instrument.instrument.instrument:hover,
.instrument.instrument.instrument:focus-visible {
  /* main.css hangs a hardcoded July-cyan bloom and a 1px lift on
     `button:hover, .sitemap-link:hover`, and the switch is a <button>. Neither
     belongs on a fitting seated in the card: the lift would pop it out of the
     face, and the cyan is a July colour on every theme. Both are cancelled
     outright, so the whole hover affordance is the ink coming up. */
  box-shadow: none;
  transform: none;
}

/* Keyboard focus. With the sockets gone there is no edge left to light up, so
   this is the one ring in the card — and it is still not a frame at rest, only
   while a keyboard is on the control. The zodiac chips remain forbidden one
   because their affordance is a halo drawn on the glyph; these two have no
   glyph to halo (a stroked gear and a knob on a track), so an outline is the
   only visible focus state available and WCAG 2.4.7 requires one. */
.instrument.instrument.instrument:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 3px;
  border-radius: 0.35rem;
}

.instrument__svg,
.instrument-mark__svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* ---- The gear ------------------------------------------------------------
   Engraved, not filled: the wheel is a stroked outline and the bore is a
   second stroked circle, so the card shows through the tool the way it shows
   through the astrolabe's rings. It turns a sixth of a tooth on hover — one
   step of the eight-tooth pitch, enough to read as a mechanism catching and
   far too little to read as a spinner. */
.instrument-gear {
  width: 1.85rem;
  height: 1.85rem;
  border-radius: 50%;
  padding: 0.3rem;
}

.instrument-gear .ig-wheel,
.instrument-gear .ig-bore {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 200ms ease;
}

.instrument-gear .ig-bore { stroke-width: 1.2; opacity: 0.72; }

.instrument-gear:hover .ig-wheel,
.instrument-gear:focus-visible .ig-wheel { transform: rotate(7.5deg); }

/* Anchor proofing. The gear is an <a href="/settings">, so all four of the
   leaks documented on the chips reach it too — :visited recolouring it once
   the page has been read, the hotpink hover text-shadow blooming behind it,
   the looping linkHotpinkGlow it is not allowed to run, and
   -webkit-text-fill-color, which inherits and would flatten the SVG's
   currentColor stroke on seven sheets. A link is also selectable and draggable
   where a button is neither, so a drag across the rail would peel it off. */
.instrument-gear.instrument-gear.instrument-gear {
  text-decoration: none;
  -webkit-text-fill-color: currentColor;
  text-shadow: none;
  animation: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
}

.instrument-gear.instrument-gear.instrument-gear:visited { color: var(--tt-gold); }

.instrument-gear.instrument-gear.instrument-gear:hover,
.instrument-gear.instrument-gear.instrument-gear:focus-visible { color: var(--tt-breadcrumb-text); }

/* ---- The keycap ----------------------------------------------------------
   Stroked like the gear beside it, so the two read as one set of tools. The
   mark inside is real type rather than a traced path — see buildKeyCapSvg in
   main.js for why — which means it needs the family and the fill said out
   loud: an <svg> <text> inherits neither from the button around it. */
.instrument-keys {
  width: 1.85rem;
  height: 1.85rem;
  border-radius: 50%;
  padding: 0.3rem;
}

.instrument-keys .ik-cap {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
}

.instrument-keys .ik-cap-mark {
  fill: currentColor;
  stroke: none;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
}

/* ---- The globe -----------------------------------------------------------
   Stroked like the gear and the keycap, and the same 1.85rem seat, so the four
   fittings on the rail read as one set of tools rather than three tools and an
   icon. Nothing here animates: the sphere is complete on the first frame and
   only the ink moves, under the pointer, exactly as the gear's does.

   The meridian is a true ellipse rather than a traced path so it stays a
   sphere at any size, and the parallels stop short of the rim on both sides —
   a line running edge to edge reads as a strikethrough, not as latitude. */
.instrument-language {
  width: 1.85rem;
  height: 1.85rem;
  border-radius: 50%;
  padding: 0.3rem;
}

.instrument-language .il-globe {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The meridian and the parallels are the interior of the sphere, and drawn at
   the rim's weight they close it into a solid. Lighter, they read as the far
   side of a globe seen through it. */
.instrument-language .il-globe + .il-globe { stroke-width: 1.15; opacity: 0.72; }

/* The globe wears the reading it is set to. A rail of five fittings all in
   gold gives a reader no way to see, without opening it, that this page is
   being shown to them in a language somebody chose — so the one instrument
   that is currently DOING something is marked while it does it.

   It is marked TWICE, and the second one is not redundancy. The lit colour is
   the site's usual "this is active" signal, but several sheets — the default
   among them — resolve --tt-gold and --tt-breadcrumb-text to the same ink, and
   on those the colour alone says nothing at all. So the sphere also fills in:
   the meridian and parallels come up to the rim's weight, which turns an
   outline drawing into a solid one. That reads at 24px on any palette, and it
   survives a reader who cannot separate the two colours in the first place. */
.instrument-language[data-language-code]:not([data-language-code="en"]) {
  color: var(--tt-breadcrumb-text);
}

.instrument-language[data-language-code]:not([data-language-code="en"]) .il-globe + .il-globe {
  stroke-width: 1.5;
  opacity: 1;
}

/* ---- The eye in the pyramid ----------------------------------------------

   THE SEAL'S OWN TRIANGLE, NOT A SIXTH FITTING ON THE RAIL. The first cut of
   this put a small pyramid among the gear and the globe, four inches from the
   large one already struck across the card, and left the reader to work out
   which of the two was meant. The seal is where the pyramid is, so the seal is
   where the eye goes.

   It is drawn OUTSIDE .tarot-card__sigil and sat on the same centre, which is
   the whole reason this is a separate element instead of three more paths in
   buildCardSigilSvg. The seal carries opacity 0.15 — its entire restraint
   budget — and opacity is a group operation: an eye cut into that <svg> could
   never be painted at more than fifteen percent of anything, and fifteen
   percent of neon is not neon. As a sibling it holds its own alpha, and it
   spends the sleeping state matching the seal's, so it reads as the same
   engraving. Waking it is the alpha coming up: the eye rises out of the plate
   rather than switching on over it.

   Sized off --seal-size, at the ratio the core circle takes in the seal's
   200-unit box (48/200 = 0.24). That circle is the triangle's incircle by
   construction — see SIGIL_R_CORE in main.js — so the eye lands inside the
   triangle for every element, upright or reversed, without either of them
   knowing about the other.

   z-index 0 puts it back in the ground with the seal, under the whole card
   face. .breadcrumb-main > * lifts every child to 1; this one and the seal are
   the two exceptions, and they are exceptions for the same reason. */
.breadcrumb-main > .tarot-eye {
  position: absolute;
  z-index: 0;
  top: 50%;
  left: 50%;
  width: calc(var(--seal-size) * 0.24);
  height: calc(var(--seal-size) * 0.24);
  padding: 0;
  border: 0;
  border-image: none;
  border-radius: 50%;
  background: transparent;
  box-shadow: none;
  opacity: 0.15;
  color: var(--tt-gold);
  /* The mouse is served from the card, not from here — see THE EYE IN THE
     PYRAMID in main.js. The zodiac chip row is stacked directly over this spot
     and the Leo chip is on top of the eye today; a control here that accepted
     pointer events would quietly eat clicks belonging to a link that has been
     on the card far longer. What this element is for is the keyboard, and
     pointer-events costs the keyboard nothing. */
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  transform: translate3d(-50%, -50%, 0);
  transition: opacity 420ms ease;
}

.tarot-eye__svg { display: block; width: 100%; height: 100%; overflow: visible; }

.tarot-eye .te-lens {
  fill: none;
  stroke: currentColor;
  /* Stroke widths are in the eye's own 48-unit box, which is a quarter of the
     seal's — so 1.6 here lands at the same weight on the page as the seal's
     1.4 does. They are meant to match: asleep, this is seal. */
  stroke-width: 1.6;
  stroke-linejoin: round;
}

.tarot-eye .te-pupil { fill: currentColor; stroke: none; }

/* The blink collapses the eye group and nothing else. fill-box rather than
   view-box: the transform has to pivot on the lens's own middle, and pivoting
   on the whole box would slide the eye shut from above, which is a shutter, not
   an eyelid. */
.tarot-eye .te-eye {
  transform-box: fill-box;
  transform-origin: center;
}

/* THE NEON. Written as a literal, not a token, and it is the only literal
   colour left in the card's furniture.

   Every other mark here is currentColor so that thirteen month sheets can ink
   it thirteen ways, and that is right for a tool. This is not a tool until it
   has been found, and the finding is the point: a reader has to be able to tell,
   at a glance, that the mark they have just put a pointer on has become
   something else. A palette colour cannot promise that — half the sheets would
   light the eye in an ink already on the card two inches away, and the reveal
   would land as a shrug.

   It is also the one place the site declines to be themed on purpose. Whatever
   month is in the wheel, the eye opens the same colour, because what it opens is
   not part of the month.

   The alpha is the louder half of the change and the one that survives every
   filter in the panel behind it, including the two that throw colour away
   entirely: 0.15 to 1 is the eye coming up out of the plate, and it reads with
   the hue thrown in the bin. That matters more here than anywhere else on the
   site — this is the one control guaranteed to be used by somebody who cannot
   rely on the colour. */
.breadcrumb-main > .tarot-eye.is-awake {
  --eye-neon: #f05cff;
  --eye-neon-glow: rgba(240, 92, 255, 0.9);
  opacity: 1;
}

.tarot-eye.is-awake .te-lens { stroke: var(--eye-neon); stroke-width: 2; }

.tarot-eye.is-awake .te-pupil { fill: var(--eye-neon); }

/* The bloom is hung on the eye group rather than the <svg>, so a lid coming
   down takes the light with it instead of leaving a halo in the air. 1.5 is in
   the eye's own user units, not screen pixels — a filter length on an SVG child
   is measured in the local coordinate system, so the bloom scales with the mark
   instead of swamping it on a small card and vanishing on a large one. */
.tarot-eye.is-awake .te-eye {
  filter: drop-shadow(0 0 1.5px var(--eye-neon-glow));
  animation: eyeBlink 3.6s ease-in-out infinite;
}

/* A simulation is running. The eye stops blinking and stays open — a blink is
   an invitation and the invitation has been taken, and something left switched
   ON should not keep waving. It keeps the neon and the bloom, so the card still
   says, from across the room, that these are not the site's own colours. */
.tarot-eye.is-filtering .te-eye { animation: none; }

/* The one ring on the card, and only while a keyboard is on the control. The
   eye has no frame to light and no glyph to halo, and WCAG 2.4.7 requires
   something. It is drawn on the neon rather than the month's gold because by
   the time a keyboard has reached this control the focus has already woken it. */
.breadcrumb-main > .tarot-eye:focus-visible {
  outline: 2px solid var(--eye-neon, var(--tt-gold));
  outline-offset: 4px;
}

/* One blink, not a pulse: nine tenths of the cycle is an open eye. The lid
   takes about 180ms to fall and the same to lift, which is roughly what a real
   one takes — a slower blink reads as a wink and a faster one as a glitch. */
@keyframes eyeBlink {
  0%, 82%, 100% { transform: scaleY(1); }
  87% { transform: scaleY(0.05); }
  92% { transform: scaleY(1); }
}

/* The blanket rule at the top of the sheet already stops this — one iteration
   at 0.01ms, which lands on an open eye — but it stops it by accident, and an
   accident is a poor thing to rest an announcement on. Said outright here.

   Nothing is lost by it. The waking was never carried by the blink: the alpha
   coming up, the neon and the bloom are all final-value styling and all survive,
   so a reader who asked for less motion is still told, three ways, that the eye
   has opened. */
@media (prefers-reduced-motion: reduce) {
  .tarot-eye .te-eye { animation: none !important; }
  .breadcrumb-main > .tarot-eye { transition: none; }
}
/* ==========================================================================
   12-language.css — THE LANGUAGE POPOVER, THE MT NOTICE, AND RTL

   The language popover with its doubled-class rows, the machine-translation
   notice, and the right-to-left corrections plus the Arabic and CJK mono stacks.

   ORDER AND LOCKS
   Contains a stray four-line :root rule declaring --font-mono-arabic and
   --font-mono-cjk. Moving it to 00-tokens.css is behaviourally safe but breaks
   byte-identical concatenation, so it stays. It is followed immediately by two
   LIVE component rules ([lang="ar"] and [lang="zh"]) — do not sweep those along
   with it.

   The RTL rules beat bare blockquote/pre/code from 09-elements.css on
   specificity, so order is not load-bearing here, but this file must never be
   emitted before the base elements.

   RULES · style/language.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */


/* ---------------------------------------------------------------------------
   The language menu.

   Seated in <body> rather than in the card — see ensureLanguageMenu in main.js
   for why a popover cannot live inside <header> — and positioned against the
   globe's rectangle, so it is written here as a free-standing panel that knows
   nothing about the rail it hangs from.

   Every name is set in its own language and its own direction. Español,
   Deutsch, 中文, العربية: a reader who cannot read this page can still find
   the line that is theirs, which is the entire job of this menu and the reason
   it is never itself translated.
   --------------------------------------------------------------------------- */
.language-menu {
  position: absolute;
  z-index: 90;
  display: flex;
  flex-direction: column;
  min-width: 12.5rem;
  padding: 0.3rem;
  /* Boxed on all four sides, like the keyboard panel and the tooltip. */
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--bg);
  box-shadow: 0 0 26px rgba(0, 0, 0, 0.55);
}

.language-menu[hidden] { display: none; }

/* Doubled, and with the fill said out loud. These are <button>s standing
   outside the card, where several month sheets write a bare `button` rule at
   (0,1,1) and seven flatten link text with -webkit-text-fill-color that a
   nested element would inherit. */
.language-menu .language-menu__item {
  display: flex;
  flex-direction: column;
  /* Said out loud because the browser's own button rule sets `center`, and in
     a column flex the cross axis is the horizontal one — so the default would
     centre both names inside the row and leave the list with no left edge to
     read down. */
  align-items: stretch;
  gap: 0.1rem;
  padding: 0.42rem 0.6rem;
  border: 0;
  border-image: none;
  border-radius: 0;
  background: transparent;
  color: var(--tt-gold);
  -webkit-text-fill-color: currentColor;
  box-shadow: none;
  text-align: start;
  cursor: pointer;
  transition: color 160ms ease, background-color 160ms ease;
}

.language-menu .language-menu__item:hover,
.language-menu .language-menu__item:focus-visible {
  color: var(--tt-breadcrumb-text);
  background: var(--surface);
  box-shadow: none;
  transform: none;
}

.language-menu .language-menu__item:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: -2px;
}

/* The chosen line is marked by ink and a bullet in the margin, not by a filled
   row: a highlighted band would read as hover, which is the one thing the
   current language must not be confusable with. */
.language-menu .language-menu__item.is-current { color: var(--tt-breadcrumb-text); }

.language-menu .language-menu__item.is-current .language-menu__endonym::after {
  content: " ·";
  opacity: 0.8;
}

.language-menu__endonym {
  font-family: var(--font-mono);
  font-size: 0.92rem;
  line-height: 1.35;
}

/* Arabic keeps its direction — that is what shapes and orders the letters —
   but it does not keep the far edge. The five names hang off one edge because
   a list of five with a single entry pushed across reads as a layout fault
   rather than as a language, and this menu's whole job is being scannable by
   someone who cannot read the rest of the page. */
.language-menu .language-menu__item[dir="rtl"] .language-menu__endonym {
  text-align: left;
}

/* The English name under each. It is the label for somebody who is choosing a
   language FOR someone else — a librarian, a clerk, a parent — and it is set
   quiet because it is never the line the reader themselves is looking for. */
.language-menu__english {
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

/* ---------------------------------------------------------------------------
   The machine-translation banner.

   Leading <main>, in the reader's own language, saying plainly that a machine
   wrote what follows and that the English is one click away. It is not a
   decoration and not a dismissible toast: a reader deciding whether to trust a
   sentence about a statute, a date or a dose is entitled to know that no human
   checked the wording, for as long as the translation is on the page.
   --------------------------------------------------------------------------- */
.mt-notice {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem 0.85rem;
  margin: 0 0 1.6rem;
  padding: 0.55rem 0.8rem;
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: transparent;
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
}

.mt-notice__status:empty { display: none; }

.mt-notice.is-working .mt-notice__status { color: var(--tt-gold); }
.mt-notice.is-failed .mt-notice__status { color: var(--tt-red); }

.mt-notice .mt-notice__back {
  margin-inline-start: auto;
  padding: 0.1rem 0.4rem;
  border: 0;
  border-image: none;
  background: transparent;
  color: var(--tt-gold);
  -webkit-text-fill-color: currentColor;
  box-shadow: none;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-decoration: underline;
  text-underline-offset: 0.22em;
  cursor: pointer;
}

.mt-notice .mt-notice__back:hover,
.mt-notice .mt-notice__back:focus-visible {
  color: var(--tt-breadcrumb-text);
  box-shadow: none;
  transform: none;
}

/* ---------------------------------------------------------------------------
   Right to left.

   Arabic is the fifth of Ohio's five, and it turns the page around. The
   document's dir attribute does most of this on its own; what is left is the
   handful of rules in this sheet that name a physical side. Each is scoped
   under [dir="rtl"] so the left-to-right page cannot be reached by any of it.
   --------------------------------------------------------------------------- */
[dir="rtl"] :is(ul, ol) {
  padding-left: 0;
  padding-right: 1.25rem;
}

/* The quote's glow comes in from the leading edge, which has just moved. */
[dir="rtl"] blockquote {
  box-shadow: inset -1rem 0 1.4rem -1.2rem rgba(163, 255, 140, 0.45);
}

/* Code is quoted verbatim from a left-to-right world and stays in it, whatever
   the prose around it is doing. */
[dir="rtl"] :is(pre, code, kbd, samp) {
  direction: ltr;
  text-align: left;
}

/* The Roman numeral is a date and a time in that order, and it is the one
   string on the card whose ORDER is its meaning. Mirroring the card is right —
   an Arabic reader should meet the astrolabe where their eye lands first — but
   mirroring VIII·XIV XVIII:IV into XVIII:IV VIII·XIV moves the clock in front
   of the calendar and says something else entirely. */
[dir="rtl"] .tarot-card__numeral { direction: ltr; }

/* The site letters its interface in JetBrains Mono, which covers Latin and
   nothing else. Left to the fallback chain, Arabic in the menu, the banner and
   the search field lands on whatever monospace face the system offers, and a
   monospace face that has never seen Arabic draws the letters as separate
   stamps rather than joining them — the script's equivalent of s p a c i n g
   out every word. These stacks name faces that shape properly and are already
   on the machine; the Latin faces stay first, so a line of mixed text still
   sets its Latin in the site's own face.

   The Latin half is written out here rather than pulled in as var(--font-mono),
   and that is not duplication for its own sake: --font-mono ENDS in the generic
   keyword `monospace`, a family that always matches, and a browser walking the
   stack stops there. Anything named after it — every Arabic and CJK face in
   these two lines — would never be reached. The generic goes last or it goes
   nowhere. If the mono stack up in :root gains a face, add it here too. */
:root {
  --font-mono-arabic: "JetBrains Mono", Menlo, SFMono-Regular, Consolas, "Fira Code", "SF Arabic", "Geeza Pro", "Noto Naskh Arabic", "Al Bayan", monospace;
  --font-mono-cjk: "JetBrains Mono", Menlo, SFMono-Regular, Consolas, "Fira Code", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", monospace;
}

.language-menu .language-menu__item[lang="ar"] { font-family: var(--font-mono-arabic); }
.language-menu .language-menu__item[lang="zh"] { font-family: var(--font-mono-cjk); }

body[data-language="ar"] :is(.mt-notice, .site-search__input, .breadcrumbs) {
  font-family: var(--font-mono-arabic);
}

body[data-language="zh"] :is(.mt-notice, .site-search__input, .breadcrumbs) {
  font-family: var(--font-mono-cjk);
}

/* Arabic script hangs below and rises above the Latin band, and the site's
   line-height is set for Latin. Without this the descenders of one line touch
   the ascenders of the next. */
body[data-language="ar"] main { line-height: 1.85; }

/* ==========================================================================
   13-flyway-lamp.css — THE BIRD LAMP AND THE PASSAGE STRIP

   The bird instrument that opens the map and doubles as a season gauge, and the
   twenty-gauge passage strip beneath the chips with its links and taxon marks.

   ORDER AND LOCKS
   Depends on the .instrument base in 11-instruments.css preceding it. Keep the
   40-line doctrine comment with `.passage-strip` directly beneath it — the live
   body[data-flyways="on"] rule above that comment is not part of it. Keep the
   [hidden]/:empty rules after the flex declaration, and keep `.passage-mark`
   above `.passage-mark:hover`: flat at rest, hued on hover, is the whole design.

   A `.passage*` grep also matches the unrelated migration card in 02-flyway.css.
   Do not merge them. The passage marks are NOT chips.

   RULES · style/flyway.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---- The flyway lamp -----------------------------------------------------

   A bird that opens the migration map, and reports on the passage whether or
   not anyone opens it.

   THE GLOW IS THE INSTRUMENT. The other two objects on this rail are inert
   until touched — the gear goes somewhere, the switch sets something. This one
   is a gauge: --migration-intensity is the same number the corridors are drawn
   with, and it drives the halo, so the bird burns hardest through the peak of
   each passage and banks down to an ember on the breeding and wintering
   grounds. That is the season reported in the corner of the card, at a glance,
   with the map still shut.

   Because it is a gauge it does not sit at rest-colour like its neighbours,
   and — like the map it opens — it is off the theme tokens on purpose. It
   carries the SEASON's colour, the same four values the corridors take, so the
   bird is green in April and amber in October in every month's card. A gauge
   that changes colour for reasons unrelated to what it measures is a gauge you
   have to learn thirteen times. */
.flyway-lamp.flyway-lamp.flyway-lamp {
  /* Its own box, because the base .instrument rule deliberately sets none —
     every instrument declares its own size, and .instrument__svg is width and
     height 100%, so an instrument that forgets to has an SVG resolving against
     nothing and blows up to the width of the card. Matched to the gear so the
     three sit on one optical line. */
  width: 1.85rem;
  height: 1.85rem;
  border-radius: 50%;
  color: var(--flyway-passage);
  padding: 0.3rem;
  /* Clamped low. At intensity 1 this is a lit filament, not a flare — it sits
     beside a wordmark and must never out-argue it. */
  --lamp-burn: calc(0.3 + 0.7 * var(--migration-intensity, 0.4));
}

.flyway-lamp .fl-wing,
.flyway-lamp .fl-tail {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.flyway-lamp .fl-eye {
  fill: currentColor;
  stroke: none;
}

/* The halo itself. drop-shadow rather than a box-shadow or a blurred
   pseudo-element, because it has to follow the SILHOUETTE — a rectangular
   glow behind a bird reads as a backlit stamp, and the whole point is that the
   shape of the light is the shape of the bird. Two stops: a tight one for the
   filament and a wide one for the bloom. */
.flyway-lamp .instrument__svg {
  /* The halo is the bird's own colour, so the glow says the same thing the
     silhouette does rather than borrowing the month's glow token and saying
     something else alongside it. */
  filter:
    drop-shadow(0 0 calc(1px + 2px * var(--lamp-burn)) var(--flyway-passage))
    drop-shadow(0 0 calc(3px + 7px * var(--lamp-burn)) var(--flyway-passage));
  opacity: calc(0.55 + 0.45 * var(--lamp-burn));
  transition: opacity 600ms ease, filter 600ms ease;
}

/* A slow breath on top of the steady burn, scaled by the same number — so the
   passage months visibly pulse and the quiet months hold nearly still. This is
   the one piece of motion on the rail and it is carrying information. */
.flyway-lamp .instrument__svg {
  animation: flywayLampBreath calc((9 - 4 * var(--lamp-burn)) * 1s) ease-in-out infinite;
}

@keyframes flywayLampBreath {
  0%, 100% { opacity: calc(0.55 + 0.45 * var(--lamp-burn)); }
  50% { opacity: calc(0.72 + 0.28 * var(--lamp-burn)); }
}

/* Switched on, the bird is unmistakably lit rather than merely warm — the
   reader needs to know the map is theirs to close again. */
body[data-flyways="on"] .flyway-lamp .instrument__svg {
  --lamp-burn: 1;
  opacity: 1;
}

.flyway-lamp:hover .instrument__svg,
.flyway-lamp:focus-visible .instrument__svg {
  opacity: 1;
  transform: translateY(-1px);
}

/* ---- The passage strip ---------------------------------------------------

   Twenty gauges across the card, under the zodiac chips: the nine bird guilds,
   then bats, butterflies and dragonflies, then the four flower groups, then the
   four mushroom seasons. The lamp on the rail is one number for the whole
   continent; this is that number broken into the groups a reader standing in
   Ohio can actually go and look at.

   IT IS THE SAME INSTRUMENT AS THE LAMP AND IT IS PAINTED LIKE ONE. Both take
   --flyway-passage rather than a theme token, for the reason written out at
   THE ONE PALETTE above: a gauge whose colour changes for reasons unrelated to
   what it measures is a gauge you have to learn thirteen times. So the mark
   under the pointer is green in April and amber in October in every month's
   card — exactly the swap the corridors make.

   THE ROW RESTS FLAT AND ANSWERS ONE AT A TIME. It ran as twelve lit gauges for
   a long while: --passage-burn drove each mark's opacity and the season's hue
   was on whichever ones were moving, so the strip painted its whole reading at
   once. That is a lot of signal for a row of 1.3rem drawings sitting between
   the zodiac chips and the trail, and it made the card's quietest instrument
   its loudest. Now every mark rests in the same --flyway-mark grey at the same
   strength, and hover or focus lights exactly one of them in the season's hue
   with its sentence in the tooltip. --passage-burn is still written to every
   mark by the tick and still drives that sentence; it no longer paints.

   NOTHING VARIES BUT THE POINTER. Twelve marks, always all twelve, always in
   the same order. Nothing here may make a mark move, resize, or leave: the row
   is read the way a row of pilot lights is read, and any of those three turns
   it into something that has to be counted instead.

   NO MOTION. The lamp breathes and says so in its own block — it is the one
   piece of movement on the card and it is carrying information. Nineteen more
   breathing objects would not carry nineteen more facts, they would carry the
   same fact badly, next to running text. Do not add an animation here.

   NO FRAMES EITHER, for the reason the chips carry none: little rectangles in
   the card face are exactly what this design does not want. The separation
   between the birds, the other animals, the flowers and the mushrooms is a
   wider gap and nothing else — a rule drawn there would be a border by another
   name. */
.passage-strip {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: nowrap;
  gap: 0.34rem;
  width: 100%;
  min-width: 0;
  max-width: 100%;
  padding: 0.1rem 0.15rem;
  overflow-x: auto;
  scrollbar-width: thin;
}

/* THE SEASON ROSTER, AT THE HEAD OF THE CARD.

   It wraps where the passage strip scrolls, and the difference is the roster.
   The passage strip is a fixed rank of eighteen gauges: one line at any sane
   width, and a scroller is the right fallback for the narrow case because the
   row's shape is meant to be constant.

   This row is a list whose length is the season. Nothing at all on 25 May, at
   which point it hides itself; five in the middle of August; 32 on 26
   December, when beaver and otter open on top of everything already running.
   `nowrap` would hand almost everybody a scroller through the winter, and you
   cannot glance at something you have to drag. Wrapped, a long roster is still
   legible as a block; and because it is rostered rather than dimmed, every
   mark in the block is one that is actually open.

   Slightly tighter than the passage strip, because on its widest days it is
   carrying twice as many marks and it sits above the numeral where vertical
   space is dearest. */
.passage-strip--season {
  flex-wrap: wrap;
  gap: 0.2rem 0.28rem;
  overflow-x: visible;
  margin-bottom: 0.2rem;
}

/* Both the row and its individual marks go out with `hidden` — see
   updatePassageStrip. `display: none` has to be forced because .passage-strip
   sets `display: flex`, which beats the UA stylesheet's [hidden] rule. */
.passage-strip[hidden],
.passage-strip .passage-link[hidden],
.passage-strip .passage-mark[hidden] { display: none; }

/* No bundles built, no marks appended, no ruled gap in the card. Same bargain
   .breadcrumbs:empty strikes at the foot of the face. */
.passage-strip:empty { display: none; }

/* The row was a single role="img" tab stop until each mark became a link to its
   taxon's chart. It is a role="group" now and takes no focus of its own; the
   rule is kept for the no-JS and missing-page case, where a strip can still end
   up focusable, and costs nothing when it never matches. */
.passage-strip:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 3px;
  border-radius: 0.35rem;
}

/* THE LINK AROUND A MARK. Layout and focus only — every property that paints
   is on .passage-mark below, and that split is load-bearing: link-camouflage.css
   sets `color: inherit !important` on every <a> at rest, and the flyway hue on
   these marks is the reading itself rather than decoration. Declared one level
   down, it survives; declared here, it would be flattened twenty times over.

   No frame and no box halo, for the reason the chips carry none — see the
   celestial chip block below. The affordance is the mark turning from grey to
   the season's hue, which is the same move .passage-mark:hover already made. */
.passage-link {
  flex: 0 0 auto;
  display: block;
  line-height: 0;
  border-radius: 0.3rem;
}

/* The visible focus state WCAG 2.4.7 requires. A drawing has no glyph to halo
   the way a chip's character does, so the ring is what is available; it is the
   same ring the strip itself used to take. */
.passage-link:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 3px;
}

/* The whole hover state, since the strip rests flat: grey to the season's hue.
   Focus takes it too, so the keyboard gets the same answer the pointer does. */
.passage-link:hover .passage-mark,
.passage-link:focus-visible .passage-mark { color: var(--flyway-passage); }

/* The peak halo travels with the hue. A drop-shadow in the season's colour
   around a mark drawn in neutral grey is a glow with nothing casting it — the
   two have to arrive together or neither should. */
.passage-link:hover .passage-mark[data-passage="peak"],
.passage-link:focus-visible .passage-mark[data-passage="peak"] {
  filter: drop-shadow(0 0 2px var(--flyway-passage));
}

/* Where one group ends and the next begins: after the bird guilds, after the
   three other animal taxa, and after the flowers. The gap is what says so —
   one seam, on the first mark of each new group. It sits on whichever element
   is the flex item: the link when the mark has a page, the mark itself when it
   does not. */
.passage-link[data-passage-seam],
.passage-mark[data-passage-seam] { margin-left: 0.5rem; }

.passage-mark {
  flex: 0 0 auto;
  display: block;
  width: 1.3rem;
  height: 1.3rem;
  /* ONE INK, ONE STRENGTH, EVERY MARK, AT REST.
     The burn used to ride the opacity — calc(0.45 + 0.55 * --passage-burn) —
     so twelve marks in twelve states were twelve different greys, and a row
     meant to read as one row read as a mixed set. The burn is still on the
     element as a custom property and still drives the tooltip's sentence; it
     simply no longer paints. Every mark is now the same light grey at the same
     full strength, and the hover is the only thing that changes any of it. */
  color: var(--flyway-mark);
  opacity: 1;
  transition: opacity 600ms ease, color 600ms ease, filter 600ms ease;
}

/* THE HUE IS A HOVER STATE, NOT A RESTING ONE.
   The strip used to paint every mark in passage with the season's colour — the
   same swap .flyways makes for a corridor with somebody on it — so on an
   August day a dozen orange animals sat in the middle of the card and read as
   a row of controls rather than a gauge. At rest the whole strip is now one
   flat --flyway-mark grey, and the season's hue is what the pointer buys.

   The reading did not disappear with the paint. Every mark still carries its
   burn and its data-passage word, and the tooltip on hover and focus says in
   a sentence what the colour used to say in a shade — which was always the
   fuller answer of the two. */
.passage-mark:hover { color: var(--flyway-passage); }

/* At peak, the hovered mark carries the lamp's halo at a fraction of its
   strength: the shape of the light is the shape of the animal, one tight stop
   rather than the lamp's two, because these are a fifth the size and sit in a
   row of twelve. Hover-only, for the reason the hue is — see .passage-link. */
.passage-mark[data-passage="peak"]:hover {
  filter: drop-shadow(0 0 2px var(--flyway-passage));
}

.passage-mark__svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* Struck the same way every other mark on the card is: stroked in currentColor
   at a weight that survives 1.15rem, with the few pieces that are field marks
   rather than outline — the thrush's spots, the sparrow's conical bill, the
   raptor's head — filled so they read as marks and not as tiny empty rings. */
.passage-mark .pm-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.passage-mark .pm-solid {
  fill: currentColor;
  stroke: none;
}

/* ==========================================================================
   14-chips-and-clock.css — THE ASTROLABE, AND THE CHIPS THAT CARRY NO BORDER

   The day/night switch that opens the celestial row, the Roman numeral cartouche,
   the astrolabe and every painted part of its SVG, the zodiac and lunar chips with
   their anchor armour and glyph halos, and the shared cultural tooltip.

   These are the ZODIAC chips — not the tag chips (18-note-foot.css) and not the
   passage marks (13-flyway-lamp.css).

   ORDER AND LOCKS
   NO BORDERS ON THE CHIPS, EVER — no border, ring outline, ring box-shadow, or
   ::before/::after ring. A box-shadow on a chip IS a border. This regresses
   because month sheets re-declare it.

   Every --modifier here is an equal-specificity override of its base (zc-ring,
   zc-tick, zc-glyph, zc-hand, celestial-chip--moon) and must stay after it. The
   no-border doctrine comment and the anchor-proofing specificity spec ARE the
   rules; never separate them from the rules they govern. `transform` is
   deliberately left on the plain :hover rules so 16-motion.css can cancel it —
   that cancellation only works because motion is emitted later.

   The switch is an instrument and the numeral is part of the breadcrumb card;
   source order pins both here.

   RULES · style/chips.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

/* ---- The day/night switch ------------------------------------------------
   Sun on the day side, moon on the night side, a track between them, and a
   knob that travels to whichever side is live. Both marks are literal and both
   stay visible; the knob points, it never covers.

   THE MARKS SIT OUTSIDE THE TRACK. That is the whole correction, and it is not
   a style choice. Inscribed ON the track — which is the obvious place and the
   first thing built here — the knob parks squarely on whichever mark is
   active, so the reader is looking at a covered sun to learn that it is day.
   Flanking the track, the knob's position and the two marks are three separate
   pieces of information that never occlude each other, at any size.

   The repeated class buys specificity, for the same reason it does on the
   chips: this is a <button>, and october and november write
   body[data-theme-id="…"] button rules that set a 2px border and cancel hover
   transforms. Neither belongs on a fitting in the card face. */
.daynight-switch.daynight-switch.daynight-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* The gap between a mark and the track. Wide enough that the crescent is
     never read as part of the capsule, tight enough that the three pieces
     still read as one control. */
  gap: 0.34rem;
  width: auto;
  /* 1.5rem is 24px, the floor for a pointer target (WCAG 2.5.8). The control
     is taller than its track on purpose: the track is 1.05rem, and the rest is
     hit area the marks happen to sit in. */
  height: 1.5rem;
  min-width: 0;
  padding: 0;
  border-radius: 0;
  font: inherit;
  -webkit-text-fill-color: currentColor;
  text-shadow: none;
}

/* The groove. The only recess left in the rail now that the sockets are gone,
   and it survives the cull because a switch without a track is just a dot: the
   track is what says this thing slides. An inset shadow with no border does
   it — a channel cut into the face, no rectangle drawn on top of it. */
.daynight-switch__track {
  position: relative;
  flex: 0 0 auto;
  width: 2.15rem;
  height: 1.05rem;
  border-radius: 999px;
  box-shadow:
    inset 0 1px 3px rgba(0, 0, 0, 0.5),
    inset 0 0 0 1px rgba(0, 0, 0, 0.14);
  pointer-events: none;
}

/* The two marks. The live one is at full strength and the other is cut back
   far enough to read as the unchosen option but never far enough to disappear
   — both have to be legible at rest or the switch stops explaining itself. */
.instrument-mark {
  display: block;
  flex: 0 0 auto;
  width: 0.85rem;
  height: 0.85rem;
  opacity: 0.34;
  transition: opacity 160ms ease;
}

.instrument-mark .im-sun-body,
.instrument-mark .im-moon { fill: currentColor; }

.instrument-mark .im-sun-ray {
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}

.daynight-switch[aria-checked="false"] .instrument-mark--sun,
.daynight-switch[aria-checked="true"] .instrument-mark--moon { opacity: 1; }

/* The knob. Absolutely positioned inside the track so its travel is a
   transform on a small isolated element — no layout, no repaint of the track,
   and no chance of it wandering outside the groove. */
.daynight-switch__knob {
  position: absolute;
  top: 50%;
  left: 0.13rem;
  width: 0.79rem;
  height: 0.79rem;
  border-radius: 50%;
  background: var(--tt-gold);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
  transform: translate3d(0, -50%, 0);
  transition: transform 180ms ease, background-color 180ms ease;
}

.daynight-switch[aria-checked="true"] .daynight-switch__knob {
  background: var(--tt-breadcrumb-text);
  /* The track has no border now, so the travel is plain rem arithmetic: the
     2.15rem groove, less the 0.13rem gutter the knob starts in, less the
     matching gutter at the far end, less the knob's own 0.79rem. Every term is
     a declaration a few lines up, which is what keeps this correct the next
     time the capsule is resized. */
  transform: translate3d(calc(2.15rem - 0.26rem - 0.79rem), -50%, 0);
}

/* ---------------------------------------------------------------------------
   Celestial instruments — astrolabe zodiac clock, lunar dial, and the
   multi-culture zodiac strip riding inside the breadcrumb bar. All geometry
   is inline SVG built client-side; every colour resolves from theme tokens
   so each month tints the brass, parchment, and moonlight its own way.
   --------------------------------------------------------------------------- */
.celestial-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.55rem 0.9rem;
  width: 100%;
  min-width: 0;
}

.zodiac-clock-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  flex: 0 0 auto;
}

/* The card's numeral, at the head of the face. Month·day and hour:minute both
   in Roman numerals, flanked by rules the way a trump's number is. The flex gap
   keeps date and time legible as two fields, so VIII·IX XV:XLV never reads as
   one run of numerals. */
.tarot-card__numeral {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  width: 100%;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.2em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
  /* Struck into the face rather than printed on it. The numeral is the one
     piece of type on the card that names the card, so it is the piece that
     gets cut; the trail below is lettering and keeps its glow. */
  text-shadow: var(--tt-engrave);
}

/* The rules that run out to either side of the numeral, tapering away from it.
   A rule of even weight all the way to its end reads as a divider — it stops
   somewhere, and the eye asks why there. A rule that fades reads as an
   inscribed stroke that ran out of pressure, which is what a struck card has,
   and it lets the mark reach much further across the face without cutting the
   head off the illustration below.

   They are double rules with a terminal point, which is how a trump's number
   is actually cut: not one line into the numeral but a heavy rule, a lighter
   rule under it that gives out sooner, and a struck point where the pair meets
   the type. That last mark is the piece that turns the head from "a label
   between two dashes" into a cartouche — it is a full stop in metal, and it
   uses the same radial-gradient point as the card's corners so the vocabulary
   of the plate is one vocabulary. All three marks are layers on a
   pseudo-element that was already painting one, so the head costs no new
   elements and nothing here animates. */
.tarot-card__numeral::before,
.tarot-card__numeral::after {
  content: "";
  flex: 1 1 auto;
  max-width: 5.5rem;
  /* 1px rule, 1px gap, 1px rule. The gap is what makes it read as two strokes
     rather than one thick one. */
  height: 3px;
  opacity: 0.45;
  background-repeat: no-repeat;
  background-size: 100% 1px, 100% 1px, 3px 3px;
}

.tarot-card__numeral::before {
  background-image:
    linear-gradient(90deg, transparent, var(--tt-red)),
    linear-gradient(90deg, transparent 54%, var(--tt-red)),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent);
  background-position: left top, left bottom, right center;
}

.tarot-card__numeral::after {
  background-image:
    linear-gradient(90deg, var(--tt-red), transparent),
    linear-gradient(90deg, var(--tt-red), transparent 46%),
    radial-gradient(circle closest-side, var(--tt-red) 98%, transparent);
  background-position: left top, left bottom, left center;
}

/* The date is the numeral proper; the time reads one step quieter beside it. */
.tarot-card__numeral-date { color: var(--tt-red); font-weight: 700; }

.tarot-card__numeral-time { opacity: 0.8; }

/* The dial is the card's illustration, so it is sized like one. At 6rem it was
   a widget sitting next to some text; the ring of twelve signs is the reason
   anyone looks at this card and it needs the room to be read as a ring rather
   than a texture. The SVG is a viewBox, so this is the only number that moves
   — every stroke inside scales with it and none of the geometry is retuned. */
.zodiac-clock-wrap { flex: 0 0 auto; width: 7.4rem; height: 7.4rem; }

/* The dial is a <button> — it opens the world clock. Tripled to (0,3,0) for
   the reason the instrument rail is, and with the same two declarations that
   have to be said rather than merely left unset: the site paints `button`
   borders with the header rainbow as a border-image, and a border-image needs
   a width, so `border: 0` is what removes both. The full arithmetic is written
   out once on .instrument.

   Nothing is drawn on top of the plate: no ring, no fill, no lift. An
   astrolabe that grows a button frame under the pointer stops being an
   instrument set into the card and becomes a control bolted onto it, which is
   the mistake the theme rail made and the reason it is gone. What the pointer
   gets is the plate's own brass coming up — a filter on the SVG, which touches
   every stroke inside at once and costs no repaint of the card. */
.zodiac-clock-button.zodiac-clock-button.zodiac-clock-button {
  display: block;
  padding: 0;
  border: 0;
  border-image: none;
  background: transparent;
  color: inherit;
  box-shadow: none;
  transform: none;
  cursor: pointer;
  transition: filter 160ms ease;
}

.zodiac-clock-button.zodiac-clock-button.zodiac-clock-button:hover,
.zodiac-clock-button.zodiac-clock-button.zodiac-clock-button:focus-visible {
  border: 0;
  box-shadow: none;
  transform: none;
  filter: brightness(1.18);
}

/* The focus ring is the one mark allowed on the plate, because a keyboard
   reader has no pointer to discover the affordance with. It is offset clear of
   the outer ring so it reads as a ring around the dial rather than a second
   ring on it. */
.zodiac-clock-button.zodiac-clock-button.zodiac-clock-button:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 3px;
}

.zodiac-clock { display: block; width: 100%; height: 100%; }

/* Month cartouche curved along the plate's upper inner arc. */
.zodiac-clock .zc-month {
  fill: var(--tt-red);
  font-family: var(--font-mono);
  font-size: 4.4px;
  letter-spacing: 0.08em;
  opacity: 0.88;
}

.zodiac-clock .zc-face { fill: var(--tt-bg-alt); fill-opacity: 0.38; }

.zodiac-clock .zc-ring { fill: none; stroke: var(--tt-gold); stroke-width: 1; opacity: 0.82; }

.zodiac-clock .zc-ring--outer { stroke-width: 1.7; opacity: 0.95; }

.zodiac-clock .zc-ring--faint { stroke-width: 0.6; opacity: 0.45; }

.zodiac-clock .zc-tick { stroke: var(--tt-gold); stroke-width: 0.5; opacity: 0.5; }

.zodiac-clock .zc-tick--major { stroke-width: 1; opacity: 0.85; }

.zodiac-clock .zc-spoke { stroke: var(--tt-gold); stroke-width: 0.4; opacity: 0.32; }

/* The ring of twelve. Raised from 7.4px because at the dial's old size these
   were a band of texture rather than twelve readable signs — the point of an
   astrolabe is that you can name what is on the ring. */
.zodiac-clock .zc-glyph {
  fill: var(--tt-cyan-soft);
  font-family: var(--font-text);
  font-size: 8.8px;
  text-anchor: middle;
  dominant-baseline: central;
  opacity: 0.92;
}

/* Today's sign is the one reading the dial exists to give, so it is set in the
   anchor red, at a size that separates it from the other eleven at a glance
   rather than on inspection. */
.zodiac-clock .zc-glyph--current { fill: var(--tt-red); font-size: 10.4px; font-weight: 700; }

.zodiac-clock .zc-numeral {
  fill: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 4.9px;
  letter-spacing: 0.04em;
  text-anchor: middle;
  dominant-baseline: central;
  opacity: 0.78;
}

.zodiac-clock .zc-hand line { stroke: var(--tt-breadcrumb-text); stroke-linecap: round; }

.zodiac-clock .zc-hand path { fill: var(--tt-breadcrumb-text); }

.zodiac-clock .zc-hand--hour line { stroke-width: 2.2; }

.zodiac-clock .zc-hand--minute line { stroke-width: 1.4; }

.zodiac-clock .zc-hand--second line { stroke: var(--tt-red); stroke-width: 0.7; }

.zodiac-clock .zc-hand--second circle { fill: var(--tt-red); }

.zodiac-clock .zc-boss { fill: var(--tt-gold); stroke: var(--tt-bg); stroke-width: 0.8; }

.celestial-strip {
  display: flex;
  align-items: stretch;
  gap: 0.42rem;
  min-width: 0;
  max-width: 100%;
  padding: 0.3rem 0.15rem;
  overflow-x: auto;
  scrollbar-width: thin;
}

/* ---------------------------------------------------------------------------
   THE ZODIAC CHIPS CARRY NO BORDER. THIS IS PERMANENT — DO NOT ADD ONE BACK.

   The frame in the breadcrumb bar belongs to the tarot card itself (see
   .breadcrumb-nav and its ::after keyline). Boxing each lunar/zodiac chip as
   well put a grid of little rectangles inside the card face, which is exactly
   what the design does not want. `border: 0` here is deliberate and load
   bearing, not an oversight.

   That includes month theme sheets: a `body[data-theme-id="…"] .celestial-chip`
   rule setting border, border-image, border-color, outline or a hairline
   box-shadow outranks this one and puts the boxes straight back. It has already
   happened once, through the monochrome months. If a month needs the chips to
   read differently, change their colour, weight or spacing — never their frame.

   NO BOX HALO EITHER. The hover affordance is drawn on the GLYPH, never on the
   chip box: `text-shadow` on .celestial-chip__glyph traces the character's own
   outline, and `filter: drop-shadow()` traces the moon's SVG path. Neither one
   can render as a rectangle, which a `box-shadow` on the chip always does the
   moment its blur tightens. The chip element itself carries no box-shadow at
   all — that is why the property is absent from the transition list below.

   The keyboard focus indicator is glyph-drawn too, for the same reason: a
   brighter, wider version of the hover halo plus the revealed system label.
   That is a visible focus state without an `outline` box. A chip is a real
   focusable control — an <a href> to its sign's page — so leaving keyboard
   users with no indicator is not on the table; if this glow is ever weakened,
   strengthen the glow rather than putting the outline ring back.

   The system label (.celestial-chip__sys) is hidden at rest and revealed on
   hover/focus with opacity alone, never `display: none`, so the label keeps its
   box and the breadcrumb bar cannot reflow as the pointer crosses the strip.

   The SVG artwork is a separate matter: the astrolabe's engraved rings
   (.zc-ring) and the moon's limb (.cm-rim) are strokes inside the illustration,
   not frames around an element, and they stay.
   --------------------------------------------------------------------------- */
.celestial-chip {
  flex: 0 0 auto;
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.18rem;
  min-width: 2.7rem;
  padding: 0.3rem 0.5rem;
  background: transparent;
  border: 0;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  /* Pinned, not inherited, and load bearing. Every chip is an <a href> today —
     the seven signs to their sign notes, the moon to /calendar/lunar-calendar —
     but the strip was mixed until the lunar note landed, and the pin is what
     held it together: a <button> takes its font-size from the UA sheet (13.3px
     in Blink, the system font in Gecko, 11px in WebKit) while an <a> takes it
     from <body>, which is 16px. Nothing in the chip renders at this size —
     every child sets its own rem — but `letter-spacing` below is in em, so
     unpinning it would measure chips differently on the same row, per engine,
     the moment any one of them changes element again. */
  font-size: 0.8125rem;
  line-height: 1;
  letter-spacing: 0.04em;
  text-align: center;
  text-transform: none;
  text-decoration: none;
  cursor: pointer;
  /* A button is unselectable and undraggable by UA default and a link is
     neither, so a drag across the strip would lasso the glyphs or peel a link
     off it. The strip is an instrument, not prose. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 140ms ease, color 140ms ease;
}

.celestial-chip:hover, .celestial-chip:focus-visible {
  color: var(--tt-breadcrumb-text);
  transform: translateY(-1px) scale(1.05);
}

/* No outline ring — the focus state is the glyph halo below. */
.celestial-chip:focus-visible { outline: none; }

/* ---------------------------------------------------------------------------
   ANCHOR PROOFING. Every chip is an <a href> into the vault: each sign to its
   sign note, the moon to /calendar/lunar-calendar. The strip ran mixed —
   anchors beside one <button> — until the lunar note landed, and it will run
   mixed again the first time a chip's target goes missing and buildChip() falls
   back to a button. One class, one row, and the chips have to be
   indistinguishable — so nothing below may care which element it is looking at.

   The class is repeated on purpose. Element-qualified selectors are the wrong
   tool: they would have to be written twice, once per element type, and would
   rot the next time a chip changes element — which is exactly what happened
   here once already. Repetition only buys specificity, and the count is the
   minimum that wins, RECOMPUTED. It used to be two: a theme's generic link
   rule was `body[data-theme-id="…"] a` at (0,1,2), and (0,2,0) cleared it.

   The day/night switch added a second attribute to every one of those
   selectors. `body[data-theme-id="october"][data-mode="day"] a` is (0,2,2),
   which BEATS a doubled class, and the `:hover` form is (0,3,2) against the
   doubled `:hover` rule's (0,3,0). Two was silently no longer enough, and the
   failure would have been invisible until a reader flipped the switch on one
   of the seven sheets that flatten links with -webkit-text-fill-color — the
   glyphs would have blanked in one mode only. Three clears both: (0,3,0) over
   (0,2,2), and (0,4,0) over (0,3,2).

   If a third attribute is ever added to the body selector, this count goes to
   four. Do not raise it speculatively; raise it against a real selector, and
   write the arithmetic down here the way these two paragraphs do. Same trick,
   and the same recount, applies to the .instrument-gear and .daynight-switch
   carve-outs in the instrument section above.

   What is being held off is all real and all already in the sheets today:
     · `a:visited` — the six monochrome months, October and November all recolour
       visited links, and .celestial-chip__glyph takes its resting colour by
       inheritance, so a chip would change colour once its sign had been read.
     · `a:hover` — the site's link affordance is a hotpink text-shadow plus the
       looping `linkHotpinkGlow`. text-shadow inherits, so it would bloom behind
       __sys and __moonage, and a chip is not allowed to run a continuous
       animation.
     · `-webkit-text-fill-color` — january, february, march, may, june, december
       and lava-lamp flatten every `a` to #fff or #000 with it. It inherits and
       it beats `color` on descendants, so it would blank the glyphs and kill the
       hover/focus colour change outright. `currentColor` is the property's own
       initial value and resolves per element, so each child keeps its own paint.
     · frames — the chips carry none, on any theme, ever; see the note above.
       Nothing borders a bare `a` today, and `border: 0` / `box-shadow: none`
       are restated here so nothing added to one later can reach the strip.

   Deliberately NOT restated here: `transform`. The lift stays on the plain
   `.celestial-chip:hover` / `:focus-visible` rules above so the reduced-motion
   block and the flat-chrome months can still cancel it at their own weight.
   --------------------------------------------------------------------------- */
.celestial-chip.celestial-chip.celestial-chip {
  background: transparent;
  border: 0;
  box-shadow: none;
  color: var(--tt-gold);
  -webkit-text-fill-color: currentColor;
  text-decoration: none;
  text-shadow: none;
  animation: none;
}

/* A read sign is not a spent link. Every chip on the strip reads the same
   whether or not its page has been opened, so the visited colour is refused
   outright. (Only `color` is restated — browsers drop everything else from a
   :visited rule for history-leak reasons, so listing more would be theatre.) */
.celestial-chip.celestial-chip.celestial-chip:visited { color: var(--tt-gold); }

.celestial-chip.celestial-chip.celestial-chip:hover,
.celestial-chip.celestial-chip.celestial-chip:focus-visible {
  color: var(--tt-breadcrumb-text);
  -webkit-text-fill-color: currentColor;
  text-decoration: none;
  text-shadow: none;
  outline: none;
  animation: none;
}

.celestial-chip__glyph {
  font-family: var(--font-text);
  font-size: 1.04rem;
  line-height: 1.1;
  transition: text-shadow 160ms ease, color 160ms ease;
}

/* The halo is cast by the character itself, so it follows the shape of ♌, ☥ or
   an ogham stave rather than boxing it. Three stops: a tight one that reads as
   an outline hugging the strokes, then two wider ones that bloom off it. */
.celestial-chip:hover .celestial-chip__glyph {
  color: var(--tt-breadcrumb-text);
  text-shadow:
    0 0 2px var(--zodiac-glow),
    0 0 7px var(--zodiac-glow),
    0 0 15px var(--zodiac-glow);
}

/* Keyboard focus gets a brighter, wider bloom than hover — this is the focus
   indicator, so it has to be unmistakable without drawing a box. */
.celestial-chip:focus-visible .celestial-chip__glyph {
  color: var(--tt-breadcrumb-text);
  text-shadow:
    0 0 2px var(--zodiac-glow),
    0 0 6px var(--zodiac-glow),
    0 0 12px var(--zodiac-glow),
    0 0 22px var(--zodiac-glow);
}

/* The moon chip has an SVG where the others have a character; drop-shadow
   traces the rendered path the same way text-shadow traces a glyph. */
.celestial-chip:hover .celestial-moon {
  filter: drop-shadow(0 0 2px var(--zodiac-glow)) drop-shadow(0 0 8px var(--zodiac-glow));
}

.celestial-chip:focus-visible .celestial-moon {
  filter:
    drop-shadow(0 0 2px var(--zodiac-glow))
    drop-shadow(0 0 7px var(--zodiac-glow))
    drop-shadow(0 0 14px var(--zodiac-glow));
}

/* Hidden at rest, revealed only while its own chip is hovered or focused.
   Opacity — not display or visibility — so the label keeps its box and the
   strip's chip widths never change, which is what stops the bar from jumping.
   `visibility` is left alone deliberately: swapping it would make the reveal
   step rather than fade, and the chip's aria-label already carries the system
   name for assistive tech either way. */
.celestial-chip__sys {
  color: var(--tt-cyan-soft);
  font-size: 0.48rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  opacity: 0;
  transition: opacity 160ms ease, color 160ms ease;
}

.celestial-chip:hover .celestial-chip__sys,
.celestial-chip:focus-visible .celestial-chip__sys {
  color: var(--tt-breadcrumb-text);
  opacity: 0.95;
}

/* Touch and pen: there is no hover, so a reveal-on-hover label would be a label
   nobody can read. Those pointers get the labels permanently, which is also the
   pre-existing behaviour on those devices, so nothing is lost. Tapping a chip
   still opens the tooltip; that path is unchanged. */
@media (hover: none) {
  .celestial-chip__sys { opacity: 0.9; }
}

.celestial-chip--moon { flex-direction: row; gap: 0.5rem; padding: 0.3rem 0.62rem; }

.celestial-moon { flex: 0 0 auto; width: 1.9rem; height: 1.9rem; transition: filter 160ms ease; }

.celestial-moon .cm-disc { fill: rgba(13, 9, 7, 0.82); }

.celestial-moon .cm-lit { fill: var(--tt-gold); }

.celestial-moon .cm-rim { fill: none; stroke: var(--tt-gold); stroke-width: 1; opacity: 0.8; }

.celestial-chip__text { display: flex; flex-direction: column; align-items: flex-start; gap: 0.2rem; }

.celestial-chip__moonname {
  color: var(--tt-breadcrumb-text);
  font-size: 0.56rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-shadow: 0 0 8px var(--tt-breadcrumb-glow);
  white-space: nowrap;
}

.celestial-chip__moonage {
  color: var(--tt-cyan-soft);
  font-size: 0.5rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Shared cultural tooltip: fixed-position so it never clips inside the bar's
   overflow, clamped to the viewport by JS, shown on hover AND keyboard focus. */
.celestial-tooltip {
  position: fixed;
  z-index: 60;
  max-width: min(20rem, calc(100vw - 1rem));
  padding: 0.62rem 0.78rem;
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--bg);
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.68rem;
  line-height: 1.55;
  letter-spacing: 0.02em;
  text-align: left;
  box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
  opacity: 0;
  visibility: hidden;
  transition: opacity 140ms ease;
  pointer-events: none;
}

.celestial-tooltip.is-visible { opacity: 1; visibility: visible; }

.celestial-tooltip__title {
  display: block;
  margin-bottom: 0.3rem;
  color: var(--tt-breadcrumb-text);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-shadow: 0 0 8px var(--tt-breadcrumb-glow);
}

/* The tooltip's foot. Most chips are links now, and the cue is the line that
   says so — "Open the Leo page →". The tooltip's other children are inline, so
   without `display: block` it runs straight on from the last word of the
   description and stops reading as an affordance at all; the margin above is
   the only separator it gets. No rule, no frame: the celestial instruments do
   not draw boxes, and that goes for the tooltip's insides too.

   Type is lifted off .celestial-chip__sys — the small, tracked, uppercase key
   the strip already sets its labels in — a step under the body text so it reads
   as a footer rather than a second sentence. --tt-gold is the chip's own
   resting glyph colour, which ties the cue back to the chip it was raised from;
   every month defines it and every month's value clears its own --bg, so this
   needs no per-theme follow-up. The months that flatten gold and breadcrumb
   text to one value collapse it against the title, which is what they want.

   Every chip on the strip has one now, the moon included — its cue reads
   `Open the lunar calendar →`. A chip built without an href carries no cue
   element at all, so this rule simply finds nothing to style. */
.celestial-tooltip__cue {
  display: block;
  margin-top: 0.42rem;
  color: var(--tt-gold);
  font-size: 0.6rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/* ==========================================================================
   15-home-and-settings.css — THE PAGES THAT ARE NOT A NOTE — AND THE BORDER SWEEP

   The home hero and card panels, the single sweep that repaints every border in
   the sheet with the header gradient, the /search results page, the /settings
   theme picker, and the scrollbars.

   ORDER AND LOCKS
   The BORDER SWEEP is the most order-constrained rule in the sheet. It overrides
   img, hr, table, th/td, input, button, .sitemap-link, .time-bar and the four
   card classes immediately above it, ALL at equal specificity, so it must stay
   after every one of them. That is why it lives here and not in 04-frame.css,
   where it is documented.

   Preserve the sweep, the `table` rule and the `hr` rule byte for byte: the
   table's border shorthand currently kills its own border-image. That is a latent
   bug, recorded here, and NOT a licence to reorder. The four rules immediately
   after the sweep are unrelated and are not part of it. The theme cards
   deliberately inherit the sweep, so they must follow it.

   RULES · style/frame.md (the sweep) · style-theme.agent.md (the picker)
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

.hero {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(17rem, 0.8fr);
  gap: clamp(1.2rem, 4vw, 2.2rem);
  align-items: start;
  padding-bottom: 2rem;
  border-bottom: 1px solid var(--border-dim);
}

.hero-copy { max-width: 42rem; }

.hero-copy p { max-width: 43rem; color: var(--text-soft); font-size: 1.02rem; }

.eyebrow {
  display: inline-flex;
  margin-bottom: 0.85rem;
  color: var(--tt-orange);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.24em;
  text-transform: uppercase;
}

.hero-panel, .content-grid { display: grid; gap: 1rem; }

.hero-card, .folder-section {
  border: 1px solid var(--border-dim);
  border-radius: 0.6rem;
  padding: clamp(1rem, 2.4vw, 1.5rem);
  box-shadow: 0 0 20px rgba(190, 103, 65, 0.1);
  transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}

.hero-card:hover, .folder-section:hover {
  border-color: rgba(176, 214, 143, 0.42);
  box-shadow: 0 0 24px rgba(176, 214, 143, 0.12);
  transform: translate3d(0, -3px, 0);
}

.hero-card h2, .folder-section h3 { margin-top: 0; }

.hero-card h2 { font-size: 1.12rem; }

.hero-card p { color: var(--text-soft); }

.folder-section { margin: 1.35rem 0; }

.folder-section ul, .page-list { list-style: none; padding: 0; }

.folder-section a, .page-list a { font-family: var(--font-mono); font-size: 0.92rem; }

.tag {
  display: inline-flex;
  align-items: center;
  border: 1px solid rgba(163, 255, 140, 0.28);
  border-radius: 999px;
  padding: 0.05rem 0.48rem;
  color: var(--tt-green);
  font-family: var(--font-mono);
  font-size: 0.82em;
}

/* Use the header neon strip's exact palette for every visible UI/content border. */
.markdown-page img,
blockquote,
code,
pre,
table,
th,
td,
input,
textarea,
select,
button,
.sitemap-link,
.time-bar,
.hero,
.hero-card,
.folder-section,
.tag {
  border-color: transparent;
  border-image: var(--tt-heading-rainbow) 1;
}

table { border: 1px solid transparent; }

hr {
  border-top-color: transparent;
  border-image: var(--tt-heading-rainbow) 1;
}

.more-items { margin-top: 0.55rem; color: var(--muted); font-style: italic; }

.sitemap-link { margin: 1rem 0; padding: 0.58rem 1rem; }

.sitemap { max-width: 100%; }

.sitemap-list li { padding: 0.18rem 0; }

/* Full results page at /search. Ranked, so an <ol> — but the ranking is carried
   by the order alone, not by numbering the reader has no use for. */
.search-page__form {
  display: flex;
  align-items: stretch;
  gap: 0.5rem;
  width: min(100%, 34rem);
  margin: 1.2rem 0 1.6rem;
}

.search-page__form input {
  flex: 1 1 auto;
  width: auto;
  min-width: 0;
  margin-top: 0;
}

.search-page__form button { flex: 0 0 auto; }

.search-summary { color: var(--text-soft); }

.search-results {
  list-style: none;
  margin: 1.6rem 0 0;
  padding-left: 0;
}

.search-result {
  margin: 0;
  padding: 0.9rem 0;
  border-top: 1px solid var(--border-dim);
}

.search-result__link {
  font-size: 1.08rem;
  font-weight: 700;
}

/* A page named after a tag reads as one thing — `baking #baking` — so the chip
   sits on the title's own line rather than under it. Same chip as .tag-row__tag;
   it is a separate class because that one is a flex child of a nav and this one
   is inline beside a heading link. On the tag page itself the chip renders as a
   <span>, which is why the colour and border are set on the base class and not
   only on the anchor. */
.search-result__tag {
  display: inline-block;
  margin-left: 0.45rem;
  padding: 0.08rem 0.45rem;
  border: 1px solid var(--border-dim);
  background: transparent;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1.5;
  letter-spacing: 0.03em;
  text-transform: lowercase;
  text-decoration: none;
  color: var(--muted);
  vertical-align: 0.08em;
  transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease;
}

a.search-result__tag:hover,
a.search-result__tag:focus-visible {
  color: var(--text);
  border-color: var(--border);
  background: rgba(176, 214, 143, 0.1);
}

.search-result__path {
  margin: 0.2rem 0 0;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  letter-spacing: 0.04em;
}

.search-result__snippet {
  margin: 0.4rem 0 0;
  color: var(--text-soft);
  line-height: 1.55;
}

/* ---------------------------------------------------------------------------
   /settings — the workshop behind the gear.

   This is where the thirteen-swatch rail went. A rail had room for a dot and a
   three-letter stub; a page has room for the swatch to be big enough to read,
   for the theme's name in full, and for the one fact the dot could never say —
   whether picking it also writes the choice into the vault.

   No new surface: sections are separated by a ruled edge in the month's red,
   the way the card separates its numeral from its illustration, over the one
   shared site background. The swatch is the only painted fill on the page, and
   it is the content.
   --------------------------------------------------------------------------- */
.settings-page {
  max-width: 52rem;
}

.settings-page__lede {
  max-width: 42rem;
  color: var(--text-soft);
}

.settings-block {
  margin-top: 2.4rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--tt-red);
  border-top-color: color-mix(in srgb, var(--tt-red) 32%, transparent);
}

.settings-block__title {
  margin-top: 0;
}

.settings-block__note {
  max-width: 42rem;
  margin-bottom: 1.3rem;
  color: var(--text-soft);
}

/* The switch on this page is the same object the card carries, mounted beside
   its written name instead of standing on its own as an icon. */
.settings-switch {
  display: flex;
  align-items: center;
  gap: 0.85rem;
}

.settings-switch__label {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

/* Auto-fill rather than a fixed column count, so thirteen themes and twenty
   themes both lay out without this rule being touched again. */
.theme-choices {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
  gap: 0.7rem;
}

/* A settings card is a button and takes the month's own button chrome — its
   frame, its radius, its rainbow border-image — on purpose: the workshop is
   dressed by whichever theme is being worn while you stand in it. Only the
   layout is stated here. */
.theme-choice {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.6rem 0.7rem;
  text-align: left;
  cursor: pointer;
}

/* The palette itself, at a size where six stops are six colours. */
.theme-choice__swatch {
  flex: 0 0 auto;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 0.3rem;
  background: var(--theme-preview, var(--tt-heading-rainbow));
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}

.theme-choice__body {
  display: flex;
  flex-direction: column;
  gap: 0.12rem;
  min-width: 0;
}

.theme-choice__name {
  color: var(--text);
  font-family: var(--font-heading);
  font-size: 0.98rem;
  line-height: 1.25;
}

.theme-choice__meta {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/* The chosen card is struck, not highlighted: an inset keyline in the month's
   red, matching the card's ::after, plus the name coming up to full ink. Two
   cards can be lit at once and that is the truth of the state — "follow the
   calendar" says who is choosing and the month says what is worn. */
.theme-choice.is-active {
  box-shadow: inset 0 0 0 1px var(--tt-red);
}

.theme-choice.is-active .theme-choice__name {
  color: var(--tt-red);
  font-weight: 700;
}

.theme-choice.is-active .theme-choice__meta { color: var(--text-soft); }

::-webkit-scrollbar { width: 9px; height: 8px; }

::-webkit-scrollbar-track { background: transparent; }

::-webkit-scrollbar-thumb { background-color: var(--tt-teal); border-radius: 999px; }

::-webkit-scrollbar-thumb:hover { background-color: var(--tt-cyan); }

::-webkit-scrollbar-thumb:active { background-color: var(--tt-purple); }

/* ==========================================================================
   16-motion.css — THE KEYFRAMES, THE PHONE BREAKPOINT, AND THE OPT-OUTS

   The keyframes library, the 800px phone breakpoint, and the reduced-motion and
   reduced-data blocks that retire the sky, fireworks, flyway birds, clock hand,
   chip lifts and link glow.

   ORDER AND LOCKS
   Must be emitted last of everything it cancels. Media queries carry no
   specificity bonus, so the 800px block wins only by position, and neither
   preference block uses !important — the reduced-data block cancels the search
   glow, chip halos and flyway animation purely on order.

   SIX @keyframes live upstream and must NOT be swept in here: flywayDrift and
   flywayDriftNorth (02), eyeBlink (11), flywayLampBreath (13), plus flywayFlight
   and flywayFlightNorth, which are nested inside the @supports in 02-flyway.css.

   Every nonessential animation needs a prefers-reduced-motion fallback.
   Continuous ambient animation stays rare.

   RULES · style/motion.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */

@keyframes headingRainbowFlow {
  from { background-position: 0% 0%; }
  to { background-position: 100% 0%; }
}

@keyframes starDrift {
  from { transform: translate3d(-1.5rem, -0.8rem, 0); }
  to { transform: translate3d(2rem, 1.3rem, 0); }
}

@keyframes starTwinkle {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 0.82; }
}

/* One turn of the celestial sphere. 86164s is the sidereal day — the time the
   sky takes to come back round, which is four minutes short of the solar one
   because the Earth has moved along its orbit meanwhile. The sign is negative
   because the sky runs counter-clockwise about Polaris. */
@keyframes skyTurn {
  to { transform: rotate(-360deg); }
}

@keyframes farStarDrift {
  from { transform: translate3d(-1rem, -0.5rem, 0); }
  to { transform: translate3d(1.5rem, 1rem, 0); }
}

@keyframes nearStarDrift {
  from { transform: translate3d(1.25rem, -1rem, 0); }
  to { transform: translate3d(-2rem, 1.5rem, 0); }
}

@keyframes farStarTwinkle {
  0%, 100% { opacity: 0.34; }
  50% { opacity: 0.52; }
}

@keyframes nearStarTwinkle {
  0%, 100% { opacity: 0.48; }
  50% { opacity: 0.78; }
}

@keyframes mouseFireworkSpark {
  0% { opacity: 0; transform: translate3d(0, 0, 0) rotate(var(--spark-angle)) scale(0.3); }
  2% { opacity: 1; transform: translate3d(0, 0, 0) rotate(var(--spark-angle)) scale(1.2); }
  8% { opacity: 1; transform: translate3d(var(--spark-x-1), var(--spark-y-1), 0) rotate(var(--spark-angle)) scale(1.05); }
  16% { opacity: 1; transform: translate3d(var(--spark-x-2), var(--spark-y-2), 0) rotate(var(--spark-angle)) scale(0.94); }
  28% { opacity: 0.96; transform: translate3d(var(--spark-x-3), var(--spark-y-3), 0) rotate(var(--spark-angle)) scale(0.82); }
  42% { opacity: 0.86; transform: translate3d(var(--spark-x-4), var(--spark-y-4), 0) rotate(var(--spark-angle)) scale(0.66); }
  58% { opacity: 0.68; transform: translate3d(var(--spark-x-5), var(--spark-y-5), 0) rotate(42deg) scale(0.5); }
  76% { opacity: 0.4; transform: translate3d(var(--spark-x-6), var(--spark-y-6), 0) rotate(18deg) scale(0.32); }
  100% { opacity: 0; transform: translate3d(var(--spark-x-7), var(--spark-y-7), 0) rotate(0deg) scale(0.12); }
}

@keyframes mouseFireworkTrail {
  0% { opacity: 0; stroke-dashoffset: 1; stroke-width: 1; transform: translate3d(0, 0, 0); }
  10% { opacity: 0.34; stroke-dashoffset: 0; stroke-width: 2; }
  38% { opacity: 0.22; stroke-dashoffset: 0; stroke-width: 4; transform: translate3d(0, 10px, 0); }
  70% { opacity: 0.07; stroke-width: 8; transform: translate3d(0, var(--trail-fall), 0); }
  100% { opacity: 0; stroke-width: 13; transform: translate3d(0, calc(var(--trail-fall) + 28px), 0); }
}

@keyframes mouseFireworkSmoke {
  0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.22); }
  8% { opacity: 0.4; transform: translate3d(0, 5px, 0) scale(var(--smoke-size)); }
  28% { opacity: 0.24; transform: translate3d(var(--smoke-x-early), var(--smoke-y-early), 0) scale(2.6); }
  52% { opacity: 0.1; transform: translate3d(var(--smoke-x-mid), var(--smoke-y-mid), 0) scale(5.2); }
  76% { opacity: 0.025; transform: translate3d(var(--smoke-x), var(--smoke-y-late), 0) scale(7.3); }
  100% { opacity: 0; transform: translate3d(var(--smoke-x), var(--smoke-fall), 0) scale(8.8); }
}

@keyframes brandFloat {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50% { transform: translate3d(0, -7px, 0); }
}

@keyframes terminalBlink {
  0%, 45% { opacity: 1; }
  46%, 100% { opacity: 0; }
}

@keyframes terminalSweep {
  0%, 18% { opacity: 0; transform: skewX(-18deg) translate3d(0, 0, 0); }
  28% { opacity: 0.8; }
  55%, 100% { opacity: 0; transform: skewX(-18deg) translate3d(590%, 0, 0); }
}

/* Named for the colour it used to animate — hotpink, #FF69B4. The keyframe now
   pulses --link-hover (#FF465A, 2026-08-14) instead; the name is stale and left
   alone rather than renamed workspace-wide for one cosmetic accuracy fix. */
@keyframes linkHotpinkGlow {
  from { text-shadow: 0 0 6px rgba(var(--link-hover-glow), 0.74), 0 0 12px rgba(var(--link-hover-glow), 0.52), 0 0 22px rgba(var(--link-hover-glow), 0.28); }
  to { text-shadow: 0 0 9px rgba(var(--link-hover-glow), 0.96), 0 0 20px rgba(var(--link-hover-glow), 0.82), 0 0 32px rgba(var(--link-hover-glow), 0.46); }
}

@media (max-width: 800px) {
  body { font-size: 15px; }
  header { padding: 0.9rem 1rem; }
  main { width: min(100% - 1rem, 1080px); padding: 1rem 0.5rem; }
  .hero { grid-template-columns: 1fr; }
  /* The card tightens up rather than changing shape: the corner gutter, the
     corner marks and the numeral all shrink together so the struck head and
     foot still read at phone width instead of crowding the trail. (This
     comment used to say "double frame". There has not been a frame since the
     three nested rectangles came off — see .breadcrumb-nav.) */
  .breadcrumb-nav { padding: 0.68rem 0.7rem; border-radius: 0.7rem; gap: 0.5rem; }
  /* Both marks shrink: the four pips via background-size, the four compass
     sweeps via the radius token they were built to read. */
  .breadcrumb-nav::after {
    inset: 0.28rem;
    --card-corner-r: 6.5px;
    background-size:
      3px 3px, 3px 3px, 3px 3px, 3px 3px,
      16px 16px, 16px 16px, 16px 16px, 16px 16px;
  }
  /* THE SEAL COMES OFF THE PHONE CARD, and it is worth saying why rather than
     leaving this looking like an oversight.

     At this width the card stops being a card and stacks into a column: dial,
     then chips, then the trail, then the instruments lying down along the
     foot. The astrolabe is then centred and nearly the full width of the face
     — so a second ring, struck concentric with it and only a little larger,
     lands as a halo round the dial and its triangle crosses the chip row.
     Two rings of the same size at the same centre is noise, not depth.

     Nothing is lost by dropping it: the emblem's whole job is to make the card
     read as an occult object at a glance, and at phone width the dial already
     does that at four times the size it manages on the desktop card. This is
     the same call the rest of the card makes at this breakpoint — the trail
     gives up crumbs, the numeral gives up letter-spacing. The seal gives up
     the mark. */
  .breadcrumb-main > .tarot-card__sigil { display: none; }

  /* And the eye goes with it, because the eye is set in the seal's triangle and
     an eye floating where a pyramid used to be is a worse mark than no mark.
     This does cost something real, and it should be said rather than buried:
     the colour vision filters have no door at phone width. If they need one
     there it belongs in /settings, which is reachable at every size, not on a
     rail the reader would have to be told about. */
  .breadcrumb-main > .tarot-eye { display: none; }
  .breadcrumbs {
    font-size: 0.7rem;
    letter-spacing: 0.06em;
    padding-top: 0.44rem;
  }
  /* Still one row on a phone. Five names sharing a 320px card means each is
     mostly ellipsis, so the cap comes down to something a thumb can still
     read: the last two or three survive at a usable width and the rest
     compress away, rather than all five becoming a row of dots. */
  .breadcrumbs a { max-width: 7rem; }
  .breadcrumb-sep { margin: 0 0.24rem; }
  .zodiac-clock-wrap { width: 5.6rem; height: 5.6rem; }
  .tarot-card__numeral { font-size: 0.56rem; gap: 0.44rem; letter-spacing: 0.14em; }
  .tarot-card__numeral::before, .tarot-card__numeral::after { max-width: 2.4rem; }
  .celestial-row { gap: 0.45rem 0.6rem; }
  .celestial-strip { justify-content: flex-start; }
  .celestial-chip { min-width: 2.4rem; padding: 0.26rem 0.42rem; }
  .celestial-chip__glyph { font-size: 0.94rem; }

  /* The strip gives up size, never seats. Twelve marks at 1rem with these gaps
     came to about 245px and the whole year fitted across a 320px card; twenty
     come to about 405px and it does not, so at this width the row becomes the
     horizontal scroller .passage-strip was always built as — nowrap, overflow-x
     auto, and justify-content flipped to flex-start below so it starts at the
     birds rather than centring on the flowers.

     THAT IS THE RIGHT TRADE AND NOT A REGRESSION TO FIX BY DROPPING MARKS. A
     seat that disappears on a phone is a gauge that reports nothing on a phone,
     and the marks most likely to be cut for width — the mushrooms, at the far
     end — are the only ones lit in January. Shrinking below 1rem is not
     available either: these are line drawings, and at 14px the morel's pits and
     the chanterelle's ridges close up into a blot. */
  .passage-strip { gap: 0.26rem; justify-content: flex-start; }
  .passage-mark { width: 1rem; height: 1rem; }
  .passage-link[data-passage-seam],
  .passage-mark[data-passage-seam] { margin-left: 0.4rem; }

  /* Narrow viewports: the card stacks and the two instruments lie down into a
     row along the foot, under the trail, instead of standing in a margin the
     card no longer has. Children stretch so the chip strip stays inside the
     bar rather than sizing the column to max-content and bleeding past the
     frame. */
  .breadcrumb-nav { flex-direction: column; align-items: stretch; }
  .instrument-rail {
    flex-direction: row;
    justify-content: center;
    gap: 0.9rem;
    align-self: auto;
    padding-left: 0;
  }

  /* /settings at phone width: one card per row rather than two half-legible
     ones, and the swatch keeps its size because the swatch is the content. */
  .theme-choices { grid-template-columns: 1fr; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }

  .night-sky::before, .night-sky__stars, .night-sky__constellations, .mouse-firework__spark, .mouse-firework__trail, .mouse-firework__smoke, .brand a, .brand a::after, .breadcrumb-nav::before, .markdown-page .heading-section__trigger {
    animation: none !important;
    transform: none;
  }

  .breadcrumb-nav::before { display: none; }

  .night-sky__fireworks { display: none; }

  /* The flyway map keeps every fact and loses only the travel. The corridors
     stay drawn, the season still picks the colour, and the grounds still light
     the hemisphere the birds are on — all of that is final-value styling, not
     keyframes, so it survives the blanket rule above intact. What goes is the
     drift down the dashes and the birds riding them, which is the one part
     that was motion for its own sake. The birds go rather than freeze: four
     chevrons stopped dead mid-continent read as a rendering fault, where their
     absence reads as nothing at all. */
  .flyways__line { animation: none !important; }

  .flyways__bird { display: none !important; }

  /* The lamp stops breathing and holds at its steady burn. Nothing is lost:
     the brightness IS the reading, and the pulse was only ever a second way of
     saying the same thing. */
  .flyway-lamp .instrument__svg { animation: none !important; }

  /* The astrolabe keeps telling time via hour/minute hands, but the
     once-per-second sweep hand is retired as nonessential motion. */
  .zodiac-clock .zc-hand--second { display: none; }

  /* The search bar needs no entry of its own. Every open-state declaration —
     the bar's width, the field's gutter and colour, the input's opacity — is a
     final value rather than a keyframe, so the blanket transition-duration
     above snaps the collapsed square straight to the open bar with the end
     state intact. Nothing is hidden behind the motion. */

  /* Zodiac chips: the glyph halo and the label reveal still happen, they just
     snap instead of fading, and the chip stops lifting. The end state of every
     hover/focus is preserved — the label must never become unreachable here. */
  .celestial-chip, .celestial-chip__glyph, .celestial-chip__sys, .celestial-moon {
    transition: none;
  }

  .celestial-chip:hover, .celestial-chip:focus-visible { transform: none; }

  /* The instruments keep every end state and lose only the travel. The knob
     still sits at the end of the track that matches the mode — it jumps there
     instead of sliding — and the gear stops taking its quarter-tooth step,
     which was mechanism flavour rather than information. Nothing here is
     hidden behind the motion; the switch reads correctly on the first frame.
     These are month-proof at (0,2,0) for the same reason the rules they
     cancel are. */
  .instrument.instrument.instrument,
  .daynight-switch__knob,
  .instrument-mark,
  .instrument-gear .ig-wheel,
  .instrument-gear .ig-bore {
    transition: none;
  }

  .instrument-gear:hover .ig-wheel,
  .instrument-gear:focus-visible .ig-wheel { transform: none; }

  a:hover, a:focus-visible { animation: none; }
}

@media (prefers-reduced-data: reduce) {
  .night-sky::before, .night-sky__stars, .night-sky__constellations, .mouse-firework__spark, .mouse-firework__trail, .mouse-firework__smoke, .brand a, .brand a::after, .breadcrumb-nav::before, .markdown-page .heading-section__trigger {
    animation: none;
  }

  .breadcrumb-nav::before { display: none; }

  .night-sky__fireworks { display: none; }

  /* Same trade as reduced motion, for a different reason: the map is already
     on the wire and costs nothing more to show, but sixteen chevrons each
     running its own compositor animation is real work on a machine that has
     asked for less of it. The map stays; the traffic stops. */
  .flyways__line { animation: none; }

  .flyways__bird { display: none; }

  /* The search bar's open state keeps its border, fill and icon colour — those
     are the affordance — and drops only the two-stop glow, which is the part
     that costs a large blurred repaint every time the pointer crosses it. */
  .site-search:hover .site-search__field,
  .site-search:focus-within .site-search__field,
  .site-search--open .site-search__field { box-shadow: none; }

  /* Cheapest possible chip hover: colour and the label reveal only. The
     multi-stop glyph halo is the one part of this that costs paint, so hover
     drops it entirely. Keyboard focus keeps a single tight stop — it is the
     focus indicator, and there is no outline ring standing behind it. */
  .celestial-chip:hover .celestial-chip__glyph { text-shadow: none; }

  .celestial-chip:focus-visible .celestial-chip__glyph {
    text-shadow: 0 0 4px var(--zodiac-glow);
  }

  .celestial-chip:hover .celestial-moon { filter: none; }

  .celestial-chip:focus-visible .celestial-moon {
    filter: drop-shadow(0 0 4px var(--zodiac-glow));
  }

  a:hover, a:focus-visible { animation: none; }
}
/* ==========================================================================
   17-embeds.css — THE FOUR THINGS A PAGE EMBEDS BY COMMENT

   The calendar grid and its export panel, the reader quiz, the pinned Leaflet map
   with its chrome and popups, and the table filter.

   ORDER AND LOCKS
   All four opt out of the base field, button and img rules in 09-elements.css and
   15-home-and-settings.css, so this file must follow both. `tr[hidden]` only
   works because `table { display: block }` came earlier, and the map's img reset
   only works because the border sweep came earlier. Each component's own 700px
   query must stay last within its section — do not hoist them into 16-motion.css.

   The Leaflet map's contract is .github/agents/maps/marker.md, which already
   names `.map-app` in this sheet. The calendar's is calendar.agent.md, the quiz's
   is user.agent.md. Do not open a rival home for any of them here.

   RULES · maps/marker.md · calendar.agent.md · user.agent.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */


/* ---------------------------------------------------------------------------
   Calendar — server-rendered month grid driven by markdown front matter dates.
   Injected by the <!-- calendar --> marker in main.js.
   --------------------------------------------------------------------------- */

.calendar-app {
  margin: 1.6rem 0 2.2rem;
  display: block;
}

.calendar-app__toolbar {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-bottom: 0.9rem;
}

.calendar-app__title {
  margin: 0;
  flex: 1 1 auto;
  min-width: 8rem;
  font-family: var(--font-heading);
  font-size: 1.35rem;
  letter-spacing: 0.02em;
  color: var(--text);
}

/* Caption for a filtered grid, e.g. <!-- calendar:holidays -->. */
.calendar-app__scope {
  margin: -0.4rem 0 0.9rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  color: var(--muted);
}

.calendar-app__nav,
.calendar-app__today {
  font-family: var(--font-mono);
  font-size: 0.86rem;
  line-height: 1;
  padding: 0.42rem 0.72rem;
  background: var(--surface);
  color: var(--text-soft);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  cursor: pointer;
}

.calendar-app__nav:hover,
.calendar-app__today:hover,
.calendar-app__nav:focus-visible,
.calendar-app__today:focus-visible {
  color: var(--text);
  background: rgba(176, 214, 143, 0.12);
}

.calendar-grid {
  display: block;
  overflow-x: auto;
}

.calendar-grid__weekdays,
.calendar-grid__days {
  display: grid;
  grid-template-columns: repeat(7, minmax(6.2rem, 1fr));
  gap: 1px;
  min-width: 43rem;
}

.calendar-grid__weekday {
  padding: 0.4rem 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.calendar-grid__days {
  background: var(--border-dim);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
}

.calendar-day {
  position: relative;
  min-height: 6.4rem;
  padding: 0.34rem 0.42rem 0.5rem;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  gap: 0.28rem;
}

.calendar-day--outside {
  opacity: 0.38;
}

.calendar-day--today {
  background: rgba(255, 230, 28, 0.09);
  box-shadow: inset 0 0 0 1px var(--tt-yellow);
}

.calendar-day__number {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--muted);
}

.calendar-day--today .calendar-day__number {
  color: var(--tt-yellow);
  font-weight: 700;
}

.calendar-day__events {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

/* A list anchor — one of the only two places in the stylesheet allowed a left
   border. The rule doubles as the category colour swatch. */
.calendar-event__link {
  display: block;
  padding: 0.14rem 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1.28;
  text-decoration: none;
  color: var(--tt-cyan-soft);
  border-left: 2px solid var(--tt-cyan);
  background: rgba(176, 214, 143, 0.08);
  overflow-wrap: anywhere;
}

.calendar-event__link:hover,
.calendar-event__link:focus-visible {
  color: var(--tt-yellow);
  background: rgba(255, 230, 28, 0.12);
}

.calendar-event__time {
  color: var(--muted);
}

/* Category accents reuse the existing terminal palette. */
.calendar-event[data-category="running"] .calendar-event__link { border-left-color: var(--tt-orange); background: rgba(254, 128, 25, 0.1); }

.calendar-event[data-category="hiking"] .calendar-event__link { border-left-color: var(--tt-green); background: rgba(163, 255, 140, 0.1); }

.calendar-event[data-category="food"] .calendar-event__link { border-left-color: var(--tt-yellow); background: rgba(255, 230, 28, 0.1); }

.calendar-event[data-category="growing"] .calendar-event__link,
.calendar-event[data-category="gardening"] .calendar-event__link { border-left-color: var(--tt-chartreuse); background: rgba(127, 255, 0, 0.1); }

.calendar-event[data-category="observance"] .calendar-event__link { border-left-color: var(--tt-purple); background: rgba(190, 103, 65, 0.12); }

.calendar-agenda {
  margin-top: 1.4rem;
}

.calendar-agenda__heading {
  margin: 0 0 0.6rem;
  font-family: var(--font-heading);
  font-size: 1.05rem;
  color: var(--text);
}

.calendar-agenda__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.calendar-agenda__item {
  display: grid;
  grid-template-columns: minmax(6.5rem, auto) 1fr;
  gap: 0.2rem 0.9rem;
  align-items: baseline;
  padding: 0.34rem 0;
  border-bottom: 1px solid var(--border-dim);
}

.calendar-agenda__date {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--muted);
  white-space: nowrap;
}

.calendar-agenda__link {
  font-family: var(--font-text);
  font-size: 0.96rem;
}

.calendar-agenda__meta {
  grid-column: 2;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--muted);
}

.calendar-agenda__empty {
  font-family: var(--font-mono);
  font-size: 0.84rem;
  color: var(--muted);
}

@media (max-width: 700px) {
  .calendar-grid__weekdays,
  .calendar-grid__days { min-width: 34rem; grid-template-columns: repeat(7, minmax(4.6rem, 1fr)); }
  .calendar-day { min-height: 5rem; }
  .calendar-agenda__item { grid-template-columns: 1fr; }
  .calendar-agenda__meta { grid-column: 1; }
}

/* Calendar export — subscribe (live feed) and download (one-time .ics). */

.calendar-subscribe {
  margin-top: 1.6rem;
  padding: 0.95rem 1.05rem 1.05rem;
  background: var(--surface);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
}

.calendar-subscribe__heading {
  margin: 0 0 0.35rem;
  font-family: var(--font-heading);
  font-size: 1.02rem;
  color: var(--text);
}

.calendar-subscribe__lede {
  margin: 0 0 0.75rem;
  font-family: var(--font-text);
  font-size: 0.88rem;
  line-height: 1.5;
  color: var(--text-soft);
}

.calendar-subscribe__actions {
  list-style: none;
  margin: 0 0 0.7rem;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
}

/* Also a list anchor, so it keeps its left border. */
.calendar-subscribe__action {
  display: inline-block;
  padding: 0.4rem 0.8rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1;
  text-decoration: none;
  color: var(--tt-cyan-soft);
  background: rgba(176, 214, 143, 0.08);
  border-left: 2px solid var(--tt-cyan);
}

.calendar-subscribe__action:hover,
.calendar-subscribe__action:focus-visible {
  color: var(--tt-yellow);
  background: rgba(255, 230, 28, 0.14);
}

.calendar-subscribe__action--download {
  border-left-color: var(--tt-green);
  background: rgba(163, 255, 140, 0.1);
}

.calendar-subscribe__url {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: var(--muted);
  overflow-wrap: anywhere;
}

.calendar-subscribe__url code {
  padding: 0.1rem 0.3rem;
  font-size: inherit;
}

@media (max-width: 700px) {
  .calendar-subscribe__actions { flex-direction: column; align-items: stretch; }
  .calendar-subscribe__action { text-align: center; }
}

/* --- Reader profile quiz -------------------------------------------------
   Rendered by the `<!-- quiz -->` marker. The shell stays hidden until the
   script mounts, so a reader without JavaScript sees the noscript line rather
   than a dead form. Borders follow the site rule: rainbow border-image on
   controls, and border-left only on the result anchors, which are list links. */

.quiz-app {
  margin: 1.6rem 0 2.2rem;
  display: block;
}

.quiz-app__noscript {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--muted);
}

.quiz-app__toolbar {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-bottom: 0.4rem;
}

.quiz-app__title {
  margin: 0;
  flex: 1 1 auto;
  min-width: 8rem;
  font-family: var(--font-heading);
  font-size: 1.35rem;
  letter-spacing: 0.02em;
  color: var(--text);
}

.quiz-app__nav,
.quiz-app__submit,
.quiz-app__reset {
  font-family: var(--font-mono);
  font-size: 0.86rem;
  line-height: 1;
  padding: 0.42rem 0.72rem;
  background: var(--surface);
  color: var(--text-soft);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  cursor: pointer;
}

.quiz-app__nav:hover,
.quiz-app__submit:hover,
.quiz-app__reset:hover,
.quiz-app__nav:focus-visible,
.quiz-app__submit:focus-visible,
.quiz-app__reset:focus-visible {
  color: var(--text);
  background: rgba(176, 214, 143, 0.12);
}

.quiz-app__nav[disabled] {
  opacity: 0.35;
  cursor: default;
}

.quiz-app__progress,
.quiz-app__status {
  margin: 0 0 1rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  color: var(--muted);
}

.quiz-app__status:empty {
  margin: 0;
}

.quiz-step__blurb {
  margin: 0 0 1.1rem;
  color: var(--text-soft);
}

.quiz-question {
  margin: 0 0 1.6rem;
  padding: 0;
  border: 0;
}

.quiz-question__prompt {
  padding: 0;
  font-family: var(--font-heading);
  font-size: 1.05rem;
  color: var(--text);
}

.quiz-question__hint {
  margin: 0.35rem 0 0.6rem;
  font-size: 0.84rem;
  color: var(--muted);
}

.quiz-question__options {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.7rem;
}

.quiz-question__text {
  width: min(100%, 24rem);
  margin-top: 0.3rem;
  border-radius: 0;
  padding: 0.5rem 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.9rem;
  color: var(--text);
  background: var(--surface);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
}

.quiz-option {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 0.42rem;
  padding: 0.36rem 0.68rem;
  font-family: var(--font-mono);
  font-size: 0.84rem;
  line-height: 1.2;
  color: var(--text-soft);
  background: var(--surface);
  border: 1px solid var(--border);
  cursor: pointer;
}

/* A chip is one line. Left to wrap, a two-word cuisine turns into a three-line
   box and the row of chips loses its shape. */
.quiz-option__label {
  white-space: nowrap;
}

.quiz-option:hover,
.quiz-option:focus-within {
  color: var(--text);
  background: rgba(176, 214, 143, 0.12);
}

.quiz-option.is-selected {
  color: var(--text);
  background: rgba(255, 230, 28, 0.16);
  border-color: var(--tt-yellow);
}

/* Same opt-out the search field takes: the global input rule sizes every field
   at min(100%, 30rem) with a label gap, which turns a checkbox into a 95px bar
   and shoves its label into wrapping. */
.quiz-option__input {
  flex: 0 0 auto;
  width: 0.95rem;
  height: 0.95rem;
  min-width: 0;
  margin: 0;
  padding: 0;
  border-radius: 0;
  accent-color: var(--tt-yellow);
}

.quiz-app__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0.4rem 0 0.9rem;
}

.quiz-results {
  margin-top: 1.4rem;
}

.quiz-results__heading {
  margin: 0 0 0.6rem;
  font-family: var(--font-heading);
  font-size: 1.05rem;
  color: var(--text);
}

.quiz-results__empty {
  margin: 0 0 1rem;
  color: var(--muted);
}

.quiz-results__list {
  margin: 0 0 1.4rem;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  list-style: none;
}

/* A list anchor, so it takes the same left border the calendar's links do —
   that rail is what marks a link as a destination on this site. */
.quiz-results__list a {
  display: inline-block;
  padding: 0.4rem 0.8rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1;
  text-decoration: none;
  color: var(--tt-cyan-soft);
  background: rgba(176, 214, 143, 0.08);
  border-left: 2px solid var(--tt-cyan);
}

.quiz-results__list a:hover,
.quiz-results__list a:focus-visible {
  color: var(--tt-yellow);
  background: rgba(255, 230, 28, 0.14);
}

.quiz-results__picks {
  margin: 0;
  font-size: 0.88rem;
}

.quiz-results__picks dt {
  margin-top: 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.03em;
  color: var(--muted);
}

.quiz-results__picks dd {
  margin: 0.15rem 0 0;
  color: var(--text-soft);
}

@media (max-width: 700px) {
  .quiz-question__options { gap: 0.42rem; }
  .quiz-app__actions { flex-direction: column; align-items: stretch; }
}

/* --- Pinned maps ---------------------------------------------------------
   The `<!-- map: … -->` marker in main.js renders `.map-app`; public/map.js
   draws it with the vendored Leaflet. Leaflet ships its own stylesheet, so
   everything here is either the frame around the map or a theme override on
   top of it — hence the `.map-app` prefix on every Leaflet class below.

   The attribution control is deliberately styled rather than hidden: OSM's
   tile usage policy requires it to stay visible. -------------------------- */

.map-app {
  margin: 1.25rem auto;
  max-width: 900px;
}

.map-app__canvas {
  height: var(--map-height, 520px);
  border: 1px solid var(--border-dim);
  border-radius: 8px;
  overflow: hidden;
}

.map-app__caption,
.map-app__fallback {
  margin: 0.6rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.76rem;
  line-height: 1.5;
  color: var(--muted);
}

.map-app__fallback {
  color: var(--text-soft);
}

.map-app--problem {
  padding: 0.85rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--text);
  background: rgba(224, 85, 63, 0.12);
  border: 1px solid rgba(224, 85, 63, 0.6);
  border-radius: 8px;
}

.map-app .leaflet-container {
  font-family: var(--font-text);
  background: var(--bg);
}

/* Leaflet builds tiles, marker icons and control glyphs out of <img>, and every
   image inside a page carries this site's neon border. Left alone, that draws a
   lit border around all 200-odd tiles — a glowing grid ruled over the world.
   Sizing needs no defence here: leaflet.css already answers the global
   `img, video, svg` rule with its own `!important` on every pane. */
.markdown-page .map-app img,
.map-app img {
  border: 0;
  border-image: none;
  border-radius: 0;
}

.map-app .leaflet-control-attribution,
.map-app .leaflet-control-scale-line {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: #201b12;
  background: rgba(242, 230, 210, 0.82);
}

.map-app .leaflet-control-attribution a {
  color: #474a30;
}

/* Map chrome sits on top of OSM tiles, not on the page, so it keeps a light
   panel and dark ink in every month rather than taking the theme palette. Some
   months set a translucent `--surface`, and a translucent legend over a pale
   basemap is a legend nobody can read.

   The colour is restated on the label because the site's own `label { color }`
   — and several months' overrides of it — otherwise reach inside the control. */
.map-app .leaflet-control-layers {
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: #201b12;
  background: rgba(242, 230, 210, 0.92);
  border: 1px solid rgba(32, 27, 18, 0.32);
}

.map-app .leaflet-control-layers label {
  margin: 0.2rem 0;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: #201b12;
  white-space: nowrap;
  cursor: pointer;
}

/* The site dresses every `input` as a text field — 30rem wide, padded, neon
   bordered. Applied to the layer control's checkboxes that is a full-width slab
   per legend row, which shoves the swatch and its label clean out of the box. */
.map-app .leaflet-control-layers input[type="checkbox"],
.map-app .leaflet-control-layers input[type="radio"] {
  width: auto;
  margin: 0 0.3rem 0 0;
  padding: 0;
  border: 0;
  border-image: none;
  border-radius: 0;
  background: none;
  vertical-align: middle;
  accent-color: var(--border);
}

.map-legend__swatch {
  display: inline-block;
  width: 0.62rem;
  height: 0.62rem;
  margin-right: 0.35rem;
  vertical-align: middle;
  border: 1px solid rgba(0, 0, 0, 0.55);
  border-radius: 50%;
}

.map-app .leaflet-bar a {
  color: #201b12;
}

.map-app .leaflet-popup-content-wrapper,
.map-app .leaflet-popup-tip {
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border-dim);
}

.map-app .leaflet-popup-content {
  margin: 0.7rem 0.85rem;
  font-size: 0.82rem;
  line-height: 1.5;
}

.map-popup__title {
  margin: 0 0 0.2rem;
  font-family: var(--font-heading);
  font-size: 0.95rem;
  color: var(--text);
}

.map-popup__title a {
  color: var(--tt-cyan-soft);
}

.map-popup__group {
  margin: 0 0 0.45rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

.map-popup__facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.1rem 0.6rem;
  margin: 0;
}

.map-popup__facts dt {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--muted);
}

.map-popup__facts dd {
  margin: 0;
  color: var(--text-soft);
}

@media (max-width: 700px) {
  .map-app__canvas { height: min(var(--map-height, 520px), 68vh); }
}

/* The `<!-- table-filter -->` control strip. Structure only — the borders come
   from the shared neon strip rule above, and nothing here uses border-left,
   which belongs to list anchors. Per-month colour lives in themes/*.css. */
.table-filter {
  display: block;
  margin: 1.6rem 0 0.4rem;
}

.table-filter__legend {
  margin: 0 0 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  text-transform: lowercase;
  color: var(--muted);
}

.table-filter__controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.6rem;
}

.table-filter__field {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex: 1 1 18rem;
  min-width: 0;
}

.table-filter__name {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--muted);
}

.table-filter__range {
  flex: 1 1 auto;
  width: auto;
  min-width: 6rem;
  margin-top: 0;
  padding: 0;
  border: 0;
  background: transparent;
  accent-color: var(--tt-chartreuse);
  cursor: pointer;
}

.table-filter__value {
  flex: 0 0 auto;
  min-width: 6.5rem;
  text-align: right;
  font-family: var(--font-mono);
  font-size: 0.86rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.table-filter__footer {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  flex-wrap: wrap;
  margin-top: 0.7rem;
}

.table-filter__count {
  margin: 0;
  flex: 1 1 auto;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  color: var(--muted);
}

.table-filter__reset {
  font-family: var(--font-mono);
  font-size: 0.86rem;
  line-height: 1;
  padding: 0.42rem 0.72rem;
  background: var(--surface);
  color: var(--text-soft);
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  cursor: pointer;
}

.table-filter__reset:hover,
.table-filter__reset:focus-visible {
  color: var(--text);
  background: rgba(176, 214, 143, 0.12);
}

.table-filter__reset[disabled] {
  opacity: 0.35;
  cursor: default;
}

/* `table { display: block }` above leaves rows as `table-row`, so the filter's
   `hidden` needs saying out loud. */
tr[hidden] {
  display: none;
}

@media (max-width: 700px) {
  .table-filter__field { flex: 1 1 100%; }
}
/* ==========================================================================
   18-note-foot.css — THE THREE THINGS HUNG ON THE FOOT OF A NOTE

   The tag chip row seated on the foot of the breadcrumb card, the Related
   disclosure block and its tag groups, and the PDF/Word download offer.

   ORDER AND LOCKS
   The Related headings are written at (0,3,1)/(0,4,1) purely to out-specify
   `main h1…h6` in 08-headings.css, so this file must follow it.
   `.related__list { text-align: left }` likewise has to follow the dead-centre
   list rules in 09-elements.css.

   Keep Related and downloads in one file — the 700px query styles both, and
   splitting them strands the mobile tracking override.

   THE RELATED BLOCK, AND ONLY THE RELATED BLOCK, OPENS ON A CLICK. It is a
   native closed <details>; Markdown headings keep their hover reveal. Do not
   generalise click-only to the headings, and do not add `open` to `.related`.

   The tag row belongs to the breadcrumb card by seating but is pinned here by
   source order. These are TAG chips, not the zodiac chips in 14.

   RULES · style/note-foot.md · style/breadcrumb.md (the tag row)
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */


/* ---------------------------------------------------------------------------
   Tag row
   ---------------------------------------------------------------------------
   `main.js` emits one of these from the note's `tags:`. Each chip links to that
   tag's page under /tags.

   THE ROW IS LOCKED TO THE FOOT OF THE BREADCRUMB BAR (2026-08-15, user
   instruction), and it lives in <header> as a SIBLING of .breadcrumb-nav — see
   the markup note in renderHtmlPage(). Three declarations do the seating and
   all three are load bearing:

     - the negative top margin cancels <header>'s own stack gap exactly, so the
       chips touch the card's bottom edge instead of floating below it;
     - `width: 100%` with the card's own `max-width: 64rem` and auto inline
       margins puts the row on the card's footprint rather than the header's,
       so the chips cannot run wider than the object they hang from;
     - `margin-bottom: 0` — the header's own padding is the space beneath, and
       a bottom margin here would push <main> down for tagged pages only.

   `justify-content: center` is the row's alignment: the card is centred, so a
   row hung off its foot that started at the left edge would read as belonging
   to the page under it rather than to the card above it.

   Chips are boxed on all four sides, deliberately: `border-left` alone is the
   list-anchor signal and must not appear anywhere else, so a chip either wears
   a full border or none. Per-month colour belongs in themes/*.css; this rule
   is structure and the July base only. */
.tag-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.4rem;
  width: 100%;
  max-width: 64rem;
  margin: calc(-1 * var(--header-stack-gap, 0px)) auto 0;
  padding: 0;
}

.tag-row__tag {
  display: inline-block;
  padding: 0.16rem 0.55rem;
  border: 1px solid var(--border-dim);
  background: transparent;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1.5;
  letter-spacing: 0.03em;
  text-transform: lowercase;
  text-decoration: none;
  color: var(--muted);
  transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease;
}

.tag-row__tag:hover,
.tag-row__tag:focus-visible {
  color: var(--text);
  border-color: var(--border);
  background: rgba(176, 214, 143, 0.1);
}

/* No margin change here. The row is seated against the card by a margin that
   has to stay cancelled at every width — see the seating note above. */
@media (max-width: 700px) {
  .tag-row { gap: 0.3rem; }
  .tag-row__tag { font-size: 0.7rem; }
}

/* ---- Related --------------------------------------------------------------

   Opens every page that is not a portal, directly under the tag row: the most
   popular pages sharing this page's tags, grouped under the tag that earned
   them. Built in main.js by renderRelatedSection(). The chips are always above
   it — renderPage() emits them first whatever RELATED_BLOCK_PLACEMENT says.

   ITS HEADING IS AN H1 AND MUST NOT LOOK LIKE ONE. A rendered Markdown h1 is
   the page's own name — the big cursive line — and Related is not the page's
   name, it is the sign over the door next to it. Two h1s in the same face
   would read as two titles arguing about which page this is.

   So the face changes and the lighting does not. The type is the site's
   instrument stack — mono, uppercase, widely tracked, the same voice the
   breadcrumbs, the time bar, the tag chips and the table-of-contents title
   speak in — while the rainbow text gradient inherited from `main h1` stays
   exactly as it is. It reads as this site's furniture rather than as a second
   title, and nothing here re-points --font-cursive, so the wordmark lock is
   untouched.

   The tag headings under it are h2 and take the same treatment one step down:
   same mono, smaller, and solid in the month accent rather than gradient, so
   the block's own hierarchy is legible without ever competing with the
   markdown headings below it.
   --------------------------------------------------------------------------- */

.related {
  margin: 0 0 2.4rem;
  padding: 0 0 1.6rem;
}

/* SPECIFICITY, DELIBERATELY. Eight month sheets restyle `main h1` and `main h2`
   at up to (0,2,3) — face, size, tracking, and in January, March and November a
   painted scene behind the glyphs. Every one of those rules is written for the
   page's own title, and every one of them would land here too and undo the one
   thing this heading exists to do: not look like the page's title. So the type
   is carried at (0,3,1) and up, which no month sheet reaches, and the theme's
   decorative ::after is switched off rather than left to double up with the
   border below. (The default sheet's tapered card rule was the case that move
   was written for; that rule is gone as of 2026-08-14, and the default sheet now
   clears this border too at (0,4,1) — the guard stays for the month sheets.)

   The LIGHTING is deliberately NOT defended. `text-shadow` and the flow
   animation are left exactly where the theme put them, so The Struck Card's
   stopped gradient stays stopped and the neon months keep their glow. Only the
   background is restated, from the same `--tt-heading-text-rainbow` token every
   sheet re-points, because a January hillside painted behind a 0.34em-tracked
   label is scenery for a title three times its size. */
.markdown-page .related h1.related__heading {
  margin: 0 0 1.3rem;
  padding-bottom: 0.55rem;
  border: 0 solid transparent;
  border-bottom-width: 1px;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--tt-heading-text-rainbow);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  font-family: var(--font-mono);
  font-style: normal;
  font-size: clamp(1.05rem, 2.4vw, 1.5rem);
  font-weight: 600;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  /* RIGHT, NOT LEFT (2026-08-15, user instruction). Everything else on a note
     starts at the left margin, so a label set against the right edge reads as
     the far end of the page rather than as the start of another column of
     prose. The heading stays a full-width block so the rule under it — on every
     sheet that keeps one; the default clears it below — still runs the whole
     measure, because that line is what separates the note from the
     neighbourhood and an inline-block shrunk to the word would cut it down to
     eight characters. Nothing inside the block follows the heading over: the
     groups live in `.related__body`, a sibling of the <summary> this heading
     sits in, so they never inherit from it and stay a left-flush column of
     scannable titles. */
  text-align: right;
}

.markdown-page .related h1.related__heading::after,
.markdown-page .related h1.related__heading::before {
  content: none;
}

/* CLICK ONLY, AND ONLY HERE (2026-08-15, user instruction). `.related` is a
   native `<details>` rendered without `open`, so the block is shut on load and
   the reader's own click is the one thing that unrolls it. Markdown headings
   are NOT this — they keep the hover reveal the `.heading-section` system has
   always had, and none of that machinery is involved down here.

   The native disclosure marker is hidden the same way `.heading-section__summary`
   hides its own: no triangle anywhere on this site. The heading's rainbow rule
   is the mark that says a section lives here. */
.related__summary {
  list-style: none;
  cursor: pointer;
}

.related__summary::-webkit-details-marker {
  display: none;
}

/* A shut block carries no interior spacing. The heading's 1.3rem is the gap
   before the groups and the block's 1.6rem is the gap under them, so both are
   space held open for content that is not on screen — without this, a closed
   Related sits in nearly three rems of nothing under the tag row. (0,4,1) and
   (0,2,0) respectively, each one step above the rule it answers. */
.markdown-page .related:not([open]) h1.related__heading {
  margin-bottom: 0;
}

.related:not([open]) {
  padding-bottom: 0;
}

.related__groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
  gap: 1.1rem 1.6rem;
}

.related__group {
  min-width: 0;
}

/* Solid, not gradient: the shared heading rule clips the text to a background
   and paints the glyphs transparent, so a colour here needs the fill handed
   back as well. The flow animation stops with it — a five-second rainbow cycle
   on eight small labels at once is noise, and the h1 above already carries the
   block's share of movement. Carried at (0,4,1) for the same reason the h1 is:
   a month sheet's `main h2` rule is written for a heading in the prose, not for
   a tag label in a component. */
.markdown-page .related .related__group h2.related__tag {
  margin: 0 0 0.5rem;
  background: none;
  -webkit-background-clip: border-box;
  background-clip: border-box;
  -webkit-text-fill-color: currentColor;
  color: var(--tt-cyan);
  animation: none;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  text-shadow: none;
}

.markdown-page .related .related__group h2.related__tag::after,
.markdown-page .related .related__group h2.related__tag::before {
  content: none;
}

.related__tag-link {
  color: inherit;
}

.related__list {
  margin: 0;
  padding: 0;
  list-style: none;
  /* Bare prose lists centre (see PROSE LISTS ARE DEAD CENTRE). This one is a
     component's list of links and stays flush left, where a scanned column of
     titles belongs. */
  text-align: left;
}

.related__item {
  margin: 0;
  padding: 0.1rem 0;
}

.related__link {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.86rem;
  line-height: 1.5;
  color: var(--text-soft);
}

.related__link:hover,
.related__link:focus-visible {
  color: var(--tt-pink);
}

/* ---- Download row ---------------------------------------------------------

   Every page can be taken away as a PDF or a Word document, both generated on
   request from the same Markdown the page renders from. It is the last thing
   on the page, under a hairline that closes the note off, and it is
   deliberately quiet: it is an offer, not an instruction.

   The rule is a `border-top`, not a `border-left` — a left border alone is the
   list-anchor signal and belongs to nothing else on the site.
   --------------------------------------------------------------------------- */

.page-downloads {
  margin: 2.6rem 0 0;
  padding-top: 1.1rem;
  border-top: 1px solid var(--border-dim);
}

/* CLICK ONLY, the same bargain `.page-toc` strikes: a native `<details>`
   emitted without `open`, so the formats are behind the word until the reader
   asks for them. No triangle — the marker goes the way every other summary's
   does on this site — and no hover reveal, because a pointer travelling down
   the page to the footer must not unroll the row on its way past. */
.page-downloads__label {
  display: inline-block;
  list-style: none;
  cursor: pointer;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  transition: color 120ms ease;
}

.page-downloads__label::-webkit-details-marker { display: none; }

.page-downloads__label:hover,
.page-downloads__label:focus-visible {
  color: var(--text);
}

/* The word carries the state, since there is no triangle to carry it: a plus
   while the row is shut, a minus once it is open. Held out of the accessible
   name — `<summary>` already announces expanded/collapsed to a screen reader,
   and a spoken "plus" on top of that is noise. */
.page-downloads__label::after {
  content: " +";
  opacity: 0.7;
}

.page-downloads__disclosure[open] > .page-downloads__label::after {
  content: " −";
}

.page-downloads__formats {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.45rem;
  margin-top: 0.75rem;
}

.page-downloads__link {
  display: inline-block;
  padding: 0.16rem 0.6rem;
  border: 1px solid var(--border-dim);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  line-height: 1.5;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease;
}

.page-downloads__link:hover,
.page-downloads__link:focus-visible {
  color: var(--text);
  border-color: var(--border);
  background: rgba(176, 214, 143, 0.1);
}

@media (max-width: 700px) {
  .related { margin-bottom: 1.8rem; }
  .markdown-page .related h1.related__heading { letter-spacing: 0.22em; }
  .related__groups { grid-template-columns: 1fr; gap: 0.9rem; }
  .page-downloads { margin-top: 1.9rem; padding-top: 0.9rem; }
  .page-downloads__formats { gap: 0.35rem; }
  .page-downloads__link { font-size: 0.68rem; }
}

/* No reduced-motion rule here on purpose: this heading is a `main h1`, and the
   shared block near the top of the sheet already stills every heading's flow
   for a reader who asked for less motion. */
/* ==========================================================================
   19-panels.css — THE BODY-LEVEL OVERLAYS

   The key-help modal, the world clock and its quadrupled table rules, and the
   colour-vision menu with the <html> filter mount it drives.

   ORDER AND LOCKS
   Emitted last, which is what makes it work. `.world-clock__table` reclaims
   display:table from the bare `table` rule in 09-elements.css, and its thead th
   beats fourteen month sheets. The tripled close buttons and .cvd-option undo the
   base pill button one declaration at a time.

   Keep key-help before world clock — the clock's cross never restates
   `.key-help__close-svg .kh-close`. Never let a minifier collapse the repeated
   class selectors: three repeats instead of four historically put a red band down
   the offset column.

   RULES · style/panels.md
   Built into public/main.css by scripts/build-css.mjs — edit here, not there.
   ========================================================================== */


/* ---- The keyboard panel ---------------------------------------------------

   The only modal surface on the site. Everything else that floats — the
   celestial tooltip, the search suggestions — sits beside the page and lets it
   carry on; this one covers it, because a list of keys is worth nothing if the
   keys are still being read by the page underneath.

   z-index 80 puts it over the tooltip's 60, which is the highest thing on the
   site, and under nothing. It is built by ensureKeyHelp() in main.js and lives
   as a direct child of <body>, so no `main`-scoped rule in main.css and no
   `body[data-theme-id] main …` rule in a month sheet reaches inside it — which
   is why the heading and the list below can be plain h2/h3/dl and stay plain.
   Buttons are the exception: the month sheets style bare `button`, so the close
   control needs the same tripled-class armour the instrument rail wears, and
   for the same reason. The rationale is written out in full on .instrument. */
.key-help {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.2rem;
}

/* Beats the .key-help rule above at (0,2,0), so `hidden` on the element is
   still honoured after display:flex has been set on the class. */
.key-help[hidden] { display: none; }

.key-help__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.62);
}

.key-help__panel {
  position: relative;
  width: min(30rem, 100%);
  max-height: calc(100vh - 2.4rem);
  overflow-y: auto;
  padding: 1.15rem 1.3rem 1.35rem;
  /* Boxed on all four sides, like the tooltip and the tag chips: border-left
     on its own is the list-anchor signal and appears nowhere else. */
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--bg);
  box-shadow: 0 0 34px rgba(0, 0, 0, 0.55);
}

.key-help__title {
  margin: 0 2.2rem 0.95rem 0;
  color: var(--tt-breadcrumb-text);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-shadow: 0 0 8px var(--tt-breadcrumb-glow);
}

.key-help__section {
  margin: 1.35rem 0 0.7rem;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* Keys in one column, meaning in the next. The keys column sizes to its widest
   cap rather than to a guess, so adding a longer key name later re-lays the
   whole list out correctly with no number to update here. */
.key-help__list {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 0.5rem 0.85rem;
  margin: 0;
  align-items: baseline;
}

.key-help__keys {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  margin: 0;
}

.key-help__what {
  margin: 0;
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  line-height: 1.5;
  letter-spacing: 0.01em;
}

.key-help__key {
  display: inline-block;
  min-width: 1.5rem;
  padding: 0.1rem 0.36rem;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  line-height: 1.5;
  text-align: center;
  white-space: nowrap;
}

.key-help__note {
  margin: 0.7rem 0 0;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.64rem;
  line-height: 1.55;
}

/* Tripled to (0,3,0) to clear `body[data-theme-id="…"] button` at (0,2,1) —
   october and november put a 2px pumpkin frame on every button, and the site's
   own chrome paints button borders with the header rainbow as a border-image.
   A border-image needs a width, so `border: 0` alone would leave it behind;
   both have to be said. Same arithmetic as .instrument, written out there. */
.key-help__close.key-help__close.key-help__close {
  position: absolute;
  top: 0.75rem;
  right: 0.8rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.7rem;
  height: 1.7rem;
  padding: 0.28rem;
  border: 0;
  border-image: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tt-gold);
  box-shadow: none;
  transform: none;
  cursor: pointer;
  transition: color 140ms ease;
}

.key-help__close.key-help__close.key-help__close:hover,
.key-help__close.key-help__close.key-help__close:focus-visible {
  color: var(--tt-breadcrumb-text);
  border: 0;
  box-shadow: none;
  transform: none;
}

.key-help__close-svg {
  display: block;
  width: 100%;
  height: 100%;
}

.key-help__close-svg .kh-close {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}

@media (max-width: 700px) {
  .key-help { padding: 0.8rem; }
  .key-help__panel { padding: 1rem 1.05rem 1.15rem; }
  .key-help__list { gap: 0.45rem 0.6rem; }
  .key-help__what { font-size: 0.68rem; }
}

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

/* ---- The world clock ------------------------------------------------------

   What the astrolabe on the card opens: thirty-eight UTC offsets, the hour
   running live in each, and the three readings that turn an offset into a
   place. Built by ensureWorldClock() in main.js.

   It is the keyboard panel's twin and deliberately so — same z-index band,
   same backdrop, same close mark, same focus trap — because a reader who has
   met one modal on this site should not have to learn a second. Only the
   contents differ: a table rather than a key list, so the panel is wider and
   its middle scrolls while the head and foot stay put.

   Like .key-help it hangs off <body>, out of reach of every `main`-scoped rule
   in this file and every `body[data-theme-id] main …` rule in a month sheet.
   The GLOBAL element rules still reach it, though, and those are the ones the
   table has to answer: `table { display: block }` up at the content defaults
   would break the columns, and `th { background: var(--tt-chartreuse) }` would
   put a chartreuse band across a panel that is meant to read as instrument
   brass. Both are re-stated below at (0,1,1), which clears an element selector
   outright. */
.world-clock {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.2rem;
}

.world-clock[hidden] { display: none; }

.world-clock__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.62);
}

/* A column, not a block: the head, the table, and the foot are three bands,
   and only the middle one is allowed to scroll. A single scrolling block would
   take the title and the sourcing note out of view exactly when a reader is
   deep in the rows and most likely to want them. */
.world-clock__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(44rem, 100%);
  max-height: calc(100vh - 2.4rem);
  padding: 1.15rem 1.3rem 1.25rem;
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--bg);
  box-shadow: 0 0 34px rgba(0, 0, 0, 0.55);
}

.world-clock__title {
  margin: 0 2.2rem 0.5rem 0;
  color: var(--tt-breadcrumb-text);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-shadow: 0 0 8px var(--tt-breadcrumb-glow);
}

.world-clock__lede {
  margin: 0 0 0.85rem;
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.68rem;
  line-height: 1.55;
}

.world-clock__scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  scrollbar-width: thin;
}

/* `display: table` is not a default here — it is an override of the content
   default at the top of this file, which makes every table a scrolling block.
   That is right for a wide table dropped into a vault note and wrong for this
   one, which has its own scroller around it. */
.world-clock__table {
  display: table;
  width: 100%;
  min-width: 30rem;
  margin: 0;
  border: 0;
  border-image: none;
  border-collapse: collapse;
  font-family: var(--font-mono);
}

/* EVERY CELL RULE FROM HERE DOWN IS QUADRUPLED, and it has to be.

   Fourteen month sheets write `body[data-theme-id="…"] th` and half of those
   write `body[data-theme-id="…"][data-mode="…"] th` as well — that second form
   is (0,3,1), which beats a doubled class and beats a tripled one on the tie
   the element count breaks. Four clears it. This is the same arithmetic as the
   chips' ANCHOR PROOFING block, run one step further because a table cell is
   the one piece of site furniture the months paint by element name.

   It is not cosmetic: those rules exist to put a solid --tt-red band behind a
   table header in a vault note, and under-armoured they laid that band down
   the offset column of this panel — red type on a red ground, the one column a
   reader cannot do without. Anything added below inherits the requirement. */
.world-clock__table.world-clock__table.world-clock__table.world-clock__table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: 0.3rem 0.6rem 0.42rem;
  border-bottom: 1px solid var(--border);
  /* Load bearing rather than decorative: a sticky row with a transparent
     ground shows the rows sliding under it. Thirty-eight rows is more than a
     screen, and a column of bare city names with the heading scrolled off is a
     list of trivia. */
  background: var(--bg);
  color: var(--tt-gold);
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-align: left;
}

.world-clock__table.world-clock__table.world-clock__table.world-clock__table td,
.world-clock__table.world-clock__table.world-clock__table.world-clock__table tbody th {
  padding: 0.26rem 0.6rem;
  border-bottom: 1px solid var(--border-dim);
  background: transparent;
  color: var(--text-soft);
  font-size: 0.7rem;
  font-weight: 400;
  line-height: 1.5;
  text-align: left;
  white-space: nowrap;
}

.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__offset {
  color: var(--tt-gold);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
}

/* Hour and weekday are one reading in two weights — the hour is what is being
   asked for, the day is the answer to the question it raises next, which on a
   table spanning twenty-six hours of offset is asked constantly. */
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__time,
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__city {
  color: var(--text);
}

/* The place names are links, but a table where two of five columns are inked
   link-blue reads as a form rather than a clock. They take the colour of the
   cell they sit in and declare themselves on the approach — underline on
   hover, gold on focus — which is enough for a column the reader is already
   pointing at. */
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__link {
  color: inherit;
  text-decoration: none;
  text-underline-offset: 0.18em;
}

/* NO UNDERLINE — standing user instruction, 2026-08-14, workspace-wide. This
   rule carried one until 2026-08-19; the gold is the substitute the instruction
   asks for, and it was already here. `.world-clock__link` is a real <a>
   (main.js builds it with createElement("a")), so the link rule reaches it.
   The `.mt-notice__back` underline in 12-language.css is NOT the same defect —
   that one is a <button>. */
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__link:hover,
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .world-clock__link:focus-visible {
  color: var(--tt-gold);
}

.world-clock__hour { font-variant-numeric: tabular-nums; }

.world-clock__day {
  margin-left: 0.45rem;
  color: var(--muted);
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* The reader's own row, marked in place. --tt-red is the anchor the whole card
   uses for "this one, now" — today's sign on the dial, the date in the numeral
   — so the row the reader is standing in is inked from the same pot. */
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .is-here th,
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .is-here td {
  background: var(--tt-bg-alt);
  color: var(--text);
}

.world-clock__table.world-clock__table.world-clock__table.world-clock__table .is-here .world-clock__offset,
.world-clock__table.world-clock__table.world-clock__table.world-clock__table .is-here .world-clock__time {
  color: var(--tt-red);
}

.world-clock__here {
  margin-left: 0.4rem;
  color: var(--tt-red);
  font-size: 0.55rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.world-clock__note {
  margin: 0.75rem 0 0;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.6;
}

.world-clock__more {
  margin-top: 0.5rem;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-decoration: none;
}

.world-clock__more:hover,
.world-clock__more:focus-visible { color: var(--tt-breadcrumb-text); }

/* Same tripled armour and the same reasons as .key-help__close, written out
   there and on .instrument before it. */
.world-clock__close.world-clock__close.world-clock__close {
  position: absolute;
  top: 0.75rem;
  right: 0.8rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.7rem;
  height: 1.7rem;
  padding: 0.28rem;
  border: 0;
  border-image: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tt-gold);
  box-shadow: none;
  transform: none;
  cursor: pointer;
  transition: color 140ms ease;
}

.world-clock__close.world-clock__close.world-clock__close:hover,
.world-clock__close.world-clock__close.world-clock__close:focus-visible {
  color: var(--tt-breadcrumb-text);
  border: 0;
  box-shadow: none;
  transform: none;
}

/* The close mark itself is the keyboard panel's — buildCloseMarkSvg() emits
   one cross for both, and .key-help__close-svg above already sizes and strokes
   it wherever it lands. Two crosses cut to two different specs on one site is
   how a vocabulary comes apart. */

@media (max-width: 700px) {
  .world-clock { padding: 0.8rem; }
  .world-clock__panel { padding: 1rem 1.05rem 1.1rem; }
  .world-clock__lede { font-size: 0.64rem; }
  .world-clock__table td,
  .world-clock__table tbody th { font-size: 0.66rem; padding: 0.24rem 0.45rem; }
}

@media (prefers-reduced-motion: reduce) {
  .world-clock__close.world-clock__close.world-clock__close { transition: none; }
  .zodiac-clock-button.zodiac-clock-button.zodiac-clock-button { transition: none; }
}

/* ---------------------------------------------------------------------------
   THE COLOUR VISION FILTER.

   Eight simulations of what this page does to an eye that cannot separate the
   colours it is painted in. The matrices, their sources and their working are
   in main.js under WHAT THE EYE SHOWS; the door to them is the eye on the
   instrument rail. This is the mount, and the mount is the interesting part.

   IT GOES ON <html>, AND IT CAN GO NOWHERE ELSE. Two reasons, both structural:

   Fixed furniture. A filtered element becomes the containing block for its
   fixed-position descendants — that is CSS, not a browser quirk — and this page
   hangs six fixed things off <body>: the scanline, the night sky, the search
   bar, the celestial tooltip, the keyboard panel and the world clock. Filter
   <body> and all six come unpinned and start scrolling with the article. The
   root element is the one documented exception to that rule, so filtering it
   leaves every one of them exactly where it was.

   The ground. body's background propagates to the canvas rather than painting
   on body's own box, so a filter on <body> would simulate the whole page except
   the surface it is printed on — the largest single area of colour on the
   screen, and the one a designer most needs to watch fail.

   The property is set by applyCvd() in main.js, which owns the eight filter
   ids because it owns the eight matrices they name. This rule is the only place
   in the sheet that reads it, and the fallback matters: a stored id whose
   filter never got minted resolves to `none` and paints the page normally
   rather than painting nothing at all. */
html[data-cvd] { filter: var(--cvd-filter, none); }

/* The <svg> the eight <filter> elements are minted into. It draws nothing and
   must occupy nothing — an SVG element is display:inline by default, and an
   inline box with no content still takes a line box and a line-height's worth
   of the document. */
.cvd-filters {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* ---------------------------------------------------------------------------
   The colour vision panel.

   The language menu's kind of object, not the keyboard panel's, and the reason
   is in what it is for: a reader opens this to watch the SITE change, so a
   modal backdrop over the thing being tested would be a window with the curtain
   drawn. No backdrop, no focus trap, no `has-` class on <body>. It stays open
   while choices are made, because the question it answers is always comparative
   — deuteranopia against protanopia, this heading against that one.

   It is filtered along with everything else, and that is correct rather than an
   oversight: under achromatopsia the neon eye goes grey and this panel goes
   grey with it. The panel is not exempt from the answer it gives.

   Like .language-menu it hangs off <body>, out of reach of every main-scoped
   rule here and every `body[data-theme-id] main …` rule in a month sheet. The
   GLOBAL element rules still reach it, which is why the options wear the
   tripled-class armour the instrument rail wears — the rationale is written out
   once, in full, on .instrument. */
.cvd-menu {
  position: absolute;
  z-index: 70;
  width: min(21rem, calc(100vw - 1.5rem));
  max-height: min(30rem, calc(100vh - 6rem));
  overflow-y: auto;
  padding: 0.95rem 1rem 1.05rem;
  border: 1px solid transparent;
  border-image: var(--tt-heading-rainbow) 1;
  background: var(--bg);
  box-shadow: 0 0 26px rgba(0, 0, 0, 0.55);
}

.cvd-menu[hidden] { display: none; }

.cvd-menu__title {
  margin: 0 0 0.5rem;
  color: var(--tt-breadcrumb-text);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-shadow: 0 0 8px var(--tt-breadcrumb-glow);
}

.cvd-menu__lede {
  margin: 0 0 0.9rem;
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.66rem;
  line-height: 1.55;
}

.cvd-menu__group {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

/* The section heads sit INSIDE the radiogroup, between its options. They are
   prose about the choice rather than steps in it, which is why the arrow keys
   in main.js walk straight past them from one option to the next. */
.cvd-menu__section {
  margin: 0.85rem 0 0.1rem;
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.cvd-menu__section:first-of-type { margin-top: 0.5rem; }

.cvd-menu__blurb {
  margin: 0 0 0.35rem;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  line-height: 1.55;
}

.cvd-menu__source {
  margin: 1rem 0 0;
  padding-top: 0.7rem;
  border-top: 1px solid var(--border-dim);
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.58rem;
  line-height: 1.6;
}

/* Tripled to (0,3,0) to clear `body[data-theme-id="…"] button` at (0,2,1) —
   october and november put a pumpkin frame on every button and the site paints
   button borders with the header rainbow as a border-image. A border-image
   needs a width, so `border: 0` alone leaves it standing; both have to be said.
   Same arithmetic as .instrument, written out there.

   AND THEN THE SITE'S OWN `button` RULE, WHICH IS THE HARDER ONE. Every other
   button on this site is a pill with one short word in it, so main.css cuts all
   of them that way at the content defaults: centred both ways, uppercased,
   letterspaced, 999px round. Six of those seven declarations are actively wrong
   for an option that is a heading, a sentence and a statistic stacked in a
   column, and none of them is cleared by `border: 0`. They are undone one by
   one below rather than by a `all: unset`, which would take the focus ring and
   the inherited font with them.

   The centring is the one that bites hardest and reads as a mystery: with
   flex-direction turned to column, the inherited `align-items: center` stops
   centring a line of text and starts centring three block children, so every
   line in the panel came out ragged on both sides for a reason written two
   thousand lines up the sheet. */
.cvd-option.cvd-option.cvd-option {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  gap: 0.12rem;
  width: 100%;
  margin: 0;
  padding: 0.42rem 0.55rem;
  border: 0;
  border-image: none;
  border-radius: 0;
  border-left: 2px solid transparent;
  background: transparent;
  box-shadow: none;
  transform: none;
  cursor: pointer;
  color: var(--text);
  letter-spacing: normal;
  text-align: left;
  text-transform: none;
  transition: background-color 140ms ease, border-color 140ms ease;
}

.cvd-option.cvd-option.cvd-option:hover,
.cvd-option.cvd-option.cvd-option:focus-visible {
  background: var(--tt-bg-alt);
  border-color: transparent;
  border-left-color: var(--tt-gold);
  box-shadow: none;
  transform: none;
}

.cvd-option.cvd-option.cvd-option:focus-visible {
  outline: 2px solid var(--tt-gold);
  outline-offset: 2px;
}

/* The chosen one is marked twice over, and by now the reason should be
   obvious: this is the one panel on the site guaranteed to be read by somebody
   who cannot use the colour. So the left rule fills in as well — a 2px bar
   either present or absent, which survives every filter in the list including
   the two that throw colour away entirely. */
.cvd-option.cvd-option.cvd-option.is-current {
  border-left-color: var(--tt-red);
  background: var(--tt-bg-alt);
}

.cvd-option__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem;
}

.cvd-option__name {
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.02em;
}

.cvd-option.is-current .cvd-option__name { color: var(--tt-red); }

/* The plain-English name, kept beside the clinical one rather than instead of
   it. "Deuteranomaly" is what the condition is called and "green-weak" is what
   a reader can act on; a panel that printed only the first would be a glossary
   and one that printed only the second would be baby talk. */
.cvd-option__plain {
  color: var(--tt-gold);
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.cvd-option__note {
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 0.64rem;
  line-height: 1.5;
}

.cvd-option__rate {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 0.56rem;
  letter-spacing: 0.06em;
}

@media (max-width: 700px) {
  .cvd-menu { padding: 0.8rem 0.85rem 0.9rem; }
  .cvd-option.cvd-option.cvd-option { padding: 0.38rem 0.45rem; }
  .cvd-option__note { font-size: 0.62rem; }
}

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