/* nocturna — the nightly build.
   Forked from assets/style.css, which is now the same design: this build was
   promoted to the front page and carries on here as where the next version is
   made. Content comes from ../config.js either way, so what differs between
   the two is only ever the shape. */

:root {
  --bg: #13131e;
  --ink: #cdcbe9;
  /* text ramp, lifted so all three tiers clear WCAG AA on --bg. The old
     --faint sat at ~2.3:1 and carried the footer, blurbs and small print;
     raising it alone would have collapsed it into --dim, so the whole ramp
     moved up: ~7.5:1 and ~4.6:1 respectively. */
  --dim: #a3a1cc;
  /* The floor of the small-print tier, and only the value before the scene's
     first paint: scene.js writes this property inline on every tone change and
     outranks what is here, so the same hex lives in both and they move
     together. Raised from #7d7ba6, which cleared AA against the flat --bg
     above but not against what a visitor actually sees, since the scene and
     the veil composite behind every row. Measured over the text column it fell
     to 3.70:1 over night's brightest patches and 3.17:1 over dawn's horizon
     band. */
  --faint: #9996cb;
  --line: #2c2b4a;
  --amber: #ffb454;
  --amber-ink: #161020;
  --violet: #b4a0ff;
  --panel: rgba(11, 11, 20, 0.78);
  /* The corner controls sit on a scene that is a train one minute and a snow
     field the next, so unlike the page's panels theirs is opaque. Translucent
     was fine over a night sky and stopped being fine the moment the sky could
     be bright. */
  --control: #0c0c16;
  --accent: var(--amber);
  --surface: #0a0a13;
  --tile: rgba(11, 11, 20, 0.5);
  --shadow: #08080f;
  /* the hard offset the buttons already used, promoted to a token: blocks
     that sit on the scene cast it too, in both themes */
  --drop: rgba(0, 0, 0, 0.55);
}

/* Daylight. The scene behind the page runs on the visitor's own clock and
   place, and when the sky comes up the page has to come with it: light text
   over a bright sky is unreadable however the veil is tuned.

   Snapped, not interpolated. A text colour and a background crossing through
   the middle is several minutes of mush, so scene.js throws this in one step
   once the sun is properly up, and only the veil and the body colour cross
   fade. The ramp keeps the same three tiers, re-picked against a light
   surface rather than a dark one. */
:root.daylight {
  --bg: #dbe3f0;
  --ink: #0d1120;
  /* Measured against the composited page, not picked by eye. The sea behind
     the text column lifted the background, and the two quiet tiers had to come
     down with it to keep clearing AA. */
  --dim: #1d2437;
  --faint: #262d40;
  /* the borders and the dotted rules all come off this, and against a bright
     page the first pick of it was a hairline nobody could see */
  --line: #7b8aa6;
  --violet: #4a35a8;
  --panel: rgba(244, 247, 252, 0.84);
  --control: #f2f5fb;
  --surface: #e9eef7;
  --tile: rgba(244, 247, 252, 0.6);
  /* a mid tone, not a light one: the masthead's hard offset is the shadow of
     dark letters here, and a near-white one behind them is no shadow at all */
  --shadow: #a3b2c9;
  --drop: rgba(22, 34, 56, 0.16);
}

/* ----- daylight, beyond the colours -----

   Swapping the ramp is not a light theme. The dark page can let a row sit
   straight on the sky because light type on a dark ground separates itself;
   in daylight nothing does, and the page reads as a night design wearing
   daytime paint. So by day the rows get a surface to sit on, the rules take
   weight, and the hard offset shadow the buttons already use turns up on the
   things that have to lift off a bright background. */

/* Held by scene.js for the length of a theme change, and only then. --fade is
   set by scene.js from the time left in the crossing that triggered the flip,
   so the page finishes changing when the sky does. At a flat 1s it fitted the
   3.5s night-to-day crossing and overran the shorter ones: day to dawn flips
   at 91% of a 1.5s sweep, so the page went on easing for most of a second
   after the scene had settled. */
:root.theming *,
:root.theming *::before,
:root.theming *::after {
  transition:
    color var(--fade, 1s) linear, background-color var(--fade, 1s) linear,
    border-color var(--fade, 1s) linear, box-shadow var(--fade, 1s) linear,
    text-shadow var(--fade, 1s) linear, opacity var(--fade, 1s) linear !important;
}

/* The same surface the featured card and the panels use, not a lighter wash of
   it. Two weights of white in one column reads as the rows being half-finished
   next to the card rather than as a hierarchy. */
:root.daylight .row {
  background: var(--panel);
  border-bottom: 1px solid var(--line);
}

:root.daylight .row:hover,
:root.daylight .row:focus-visible,
:root.daylight .row-wrap:hover > .row {
  background: color-mix(in srgb, var(--accent) 30%, #ffffff);
}

/* Tiles carry a whole section on their own — no dashed rule under them, no
   run of neighbours to be read as part of. Against a bright sky the 55%
   wash they had was not a card, so by day they get a real one. */
:root.daylight .tile {
  background: var(--panel);
  border: 1px solid var(--line);
  border-left-width: 2px;
  box-shadow: 4px 4px 0 var(--drop);
}

:root.daylight .tile:hover,
:root.daylight .tile:focus-visible,
:root.daylight .row-wrap:hover > .tile { background: color-mix(in srgb, var(--accent) 26%, #ffffff); }

:root.daylight .expand.open .row-toggle { background: var(--panel); }

/* The dotted rules are a hairline of --line against black at night, which is
   plenty. Against a bright page they need the extra pixel, a darker ink, and
   the dots closer together, or they read as a smudge rather than a rule. */
:root.daylight .block-rule {
  height: 3px;
  background-image: repeating-linear-gradient(90deg,
    #5f6f8c 0 2px, transparent 2px 5px);
}

:root.daylight .row { border-bottom-color: #93a1b8; }
:root.daylight .block-blurb { color: var(--dim); }

:root.daylight .chip { filter: saturate(1.35) brightness(0.74); }

:root.daylight .row:hover,
:root.daylight .row:focus-visible,
:root.daylight .row-wrap:hover > .row {
  border-left-color: color-mix(in srgb, var(--accent) 70%, #101a2c);
}

/* The amber is the site's own colour and it stays the site's own colour where
   it is a background. Where it is ink it cannot be: 4.5:1 against white needs
   most of the light taken out of it. */
:root.daylight .tagline b,
:root.daylight .block-num,
:root.daylight .sky-modes button[aria-pressed="true"],
:root.daylight #sky-geo[aria-pressed="true"],
:root.daylight #snd:hover,
:root.daylight #snd:focus-visible,
:root.daylight #sky:hover,
:root.daylight #sky:focus-visible,
:root.daylight .social:hover,
:root.daylight .social:focus-visible,
:root.daylight #sky[aria-expanded="true"] { color: #93560b; }

:root.daylight .sky-modes button[aria-pressed="true"],
:root.daylight #sky-geo[aria-pressed="true"],
:root.daylight #sky:hover,
:root.daylight #sky:focus-visible,
:root.daylight .social:hover,
:root.daylight .social:focus-visible,
:root.daylight #sky[aria-expanded="true"] { border-color: #93560b; }

/* ----- an accent used as ink -----

   Every item's accent in config.js was picked to glow on near-black, and as a
   block of colour that is all it has to do. As *text* it has two more jobs: it
   has to hold up on a light panel by day, where a pastel simply disappears,
   and on a dark one at night, where the darker accents like Linea's red go
   thin. Neither is fixable per item, because the next accent added will be a
   hue nobody thought about, so the correction is on the mechanism: every
   accent gets pulled toward the theme's far end before it is used as ink, and
   carries a hard one-pixel shadow the same way the masthead does.

   Declared on the elements that carry --accent rather than on :root, because a
   custom property whose value references another is resolved where it is
   declared, not where it is used: on :root it would only ever see the amber
   default, and every row would get the same ink whatever its accent. */
.row,
.row-wrap,
.expand,
.panel {
  --accent-ink: color-mix(in srgb, var(--accent) 75%, #ffffff);
}

:root.daylight .row,
:root.daylight .row-wrap,
:root.daylight .expand,
:root.daylight .panel {
  --accent-ink: color-mix(in srgb, var(--accent) 35%, #0b1120);
}

* { box-sizing: border-box; }

html { scrollbar-color: var(--faint) var(--bg); }

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  font-family: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
  font-size: 15px;
  line-height: 1.6;
}

::selection { background: var(--amber); color: var(--amber-ink); }

/* ----- first-visit audio intro ----- */

/* set pre-paint by the inline <head> script; .ready arrives once the intro
   (or page boot) finishes, and everything below fades in from there */
html.first-visit:not(.ready) main { opacity: 0; }

/* Same trap as #ask, and this one has been shipping: the splash is `hidden` in
   the markup and shown by app.js only on a first visit, but `display: flex`
   below outranks the UA rule for [hidden], so on every repeat visit it was
   painted until app.js got round to removing it. */
#intro[hidden] { display: none; }

#intro {
  position: fixed;
  inset: 0;
  z-index: 10;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  /* Was flat black, which is right at night and a hole in the screen at noon,
     since the page it hands over to may be a light one. The page's own
     background either way, and the icon and both lines already come off the
     text ramp, so the whole splash follows the theme. */
  background: var(--bg);
  /* the backdrop leaves last, after the parts have gone */
  transition: opacity 0.95s ease 1.05s;
}

#intro.out { opacity: 0; pointer-events: none; }

/* parts leave one by one, the way they arrived */
#intro.out .intro-text { animation: intro-out 0.7s steps(5) forwards; }
#intro.out .intro-icon { animation: intro-out 0.7s steps(5) 0.35s forwards; }
#intro.out .intro-skip { animation: intro-out 0.4s steps(3) forwards; }

.intro-icon {
  width: 88px;
  height: 88px;
  color: var(--ink);
  animation: intro-in 1.4s steps(7) 0.4s backwards;
}

.intro-text {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-size: 14px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink);
  animation: intro-in 1.2s steps(6) 1.2s backwards;
}

/* the way out has to be visible, or the intro is just a toll */
.intro-skip {
  margin: -12px 0 0;
  font-family: "Silkscreen", monospace;
  font-size: 9px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--faint);
  animation: intro-in 0.8s steps(4) 2.1s backwards;
}

@keyframes intro-in {
  from { opacity: 0; transform: translateY(6px); }
}

@keyframes intro-out {
  to { opacity: 0; transform: translateY(-6px); }
}

/* ----- entrance fades ----- */

html.ready .masthead h1 { animation: rise 0.5s steps(4) backwards; }
html.ready .tagline { animation: rise 0.5s steps(4) 0.15s backwards; }
html.ready footer { animation: rise 0.5s steps(4) 0.45s backwards; }

/* ----- corner chrome ----- */

#chrome {
  position: fixed;
  right: 18px;
  bottom: 16px;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 12px;
}

#snd,
#sky {
  padding: 8px 12px;
  font-family: "Silkscreen", monospace;
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--dim);
  background: var(--control);
  border: 1px solid var(--line);
  /* the same hard offset the panels and the copy button carry, permanently:
     these sit on top of a moving scene and need to be read as controls at a
     glance, in either theme */
  box-shadow: 3px 3px 0 var(--drop);
  cursor: pointer;
  /* no transition: steps() on a colour invents an in-between shade, and
     sweeping on and off cycles through it. Hover lands in one jump. */
}

/* pressed in, the way the copy button does it */
#snd:active,
#sky:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 var(--drop);
}

#sky {
  display: flex;
  padding: 7px;
  line-height: 0;
}

#sky svg {
  width: 16px;
  height: 16px;
}

#snd:hover, #snd:focus-visible,
#sky:hover, #sky:focus-visible {
  color: var(--amber);
  border-color: var(--amber);
  outline: none;
}

#snd[aria-pressed="false"] { color: var(--faint); }
#sky[aria-expanded="true"] { color: var(--amber); border-color: var(--amber); }

/* ----- sky menu ----- */

/* Sits directly over the button that opens it, in the same corner, so the
   thing being changed stays in view while it is changed. */
#sky-menu {
  position: fixed;
  right: 18px;
  bottom: 58px;
  z-index: 6;
  width: 168px;
  padding: 12px 12px 14px;
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 5px 5px 0 var(--drop);
  font-family: "Silkscreen", monospace;
  font-size: 10px;
  letter-spacing: 0.08em;
}

.sky-head {
  margin: 0 0 10px;
  color: var(--faint);
}

.sky-modes {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.sky-modes button {
  padding: 7px 8px;
  font: inherit;
  letter-spacing: inherit;
  text-align: left;
  color: var(--dim);
  background: transparent;
  border: 1px solid transparent;
  cursor: pointer;
}

.sky-modes button:hover,
.sky-modes button:focus-visible {
  color: var(--ink);
  border-color: var(--line);
  outline: none;
}

.sky-modes button[aria-pressed="true"] {
  color: var(--amber);
  border-color: var(--amber);
}

.sky-note {
  margin: 10px 0 0;
  color: var(--faint);
}

.sky-modes button:disabled { opacity: 0.45; cursor: default; }

#sky-geo {
  margin-top: 10px;
  padding: 7px 8px;
  font: inherit;
  letter-spacing: inherit;
  text-align: left;
  color: var(--dim);
  background: transparent;
  border: 1px solid transparent;
  cursor: pointer;
}
#sky-geo:hover,
#sky-geo:focus-visible {
  color: var(--ink);
  border-color: var(--line);
  outline: none;
}
#sky-geo[aria-pressed="true"] {
  color: var(--amber);
  border-color: var(--amber);
}
/* unmuted, but the browser is still holding every sound until a gesture */
#snd[data-waiting] { color: var(--faint); }

#ver {
  font-family: "Silkscreen", monospace;
  font-size: 9px;
  letter-spacing: 0.08em;
  color: var(--faint);
  pointer-events: none;
}

/* ----- the way back to the stable site ----- */

#nocturna {
  position: fixed;
  left: 18px;
  bottom: 16px;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  font-family: "Silkscreen", monospace;
  font-size: 10px;
  letter-spacing: 0.08em;
  color: var(--dim);
  text-decoration: none;
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 3px 3px 0 var(--drop);
  /* no transition: steps() on a colour invents an in-between shade, and
     sweeping on and off cycles through it. Hover lands in one jump. */
}

#nocturna:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 var(--drop);
}

/* Switched off. `inert` already stops the clicks, the focus and the screen
   reader; this is the half of it you can see. The beacon stops too, since a
   light blinking on a dead control is worse than no light. */
#nocturna[inert] {
  opacity: 0.4;
  box-shadow: 2px 2px 0 var(--drop);
}

#nocturna[inert] .beacon {
  animation: none;
  opacity: 0.45;
}

#nocturna:hover, #nocturna:focus-visible {
  color: var(--violet);
  border-color: var(--violet);
  outline: none;
}

#nocturna .beacon {
  width: 3px;
  height: 3px;
  background: var(--violet);
  animation: beacon 1.7s steps(1, end) infinite;
}

@keyframes beacon {
  0%, 12% { opacity: 1; }
  13%, 100% { opacity: 0.15; }
}

/* ----- scene ----- */

/* These were briefly given their own compositing layers with will-change, to
   stop page repaints dragging them along. Measured: it made things worse.
   Promoting them put the footer on a layer boundary, so hovering the fixed
   corner buttons re-rasterized it and the bottom of the page flickered.
   Leave them in the root layer. */
#scene {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  image-rendering: pixelated;
  pointer-events: none;
}

/* foreground plane — near ridge, ground and train render above the text,
   so the page scrolls away behind the mountains */
#front {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 3;
  image-rendering: pixelated;
  pointer-events: none;
}

/* Readability veil. The sky behind it is quantized to four indigo bands and
   Bayer-dithered; a smooth gradient on top was the one soft edge in the whole
   design, so this steps in hard bands to match.

   There used to be a 4px repeating-conic-gradient over the top to break each
   seam. It cost far more than it was worth: a conic tiled 4px across a fixed
   full-viewport element is around 130k tiles at 1440p, and because everything
   translucent in the page composites against this layer, every hover that
   repainted a row or a tile re-rasterized the whole thing. The bands alone
   carry the stepped look, so it is no loss. */
/* --tone and --lift are set by scene.js: tone is 0 at night, 1 at twilight and
   2 in daylight; lift is the shaped 0..1 the dark scrim thickens on, so light
   text keeps a background under it right through dawn. The light scrim runs
   the same gradient, because by day the deep end of the sky is still the top
   of the screen. */
.veil {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

/* Two scrims on the one element, cross-faded rather than stacked: the dark
   one carries light type, the light one carries dark type, and whichever is
   not in use is at zero. Both are pseudo-elements because fading the parent
   would take the other one with it, and the first pass did exactly that —
   a dark veil at 0.92 under a light veil at 0.5, which is how a blue noon
   ended up the colour of wet concrete. */
.veil::before,
.veil::after {
  content: "";
  position: absolute;
  inset: 0;
  transition: opacity 1.2s linear;
}

/* Sixteen steps rather than eight.

   The bands are deliberate — a smooth gradient would be the one soft edge in a
   page that dithers its own sky — but eight of them meant a twelve point jump
   in alpha at each edge, and an edge crossing the moon took a bite out of the
   top of it. Halving the step halves the bite while keeping the stepping,
   which is the part that was doing the work. */
.veil::before {
  opacity: 1;
  background:
    linear-gradient(180deg,
      rgba(10, 10, 18, calc(0.74 + 0.19 * var(--lift))) 0 6.25%,
      rgba(10, 10, 18, calc(0.70 + 0.21 * var(--lift))) 6.25% 12.5%,
      rgba(10, 10, 18, calc(0.65 + 0.24 * var(--lift))) 12.5% 18.75%,
      rgba(10, 10, 18, calc(0.59 + 0.27 * var(--lift))) 18.75% 25%,
      rgba(10, 10, 18, calc(0.54 + 0.31 * var(--lift))) 25% 31.25%,
      rgba(10, 10, 18, calc(0.49 + 0.34 * var(--lift))) 31.25% 37.5%,
      rgba(10, 10, 18, calc(0.43 + 0.36 * var(--lift))) 37.5% 43.75%,
      rgba(10, 10, 18, calc(0.38 + 0.39 * var(--lift))) 43.75% 50%,
      rgba(10, 10, 18, calc(0.33 + 0.41 * var(--lift))) 50% 56.25%,
      rgba(10, 10, 18, calc(0.29 + 0.43 * var(--lift))) 56.25% 62.5%,
      rgba(10, 10, 18, calc(0.24 + 0.44 * var(--lift))) 62.5% 68.75%,
      rgba(10, 10, 18, calc(0.20 + 0.45 * var(--lift))) 68.75% 75%,
      rgba(10, 10, 18, calc(0.16 + 0.44 * var(--lift))) 75% 81.25%,
      rgba(10, 10, 18, calc(0.12 + 0.43 * var(--lift))) 81.25% 87.5%,
      rgba(10, 10, 18, calc(0.08 + 0.40 * var(--lift))) 87.5% 93.75%,
      rgba(10, 10, 18, calc(0.05 + 0.32 * var(--lift))) 93.75% 100%);
}

/* --dip is 1 the moment the page flips and 0 once the sun is properly up: the
   light scrim starts heavy, because it is holding dark type over a sky that
   is still half twilight, and thins out as the sky does the work itself. */
.veil::after {
  opacity: 0;
  --extra: calc(0.22 * var(--dip, 0));
  background:
    linear-gradient(180deg,
      rgba(233, 239, 249, calc(0.51 + var(--extra))) 0 6.25%,
      rgba(233, 239, 249, calc(0.49 + var(--extra))) 6.25% 12.5%,
      rgba(233, 239, 249, calc(0.48 + var(--extra))) 12.5% 18.75%,
      rgba(233, 239, 249, calc(0.46 + var(--extra))) 18.75% 25%,
      rgba(233, 239, 249, calc(0.44 + var(--extra))) 25% 31.25%,
      rgba(233, 239, 249, calc(0.42 + var(--extra))) 31.25% 37.5%,
      rgba(233, 239, 249, calc(0.40 + var(--extra))) 37.5% 43.75%,
      rgba(233, 239, 249, calc(0.38 + var(--extra))) 43.75% 50%,
      rgba(233, 239, 249, calc(0.36 + var(--extra))) 50% 56.25%,
      rgba(233, 239, 249, calc(0.34 + var(--extra))) 56.25% 62.5%,
      rgba(233, 239, 249, calc(0.31 + var(--extra))) 62.5% 68.75%,
      rgba(233, 239, 249, calc(0.28 + var(--extra))) 68.75% 75%,
      rgba(233, 239, 249, calc(0.25 + var(--extra))) 75% 81.25%,
      rgba(233, 239, 249, calc(0.22 + var(--extra))) 81.25% 87.5%,
      rgba(233, 239, 249, calc(0.19 + var(--extra))) 87.5% 93.75%,
      rgba(233, 239, 249, calc(0.17 + var(--extra))) 93.75% 100%);
}

:root.daylight .veil::before { opacity: 0; }
:root.daylight .veil::after { opacity: 1; }

/* the two big surfaces cross the flip rather than jumping it; everything else
   is small enough that a step reads as the light changing */
body { transition: color 1.2s linear; }

/* ----- the ask -----

   Built by app.js, not written into index.html, so both builds get it from
   their own copy of the script. Sits above everything including the foreground
   canvas, because a question you can click through is not a question. */
#ask {
  position: fixed;
  inset: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(6, 6, 12, 0.62);
}

/* An author `display` beats the UA stylesheet's rule for [hidden], so without
   this the overlay never goes away: app.js sets hidden, nothing happens, and
   cancel looks like a dead button. */
#ask[hidden] { display: none; }

:root.daylight #ask { background: rgba(20, 30, 48, 0.42); }

.ask-box {
  position: relative;
  width: min(420px, 100%);
  padding: 22px 24px 20px;
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 6px 6px 0 var(--drop);
  animation: rise 0.22s steps(3) backwards;
}

.ask-q {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink);
}

/* the exact destination or the exact command, wrapped rather than clipped: the
   whole point is that it can be read before it is agreed to */
.ask-detail {
  margin: 12px 0 20px;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--dim);
  word-break: break-all;
}

.ask-buttons {
  display: flex;
  gap: 10px;
}

.ask-buttons button {
  padding: 9px 14px;
  font-family: "Silkscreen", monospace;
  font-size: 11px;
  letter-spacing: 0.08em;
  cursor: pointer;
  border: 1px solid var(--line);
  background: transparent;
  color: var(--dim);
}

.ask-yes {
  color: var(--amber) !important;
  border-color: var(--amber) !important;
}

:root.daylight .ask-yes {
  color: #93560b !important;
  border-color: #93560b !important;
}

.ask-buttons button:hover,
.ask-buttons button:focus-visible {
  outline: none;
  color: var(--ink);
  border-color: var(--ink);
}

.ask-buttons button:active { transform: translate(1px, 1px); }

@media (prefers-reduced-motion: reduce) {
  .ask-box { animation: none; }
}

/* page-transition fade — black-out before navigating to a sub-site */
#page-fade {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s steps(10);
}
#page-fade.show {
  opacity: 1;
  pointer-events: auto;
}

/* ----- layout ----- */

main {
  position: relative;
  z-index: 2;
  max-width: 860px;
  margin: 0 auto;
  padding: clamp(56px, 10vh, 110px) 28px 200px;
}

/* Silkscreen is a bitmap face. Sized fluidly it lands on fractional multiples
   of its own raster at nearly every viewport width and the stems antialias to
   grey, so it steps between whole multiples of 8 instead. */
.masthead h1 {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-weight: 700;
  font-size: 40px;
  line-height: 1;
  letter-spacing: -0.01em;
  color: var(--ink);
  /* a hard offset, matching .copy-btn — the old 28px blur was the only
     gaussian in a page that dithers its own sky */
  text-shadow: 3px 3px 0 var(--shadow);
}

@media (min-width: 600px) { .masthead h1 { font-size: 56px; text-shadow: 4px 4px 0 var(--shadow); } }
@media (min-width: 900px) { .masthead h1 { font-size: 72px; text-shadow: 5px 5px 0 var(--shadow); } }

.tagline {
  margin: 14px 0 0;
  color: var(--dim);
  font-size: 14px;
  letter-spacing: 0.04em;
}

.tagline b { color: var(--amber); font-weight: 500; }

/* how many of what, so the page states its own size on arrival */
.counts {
  margin: 8px 0 0;
  color: var(--faint);
  font-size: 12px;
  letter-spacing: 0.04em;
}

/* ----- sections ----- */

.block {
  margin-top: 76px;
  animation: rise 0.4s steps(4) backwards;
}

.block-head {
  display: flex;
  align-items: baseline;
  gap: 14px;
  margin-bottom: 18px;
}

.block-num {
  font-family: "Silkscreen", monospace;
  font-size: 12px;
  color: var(--amber);
}

.block-label {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-weight: 400;
  font-size: 17px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink);
}

/* dithered rule — square dots, no anti-aliased lines here */
.block-rule {
  flex: 1;
  height: 2px;
  align-self: center;
  background-image: repeating-linear-gradient(90deg,
    var(--line) 0 2px, transparent 2px 6px);
}

.block-blurb {
  color: var(--faint);
  font-size: 12px;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

/* ----- link rows ----- */

.row {
  display: flex;
  /* the chip is a block, not text, so parts centre on it rather than sharing
     a baseline with it */
  align-items: center;
  /* wraps so .row-detail can take a line of its own under the name; the row
     gap is what separates the two lines, the column gap is the old spacing
     between name, description and arrow */
  flex-wrap: wrap;
  gap: 7px 18px;
  padding: 15px 14px;
  color: var(--ink);
  text-decoration: none;
  /* One accent edge, transparent until hover, holding its own space so
     nothing shifts. The old `border: 1px solid transparent` plus a dashed
     bottom meant hover's `border-color` recoloured a box that was solid on
     three sides and dashed on the fourth. This is also the featured card's
     vocabulary, at a different permanence: the card's edge is always on, a
     row's arrives on hover. */
  border: 0;
  border-left: 2px solid transparent;
  border-bottom: 1px dashed var(--line);
  transition: transform 0.18s steps(3), background-color 0.18s steps(3),
              border-color 0.18s steps(3);
  animation: rise 0.4s steps(4) backwards;
}

.row-name {
  display: flex;
  align-items: center;
  gap: 12px;
  font-family: "Silkscreen", monospace;
  font-size: 15px;
  white-space: nowrap;
}


.row-ver {
  font-family: "IBM Plex Mono", monospace;
  font-size: 11px;
  font-weight: 400;
  color: var(--dim);
  letter-spacing: .4px;
}

/* Generated in chip.js from the project's name, painted in its accent. Six
   pixels by six, displayed at whole multiples only — 18px here, 24px in the
   featured card — so each pixel stays square. Kept small: it marks the row,
   it isn't the subject of it. */
.chip {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
  image-rendering: pixelated;
  /* no drop-shadow here: a filter can't be composited cheaply, and eleven of
     them inside rows that transition on hover made every hover an expensive
     re-raster. At 18px the glow was barely visible anyway. */
}

.row-desc {
  color: var(--dim);
  font-size: 13px;
  flex: 1;
}

/* The mechanism, under the sentence saying what the thing is. It takes the
   whole width rather than sharing the line, because the name and the arrow are
   what the eye runs down and this is what it stops for. `order` keeps it after
   the description on small screens, where the description takes a line of its
   own too. */
.row-detail {
  flex-basis: 100%;
  order: 4;
  margin-left: 30px;
  max-width: 74ch;
  color: var(--faint);
  font-size: 12px;
  line-height: 1.65;
}

/* flex-basis is the main axis, and in a column that is height, so the row's
   100% would ask for the whole tile. */
.tile .row-detail { flex-basis: auto; margin-left: 0; font-size: 11.5px; }

/* every item in config.js carries its own accent; it used to reach nothing
   but an 8px square, and now paints the chip, the arrow and the hover edge */
.row-go {
  font-family: "Silkscreen", monospace;
  font-size: 13px;
  color: var(--accent-ink, var(--accent));
  text-shadow: 1px 1px 0 var(--shadow);
  opacity: 0;
  transform: translateX(-6px);
  transition: opacity 0.18s steps(3), transform 0.18s steps(3);
}

/* the wrapper's own hover is in here so a row keeps its lit state while the
   pointer is on the GitHub mark beside it. The mark is a sibling, not a child,
   so :hover on the row is false the moment you reach for it */
.row:hover,
.row:focus-visible,
.row-wrap:hover > .row {
  background: color-mix(in srgb, var(--accent) 11%, transparent);
  border-left-color: var(--accent);
  transform: translateX(6px);
  box-shadow: 4px 4px 0 var(--drop);
  outline: none;
}

.row:hover .row-go,
.row:focus-visible .row-go,
.row-wrap:hover > .row .row-go {
  opacity: 1;
  transform: translateX(0);
}

/* on touch there is no hover, so the arrow was the one signal a row leads
   somewhere and it never appeared */
@media (hover: none) {
  .row-go { opacity: 1; transform: none; }
  /* and the mark never became a control, because that was on the row's hover.
     On touch it wears the chrome from the start, the way #sky always does. */
  .gh {
    color: var(--dim);
    background: var(--control);
    border-color: var(--line);
    box-shadow: 3px 3px 0 var(--drop);
  }
}

/* ----- tiles -----
   Sections marked layout: "grid" in config.js. A run of small experiments
   reads better as a set of things than as five more horizontal bars, and it
   gives the page a second movement between the releases and the concepts. */

.tiles {
  display: grid;
  grid-template-columns: 1fr;
  gap: 10px;
}

@media (min-width: 720px) {
  .tiles { grid-template-columns: 1fr 1fr; }
}

/* anything that isn't a tile (an expand, an install card) keeps full width
   rather than being squeezed into a column. A wrapped tile is still a tile,
   so it is the one exception: it takes its column like a bare one. */
.tiles > :not(.tile):not(.row-wrap) { grid-column: 1 / -1; }

.tile {
  position: relative;
  flex-direction: column;
  align-items: flex-start;
  /* .row wraps so its detail line can take a row of its own. A tile is the
     same element turned on its side, and wrapping a column container spills
     the overflow into a second column beside it, straight over the next tile.
     A tile has nothing to wrap: it is already one item per line. */
  flex-wrap: nowrap;
  gap: 10px;
  padding: 16px 16px 18px;
  border-bottom: 0;
  background: var(--tile);
}

.tile .row-desc { font-size: 12.5px; flex: 0 1 auto; }

/* the arrow would push the tile's height around as it fades in and out, so it
   rides in the corner instead of in the flow */
.tile .row-go {
  position: absolute;
  top: 14px;
  right: 14px;
  transform: none;
}

.tile:hover .row-go,
.tile:focus-visible .row-go { transform: none; }

/* ----- the open source mark -----
   Wrapped around any item in config.js that names a `repo`, and nothing else.
   The row stays a real anchor and the mark is its sibling rather than its
   child, because an <a> inside an <a> is not markup that survives parsing.

   The wrapper is what slides on hover, so the mark travels with the row
   instead of standing still while it moves out from under it. .expand solved
   the same problem the same way for its toggle. */

.row-wrap {
  position: relative;
  /* the row fills the wrapper in both directions, so a wrapped tile is still
     the same height as its unwrapped neighbour in the same grid row */
  display: flex;
  transition: transform 0.18s steps(3);
}

.row-wrap > .row { flex: 1; }

/* z-index so the label below can rise over the row above it */
.row-wrap:hover { transform: translateX(6px); z-index: 2; }

/* the wrapper is doing it, so the row inside must not do it a second time */
.row-wrap > .row:hover,
.row-wrap > .row:focus-visible,
.row-wrap:hover > .row { transform: none; }

/* Bare glyph at rest, so eight of these down the page stay quiet. The row
   under the pointer turns it into a control, taking the same --control fill,
   --line border and hard 3px offset that #sky wears permanently, and hovering
   the mark itself takes it to the card's own accent. Pressing it sinks 2px into
   its shadow, exactly as the corner buttons do.

   That progression is the whole affordance: a detail at rest, a button under
   the pointer. An earlier pass changed only the ink, which on a page where
   every other hover slides, tints and casts a shadow read as nothing at all. */
.gh {
  position: absolute;
  top: 50%;
  right: 14px;
  /* half its own 30px box. Centred on a margin rather than translateY(-50%) so
     `transform` stays free for the press, and for `rise` on the way in. */
  margin-top: -15px;
  display: flex;
  padding: 6px;
  line-height: 0;
  color: var(--faint);
  background: transparent;
  border: 1px solid transparent;
  transition: color 0.18s steps(3), background-color 0.18s steps(3),
              border-color 0.18s steps(3), box-shadow 0.18s steps(3),
              transform 0.1s steps(2);
  animation: rise 0.4s steps(4) backwards;
}

.gh svg {
  display: block;
  width: 16px;
  height: 16px;
  fill: currentColor;
}

/* the row is under the pointer: the mark becomes a control */
.row-wrap:hover .gh {
  color: var(--dim);
  background: var(--control);
  border-color: var(--line);
  box-shadow: 3px 3px 0 var(--drop);
}

/* the mark itself is. Written from .row-wrap rather than as a bare .gh:hover
   only to clear the specificity of the rule above it. The focus ring is left
   to the global a:focus-visible, which is a stronger signal than a recolour. */
.row-wrap .gh:hover,
.row-wrap .gh:focus-visible {
  color: var(--accent-ink, var(--accent));
  border-color: var(--accent-ink, var(--accent));
}

.row-wrap .gh:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 var(--drop);
}

/* What it is, because a mark on its own never says. The gear in the corner
   needed #sky-tip for exactly this reason. Rides above the mark, clear of the
   row's own text, and out of the pointer's way. */
.gh::after {
  content: "source";
  position: absolute;
  right: -1px;
  bottom: calc(100% + 9px);
  padding: 5px 7px;
  font-family: "Silkscreen", monospace;
  font-size: 9px;
  letter-spacing: 0.08em;
  line-height: 1;
  color: var(--dim);
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 3px 3px 0 var(--drop);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.12s steps(2);
}

.gh:hover::after,
.gh:focus-visible::after { opacity: 1; }

/* Room for it. The mark sits at the row's own right edge, where every other
   edge on the page lines up, and the arrow moves inboard of it. It used to be
   the other way round, which left the mark floating 40px short of the edge
   against nothing, since the arrow it was aligned to only exists on hover. */
.row-wrap > .row:not(.tile) { padding-right: 52px; }

/* a tile's arrow already owns the top corner, so the mark takes the bottom
   one, and the tile reserves that strip so no description runs under it */
.row-wrap > .tile { padding-bottom: 52px; }

.tile ~ .gh {
  top: auto;
  bottom: 14px;
  margin-top: 0;
}

/* ----- expanding rows ----- */

.row-toggle {
  width: 100%;
  margin: 0;
  background: none;
  font: inherit;
  color: var(--ink);
  cursor: pointer;
}

/* the +/− is the affordance — always visible, unlike link arrows */
.row-toggle .row-go {
  opacity: 1;
  transform: none;
  font-size: 15px;
}

/* the dashed separator lives on the wrapper, fixed in place, so the
   sliding panel never makes a line pop in and out; hovering the toggle
   slides the whole wrapper — underline included — like plain rows */
.expand {
  border-bottom: 1px dashed var(--line);
  transition: transform 0.18s steps(3);
}

.expand:has(> .row-toggle:hover),
.expand:has(> .row-toggle:focus-visible) {
  transform: translateX(6px);
}

.expand .row-toggle:hover,
.expand .row-toggle:focus-visible {
  transform: none;
}

.expand .row-toggle,
.expand .row-toggle:hover,
.expand .row-toggle:focus-visible {
  border-bottom-color: transparent;
}

.expand.open .row-toggle {
  background: color-mix(in srgb, var(--accent) 11%, transparent);
  border-left-color: var(--accent);
  border-bottom-color: transparent;
}

/* slides open and shut — height is measured and driven from JS, so the
   wrapper's dashed line rides the motion in every browser */
.expand-detail {
  overflow: hidden;
  transition: height 0.3s ease;
}

.expand-detail[hidden] { display: none; }

.expand-inner { padding: 4px 14px 26px; }

/* ----- featured row -----
   The flagship used to look exactly like a one-off experiment, to the point
   that its own description had to say "click me".

   Three signals and no more: an accent edge, a panel behind it, and a modest
   size bump. An earlier pass stacked six (full border, corner marks, 21px
   name, 22px padding, 34px margin, on top of these) and the card stopped
   reading as the most important item in the list — it read as a different
   kind of object that had wandered into it. The name in particular outgrew
   .block-label at 17px, so an item outranked the section holding it. */

.expand.featured {
  margin-bottom: 20px;
  border-left: 2px solid var(--accent);
  border-bottom: 0; /* a card, not a list row: no dashed separator hanging off */
  background: var(--panel);
  box-shadow: 5px 5px 0 var(--drop);
  transition: transform 0.18s steps(3), background-color 0.18s steps(3);
}

/* The card owns every state, because .row-toggle also carries .row: without
   this, .row:hover and .expand.open .row-toggle each paint a 1px accent border
   on the button's top, left and right — a second edge sitting 2px inside the
   card's own left edge — and stack a second background tint under the
   wrapper's. That's what made the sides jump on hover and stay wrong once
   open. Neutralise the inherited box; the wrapper does all the painting. */
.expand.featured .row-toggle,
.expand.featured .row-toggle:hover,
.expand.featured .row-toggle:focus-visible,
.expand.featured.open .row-toggle {
  background: none;
  /* border: 0, not just a transparent colour: .row's 2px left edge would
     otherwise inset this text 2px past .expand-inner's, so the header and the
     detail below it would not line up */
  border: 0;
}

/* a card doesn't slide the way a row does; it lights up instead, and hover
   and open land on the same tint so the two states agree */
.expand.featured:has(> .row-toggle:hover),
.expand.featured:has(> .row-toggle:focus-visible),
.expand.featured.open {
  transform: none;
  background: color-mix(in srgb, var(--accent) 9%, var(--panel));
}

.expand.featured .row-toggle { padding: 18px; }

/* level with .block-label, never above it */
.expand.featured .row-name { font-size: 17px; }

/* 24px is 4x the 6px grid, one whole step up from a row's 18px */
.expand.featured .chip { width: 24px; height: 24px; }

.expand.featured .row-desc { font-size: 14px; }

.expand.featured .row-go { font-size: 17px; }

.expand.featured .expand-inner { padding: 0 18px 26px; }

.corner {
  position: absolute;
  width: 5px;
  height: 5px;
  background: var(--accent);
}

.corner.tl { top: -3px; left: -3px; }
.corner.tr { top: -3px; right: -3px; }
.corner.bl { bottom: -3px; left: -3px; }
.corner.br { bottom: -3px; right: -3px; }

.detail-label {
  margin: 22px 0 10px;
  font-family: "Silkscreen", monospace;
  font-weight: 400;
  font-size: 12px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-ink, var(--accent));
  text-shadow: 1px 1px 0 var(--shadow);
}

.how-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.how-list li {
  position: relative;
  margin: 5px 0;
  padding-left: 18px;
  color: var(--dim);
  font-size: 13.5px;
}

.how-list li::before {
  content: "+";
  position: absolute;
  left: 0;
  top: 1px;
  color: var(--accent-ink, var(--accent));
  text-shadow: 1px 1px 0 var(--shadow);
  font-family: "Silkscreen", monospace;
  font-size: 11px;
}

.detail-links { margin-top: 18px; display: flex; gap: 14px; }

/* ----- install panel ----- */

.panel {
  position: relative;
  margin-top: 6px;
  padding: 24px 26px;
  border: 1px solid var(--line);
  background: var(--panel);
  /* the same hard offset the copy button has, so a block reads as sitting on
     the scene rather than being cut out of it */
  box-shadow: 5px 5px 0 var(--drop);
  animation: rise 0.4s steps(4) backwards;
}

.panel-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
}

.panel-name {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-weight: 400;
  font-size: 19px;
}

.panel-links { display: flex; gap: 14px; }

.plink {
  color: var(--dim);
  text-decoration: none;
  font-size: 13px;
  letter-spacing: 0.02em;
  /* instant, for the same reason as the corner buttons */
}

.plink:hover, .plink:focus-visible { color: var(--accent); outline: none; }

.panel-desc {
  margin: 10px 0 18px;
  color: var(--dim);
  font-size: 13.5px;
  max-width: 56ch;
}

.term {
  display: flex;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--line);
  background: var(--surface);
}

.term-lines {
  flex: 1;
  padding: 13px 16px;
  overflow-x: auto;
  white-space: nowrap;
}

.term-line {
  display: block;
  font-family: inherit;
  font-size: 13px;
  line-height: 2;
  color: var(--ink);
}

.term-line::before {
  content: "$ ";
  color: var(--accent-ink, var(--accent));
  text-shadow: 1px 1px 0 var(--shadow);
}

.copy-btn {
  flex: 0 0 auto;
  align-self: center;
  margin: 0 12px;
  padding: 9px 14px;
  font-family: "Silkscreen", monospace;
  font-size: 11px;
  color: var(--amber-ink);
  background: var(--accent);
  border: 0;
  cursor: pointer;
  box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
  transition: transform 0.1s steps(2), box-shadow 0.1s steps(2);
}

.copy-btn:hover { filter: brightness(1.08); }

.copy-btn:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.55);
}

.copy-btn.done { background: #7de8b8; }

.copy-btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.panel-note {
  margin: 14px 0 0;
  color: var(--faint);
  font-size: 12px;
  max-width: 64ch;
}

/* ----- footer ----- */

/* The page opens every section with a dotted rule and a label. It used to end
   on a single dim run-on line with a middot doing all the structural work, so
   it closes on the same rule instead: sign-off left, link right. */

/* No position/z-index here. main is already position:relative z-index:2, so
   everything inside it paints above the veil and this only ever duplicated
   that. What it did do was make the footer its own stacking context, which
   Chrome then promoted to its own compositing layer (reason: Overlap, against
   the #front canvas). Text on a separately composited layer with a
   transparent background loses subpixel antialiasing and is re-rasterized
   whenever the layer is re-composited, so the letters and the dotted rule
   shimmered while flat fills sat still. The rows never had it, and never
   shimmered. */
footer {
  /* 110px here plus the 200px of padding below left a chasm between the last
     section and the sign-off; a section-sized gap is enough to separate them */
  margin-top: 64px;
}

/* .block-rule is reused verbatim from the section heads; as a block child its
   flex properties simply don't apply and the dotted pattern is all that's left */

.foot-main {
  display: flex;
  /* was baseline, which is what two lines of text want. The right-hand side is
     a row of 38px marks now, and a mark has no baseline worth sharing. */
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 6px 20px;
  margin-top: 16px;
}

.foot-sign {
  margin: 0;
  font-family: "Silkscreen", monospace;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
}

footer a {
  color: var(--dim);
  text-decoration: none;
  font-size: 12px;
  letter-spacing: 0.02em;
}

footer a:hover, footer a:focus-visible { color: var(--amber); outline: none; }

.legal {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  margin-top: 14px;
}

/* ----- the footer's accounts -----
   One mark each, from TINUX_SOCIALS in config.js, where the footer used to
   spell out a single GitHub handle in text.

   Bigger than the marks on the cards, 20px in a 38px box against 16px in 30px,
   because here they are the thing rather than a detail on something else. And
   they wear the control chrome from the start, the way #sky does: a card's
   mark can afford to stay bare until its row is hovered, but these have no row
   to bring them out, and a bare glyph in a footer reads as decoration. */

.socials {
  display: flex;
  align-items: center;
  gap: 10px;
}

.social {
  position: relative;
  display: flex;
  padding: 8px;
  line-height: 0;
  color: var(--dim);
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 3px 3px 0 var(--drop);
  transition: color 0.18s steps(3), border-color 0.18s steps(3),
              transform 0.1s steps(2), box-shadow 0.1s steps(2);
}

.social svg {
  display: block;
  width: 20px;
  height: 20px;
  fill: currentColor;
}

.social:hover,
.social:focus-visible {
  color: var(--amber);
  border-color: var(--amber);
}

/* footer a:focus-visible turns the global ring off, which is right for a text
   link that recolours and wrong for a mark that has nothing else to say it is
   focused, so it comes back here */
.social:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: 3px;
}

.social:active {
  transform: translate(2px, 2px);
  box-shadow: 1px 1px 0 var(--drop);
}

/* the handle these replaced, back on hover. Same label the cards' marks use */
.social::after {
  content: attr(data-tip);
  position: absolute;
  right: -1px;
  bottom: calc(100% + 9px);
  padding: 5px 7px;
  font-family: "Silkscreen", monospace;
  font-size: 9px;
  letter-spacing: 0.08em;
  line-height: 1;
  color: var(--dim);
  background: var(--control);
  border: 1px solid var(--line);
  box-shadow: 3px 3px 0 var(--drop);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.12s steps(2);
}

.social:hover::after,
.social:focus-visible::after { opacity: 1; }

/* Revealed by app.js on the first keypress, never before: a mouse user has no
   use for it. Real <kbd> elements now, drawn as keycaps with the same hard
   offset shadow the copy button uses. */

.foot-keys {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 22px;
  margin: 20px 0 0;
  padding: 0;
  list-style: none;
  animation: rise 0.4s steps(4) backwards;
}

/* display:flex above would otherwise beat the [hidden] default and the legend
   would show before anyone touched a key */
.foot-keys[hidden] { display: none; }

.foot-keys li {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--faint);
}

.foot-keys kbd {
  min-width: 20px;
  padding: 3px 5px;
  font-family: "Silkscreen", monospace;
  font-size: 10px;
  line-height: 1;
  text-align: center;
  color: var(--dim);
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
}

.foot-keys kbd + kbd { margin-left: -3px; }

/* ----- motion & small screens ----- */

@keyframes rise {
  from { opacity: 0; transform: translateY(10px); }
}

a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (max-width: 640px) {
  main { padding-bottom: 170px; }
  /* .row wraps at every width now; this is only the tighter row gap. */
  .row { gap: 6px 18px; }
  .row-desc { flex-basis: 100%; order: 3; }
  .block-blurb { display: none; }
  .term { flex-direction: column; }
  .copy-btn { align-self: stretch; margin: 0; padding: 12px; }
  .expand.featured .row-toggle { padding: 16px 14px; }
  .expand.featured .row-name { font-size: 16px; }
  .expand.featured .expand-inner { padding: 0 14px 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .block, .row, .panel { animation: none; }
  .intro-icon, .intro-text, .intro-skip,
  #intro.out .intro-icon, #intro.out .intro-text,
  #intro.out .intro-skip { animation: none; }
  #intro { transition-delay: 0s; }
  html.ready .masthead h1, html.ready .tagline, html.ready footer { animation: none; }
  .row, .row-go, .copy-btn, .plink, #snd, #sky, #nocturna, .expand-detail, .expand,
  .expand.featured { transition: none; } /* (0,2,0), to outrank the card's own */
  #nocturna .beacon { animation: none; }
  #page-fade { transition: none; }
}
