/*
Description: cyburdine cyberpunk terminal site stylesheet
SPDX-FileCopyrightText: © 2025 Justin Burdine <justin@cyburdine.com>
SPDX-License-Identifier: BSD-3-Clause
*/

/* Self-hosted fonts (avoids third-party CDN dependency) */
@font-face {
  font-family: 'VT323';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/VT323.woff2') format('woff2');
}

@font-face {
  font-family: 'Noto Sans JP';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/NotoSansJP-subset.woff2') format('woff2');
}

/* Clean-mode body font — readable monospace once you're "through the screen". */
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/assets/fonts/IBMPlexSans-Regular.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/assets/fonts/IBMPlexSans-Medium.woff2') format('woff2');
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/assets/fonts/IBMPlexSans-SemiBold.woff2') format('woff2');
  font-weight: 600; font-style: normal; font-display: swap;
}

@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/IBMPlexMono-Regular.woff2') format('woff2');
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/IBMPlexMono-Medium.woff2') format('woff2');
}

/* ════════════════════════════════════════════════════════════════
   DESIGN TOKENS — consumed by CLEAN MODE only (html.cy-clean …).
   Inert to the CRT/boot rules, which keep their own literal hex, so
   changing these cannot shift the intro's appearance.

   THEME: "Instrument" — the site as a piece of lab equipment.
   Cold blue-graphite ground, amber as the signal colour, cyan for
   data/links, prose in a proportional face and mono demoted to
   labels and readouts.
   ════════════════════════════════════════════════════════════════ */
:root {
  /* palette */
  --cy-bg:      #0a0f14;   /* cold blue-graphite ground */
  --cy-fg:      #d6e4ec;   /* body text — proportional, near-white */
  --cy-fg-dim:  #7d95a3;   /* meta / labels / secondary */
  --cy-link:    #4fd6d6;   /* cyan = data + interactive */
  --cy-accent:  #ffb020;   /* amber = signal / status */
  --cy-white:   #ffffff;
  --cy-rule:    #1e2c37;   /* frame + divider lines */
  --cy-rule-lit:#2f4757;   /* brighter rule on hover/focus */
  --cy-panel:   rgba(79, 214, 214, 0.045);  /* faint card fill */

  /* type — mono is now a LABEL face, not the body face */
  --cy-font-display: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
  --cy-font-body:    'IBM Plex Sans', system-ui, -apple-system, sans-serif;
  --cy-font-mono:    'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  --cy-fs-body: 18px;
  --cy-lh:      1.7;
  --cy-fs-h1:   2.35rem;
  --cy-fs-h2:   1.6rem;
  --cy-fs-h3:   1.25rem;
  --cy-fs-meta: 0.72rem;
  --cy-tracking-meta: 0.18em;
  --cy-panel-pad: clamp(1.25rem, 3vw, 2.5rem); /* <main>'s inset — see .cy-plate */
  /* Reading column. This is the OUTER cap: .terminal-screen's padding and the
     article panel's --cy-panel-pad both come out of it, so at 42rem the prose
     itself was only ~33rem (~55 characters) wide and read as a thin strip.
     72rem leaves ~63rem of text on a desktop screen. */
  --cy-measure: 72rem;
  --cy-measure-wide: 74rem;/* gallery column — pages with data-cy-layout="wide" */

  /* spacing rhythm (8px base) */
  --cy-sp-1: 0.5rem;
  --cy-sp-2: 1rem;
  --cy-sp-3: 1.5rem;
  --cy-sp-4: 2rem;
  --cy-sp-5: 3rem;
  --cy-sp-6: 4rem;
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #000; /* black behind frame if viewport is larger */
  overflow-x: hidden;
  color: #15ff00;
  font-family: 'VT323', monospace;
}

/* Console text defaults */
.console {
  white-space: pre;       /* preserve line breaks exactly */
  overflow-x: auto;       /* horizontal scroll if lines exceed width */
  word-break: break-word; /* break long words/URLs if needed */
  color: #15ff00;
}

/* Link styling */
a {
  margin-right: 20px;
  color: #33ffcc;
  text-transform: uppercase;
  font-weight: bold;
  letter-spacing: 1px;
  text-decoration: none;
}

a:hover {
  color: #FFFFFF;
  text-shadow:
    0 0 4px currentColor,  /* small inner glow using each element’s own color */
    0 0 8px currentColor;  /* larger outer glow */
}

/* Scanline overlay */
body::before {
  content: "";
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.0) 0px,
    rgba(0, 0, 0, 0.08) 1px,
    transparent 1px,
    transparent 2px
  );
  pointer-events: none;
  z-index: 1005;
  mix-blend-mode: overlay;
  animation: flicker 1s infinite;
}

@keyframes flicker {
  0% { opacity: 0.03; }
  5% { opacity: 0.1; }
  10% { opacity: 0.03; }
  15% { opacity: 0.06; }
  20% { opacity: 0.02; }
  100% { opacity: 0.05; }
}

.char {
  font-family: 'VT323', monospace;
  display: inline;
  transition: font-family 0.15s ease;
    text-shadow:
    0 0 4px currentColor,  /* small inner glow using each element’s own color */
    0 0 8px currentColor;
}

.char.foreign {
  color: #ffffff;
  font-family: var(--foreign-font), monospace;
    text-shadow:
    0 0 4px currentColor,  /* small inner glow using each element’s own color */
    0 0 8px currentColor;
}

/* Terminal frame: fixed at native 3000×1688 px */
.terminal-container {
  position: relative;
  width: 3000px;
  height: 1688px;
  background-size: auto;
  overflow: hidden;
  box-sizing: border-box;
  margin: 0;
  z-index: 1000;
}

/* make the frame sit on top of the screen */
.terminal-frame {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: auto; /* preserves aspect ratio */
  z-index: 1001; /* higher than .terminal-screen */
  pointer-events: none; /* clicks pass through */
}

/* Easter eggs: invisible, clickable links over keypad buttons (percentages of
   the 3000×1688 frame, so they scale with the container). Clicking one replays
   the boot sequence — see boot.js. */
.cy-egg {
  position: absolute;
  z-index: 1002;          /* above the frame, so it receives clicks */
  cursor: pointer;
}
/* "4" key — middle keypad, top-left */
.cy-egg--four { left: 47.8%; top: 39%;   width: 6.2%; height: 10.5%; }
/* "D" key — lower-left keypad, top row, 3rd from left (reachable on phones) */
.cy-egg--d    { left: 20%;   top: 76.5%; width: 4.6%; height: 9.5%; }

/* Inner “screen” window */
.terminal-screen {
  position: absolute;
  top: 56px;       /* 3.306% of 1688 */
  left: 65px;      /* 3.102% of 3000 */
  width: 1469px;   /* 48.963% of 3000 */
  height: 1069px;  /* 63.381% of 1688 */
  z-index: 1000;
  overflow-y: auto;
  overflow-x: auto;
  /* phosphor-green CRT screen (matches the boot console) */
  background: var(--crt-bg);
  color: #15ff00;
  padding: 1rem;
  box-sizing: border-box;
}

/* The glitch canvas fills the entire .terminal-screen */
#glitchCanvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 100000; /* higher than any text inside .terminal-screen */
}

/* 2) PURE‐CSS “SCANLINES” OVERLAY (no PNG needed) */
.terminal-screen::before {
  content: "";
  position: absolute;
  inset: 0; /* shorthand for top:0; left:0; right:0; bottom:0; */
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.2),    /* scanline color & opacity (tweak only this if needed) */
    rgba(0, 0, 0, 0.3) 1px, /* 1px thick dark line */
    transparent 1px,       /* start transparent gap */
    transparent 1px        /* total cycle = 4px (1px line + 3px gap) */
  );
  pointer-events: none; /* let clicks/pass-through go to the text below */
  z-index: 1002;           /* sits above the background but below the text */
}

/* 3) NEON‐GLOW FOR WHATEVER LIVES INSIDE .terminal-screen */
/*    Here we assume your terminal text uses the existing .console class */
.terminal-screen .console {
  position: relative;
  text-shadow:
    0 0 4px currentColor,  /* small inner glow using each element’s own color */
    0 0 8px currentColor;  /* larger outer glow */
	z-index: 1003; /* ensure it sits above the scanlines (::before is z-index:1) */
}

/* Keep console formatting inside screen */
.terminal-screen .console {
  white-space: pre;
  overflow-x: auto;
}

/* Dark scrollbar for terminal-screen */
.terminal-screen::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

.terminal-screen::-webkit-scrollbar-track {
  background: #111;
}

.terminal-screen::-webkit-scrollbar-thumb {
  background-color: #333;
  border-radius: 4px;
  border: 2px solid #111;
}

.terminal-screen::-webkit-scrollbar-thumb:hover {
  background-color: #555;
}

/* Firefox scrollbar */
.terminal-screen {
  scrollbar-width: thin;
  scrollbar-color: #333 #111;
}

/* Footer fixed bottom-left in small font */
footer {
  position: fixed;
  bottom: 0;
  left: 0;
  font-size: 0.5rem;
  color: #888;
  padding: 0.25rem 0.5rem;
  background: transparent;
  z-index: 1000;
}
/* Footer "reboot" link — reads as the dim footer text until hovered. */
.cy-reboot,
html.cy-clean .cy-reboot { color: inherit; text-decoration: none; cursor: pointer; }
.cy-reboot:hover,
html.cy-clean .cy-reboot:hover { color: #33ffcc; text-decoration: underline; }

/* ─── NAVBAR WITH GLOWING BACKGROUND ─── */

nav.console {
  background-color: #008080;   /* teal bar */
  display: flex;
  justify-content: left;
  gap: 0.25rem;
  padding: 0.25rem 0;
  white-space: nowrap;
  padding-left: 2rem;
  text-shadow:
    0 0 4px white,  /* small inner glow using each element’s own color */
    0 0 8px white;

  /* add a soft glow around the bar: */
  box-shadow:
    0 0 8px rgba(0, 255, 255, 0.6),   /* inner-ish glow */
    0 0 16px rgba(0, 255, 255, 0.4),  /* mid-range glow */
    0 0 32px rgba(0, 255, 255, 0.2);  /* outer faint glow */
}

nav.console a {
  color: #000000;            /* black text (no glow) */
  text-decoration: none;
  text-shadow: none;         /* ensure links do not glow */
  font-weight: bold;
}

/* optional hover fade */
nav.console a:hover {
  color: white;
  opacity: 1;
  text-shadow:
    0 0 4px white,  /* small inner glow using each element’s own color */
    0 0 8px white;
}

/* ───── Add “.glow” for neon-shadow only ───── */
.glow {
  /* keep whatever font/color you already have */
  text-shadow:
    0 0 4px currentColor,
    0 0 8px currentColor;
}

.project {
  /* keep whatever font/color you already have */
  text-shadow:
    0 0 4px currentColor,
    0 0 8px currentColor;
  text-align: justify;
  text-justify: inter-word;
  max-width: 100%;
  white-space: pre-wrap;         /* respect existing line breaks, but wrap long lines */
  overflow-wrap: break-word;     /* break words if they’re too long to fit */
}

/* make sure .terminal-screen is positioned for its overlay */
.terminal-screen {
  position: relative;
}

/* ─── RESPONSIVE WRAPPER ─── */

.terminal-wrapper {
  width: 100%;
  overflow: hidden;
  background: #000;
}

/* ─── RESPONSIVE FONT COUNTER-SCALING ─── */
/* The container is scaled by responsive.js via transform: scale(S), which also
   shrinks the in-screen text. To keep text a roughly constant on-screen size at
   any zoom, counter-scale the font by 1/S: rendered = (16px / S) * S = 16px.
   responsive.js publishes the live scale as --cy-scale. */
.terminal-screen {
  /* Counter-scale to ~16px effective, but cap the unscaled size so long lines
     still fit the visible screen. responsive.js sets --cy-fontcap per layout
     (tighter on phones, where we zoom into the narrower CRT glass). */
  font-size: min(calc(16px / var(--cy-scale, 1)), var(--cy-fontcap, 52px));
}

/* Tablet: 768px–1023px */
@media (max-width: 1023px) {
  nav.console {
    padding-left: 1rem;
  }

  nav.console a {
    margin-right: 12px;
  }
}

/* Mobile: <768px */
@media (max-width: 767px) {
  nav.console {
    padding-left: 0.5rem;
  }

  nav.console a {
    margin-right: 8px;
  }
}

/* ════════════════════════════════════════════════════════════════
   BOOT SEQUENCE  (landing page, first visit only — see boot.js)
   Cold start → wide shot → zoom into monitor → CRT power-on →
   Cyburdine Systems boot log → handoff to the live site.
   ════════════════════════════════════════════════════════════════ */

/* The .terminal-screen element is wider than the visible transparent screen
   "hole" in the frame art — it extends ~27.8% further right, under the bezel.
   These insets describe the actual CRT glass within .terminal-screen so the
   boot overlays line up with (and center inside) the visible screen.
   Measured from the frame image's alpha channel. */
.terminal-screen {
  --glass-top: 1.5%;
  --glass-left: 0.14%;
  --glass-right: 27.8%;
  --glass-bottom: 3.55%;
  /* The CRT "phosphor green" screen background — shared by the boot console
     and the live site so the screen always glows green inside the tube. */
  --crt-bg: radial-gradient(ellipse at center,
            rgba(8, 26, 12, 0.96) 0%,
            rgba(2, 10, 4, 0.99) 100%);
}

/* Set synchronously by boot.js (on <html>) before first paint, so the
   final zoomed-in view never flashes before the wide shot is positioned. */
html.cy-booting,
html.cy-booting body {
  overflow: hidden;
  height: 100%;
}
.cy-booting .terminal-wrapper {
  background: #000;
}
.cy-booting .terminal-container {
  opacity: 0;     /* JS fades this to 1 during the cold start */
}

/* Black field that covers the whole viewport during the cold start +
   wide shot, so nothing leaks before the camera pushes in. */
.boot-active .terminal-wrapper {
  background: #000;
}

/* ── CRT "off" layer: an opaque black sheet over the screen during the
   wide shot, hiding the boot console until the tube powers on. ── */
.crt-off {
  position: absolute;
  top: var(--glass-top); left: var(--glass-left);
  right: var(--glass-right); bottom: var(--glass-bottom);
  background: #000;
  z-index: 90000;       /* above boot console, below glitch canvas */
  pointer-events: none;
}

/* ── The power-on flash: a white sheet that snaps from a center line to
   a full-screen bloom, mimicking an old CRT energising. ── */
.crt-flash {
  position: absolute;
  top: var(--glass-top); left: var(--glass-left);
  right: var(--glass-right); bottom: var(--glass-bottom);
  background: #eafff0;
  transform-origin: center center;
  transform: scaleY(0);
  opacity: 0;
  z-index: 90001;
  pointer-events: none;
  box-shadow: 0 0 60px 20px rgba(180, 255, 200, 0.8);
}

.crt-flash.fire {
  animation: crt-power-on 0.62s ease-out forwards;
}

@keyframes crt-power-on {
  0%   { transform: scaleY(0) scaleX(1);     opacity: 1; }
  10%  { transform: scaleY(0.006) scaleX(1.25); opacity: 1; }   /* hairline */
  28%  { transform: scaleY(0.012) scaleX(1.1);  opacity: 1; }   /* line holds */
  55%  { transform: scaleY(1) scaleX(1);     opacity: 1; }      /* bloom */
  75%  { transform: scaleY(1) scaleX(1);     opacity: 0.85; }
  100% { transform: scaleY(1) scaleX(1);     opacity: 0; }      /* settle */
}

/* Subtle on-settle flicker for the screen as the tube stabilises. */
@keyframes crt-settle-flicker {
  0%   { opacity: 0.2; }
  20%  { opacity: 1; }
  35%  { opacity: 0.6; }
  50%  { opacity: 1; }
  70%  { opacity: 0.85; }
  100% { opacity: 1; }
}

/* ── The boot console: the green CRT screen showing the logo + log. ── */
.boot-console {
  position: absolute;
  top: var(--glass-top); left: var(--glass-left);
  right: var(--glass-right); bottom: var(--glass-bottom);
  z-index: 80000;       /* above page content, below crt-off/flash */
  background: var(--crt-bg);
  background-color: #020a04;   /* opaque floor: --crt-bg alone is 96–99% alpha */
  color: #15ff00;
  font-family: 'VT323', monospace;
  padding: 1.4rem 1.6rem;
  box-sizing: border-box;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  text-shadow: 0 0 4px currentColor, 0 0 8px currentColor;
}

/* Intro: only the logo, centered in the monitor. When the logo slides up,
   `.intro` is removed (flex-start) but the body stays hidden until
   `.show-body` is added a beat later. */
.boot-console.intro {
  justify-content: center;
}

/* Boot body (BIOS header + log) — hidden until revealed under the logo. */
.boot-body {
  display: none;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  opacity: 0;
  transition: opacity 0.6s ease;
}
.boot-console.show-body .boot-body {
  display: flex;
  opacity: 1;
}

/* CRT scanlines across the whole boot screen (logo + text), so the logo reads
   as the same phosphor display as the letters. em-based so the line spacing is
   consistent at any scale. */
.boot-console::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 5;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.38) 0,
    rgba(0, 0, 0, 0.38) 0.045em,
    transparent 0.045em,
    transparent 0.16em
  );
}

.boot-console.powered {
  animation: crt-settle-flicker 0.7s ease-out;
}

/* "Screen goes blank": the logo + boot log clear and the tube holds a black
   screen (monitor still on) before the site is brought up. */
.boot-console.blank {
  background: #000;
}
.boot-console.blank.logo-in .boot-logo-wrap,
.boot-console.blank.show-body .boot-body {
  transition: opacity 0.35s ease-out;
  opacity: 0;
}

/* Main page is held hidden during boot; the blank black console clears to
   reveal it from black. !important because render.js leaves an inline
   `visibility: visible` on <main> after a render, which would otherwise win
   on a replay and play the boot over the live gallery. */
.boot-active main,
.boot-active header,
.boot-active footer {
  visibility: hidden !important;
}

/* BIOS-style header strip. Boot console fonts use `em` so they inherit the
   screen's counter-scaled (and phone-capped) size — otherwise fixed rem text
   becomes unreadably tiny when the monitor is scaled down on a phone. */
.boot-bios {
  font-size: 1.1em;
  line-height: 1.25;
  color: #aaffcc;
  white-space: pre-wrap;
  margin-bottom: 0.4rem;
}
.boot-bios .hl { color: #33ffcc; }

/* Logo block — image when present, styled text as graceful fallback.
   align-self:center keeps it horizontally centered while the boot body
   below it stretches full-width (left-aligned log lines). */
.boot-logo-wrap {
  display: flex;
  align-items: center;
  gap: 1.2rem;
  margin: 0.3rem 0 0.6rem;
  align-self: center;
  opacity: 0;                 /* hidden until the tube "warms up" */
}
/* Logo fades in like a CRT warming up — a dim flicker rising to full glow.
   No `forwards`: opacity rests at 1 via the rule, so the blank state can
   still fade it out later. */
.boot-console.logo-in .boot-logo-wrap {
  opacity: 1;
  animation: logo-warmup 1.4s ease-out;
}
@keyframes logo-warmup {
  0%   { opacity: 0;    filter: brightness(0.25); }
  35%  { opacity: 0.5;  filter: brightness(0.7); }
  48%  { opacity: 0.28; filter: brightness(0.5); }   /* warm-up flicker */
  70%  { opacity: 0.9;  filter: brightness(0.9); }
  100% { opacity: 1;    filter: brightness(1); }
}
@media (prefers-reduced-motion: reduce) {
  .boot-console.logo-in .boot-logo-wrap { animation: none; }
}
.boot-logo-img {
  height: 9em;                      /* header size; scales with the font */
  width: auto;
  transition: height 0.6s ease;     /* shrinks as it slides up to the top */
  image-rendering: pixelated;       /* chunky low-res pixels, like the CRT text */
  filter: drop-shadow(0 0 8px rgba(21, 255, 0, 0.55));
}
/* Bigger "hero" size while centered, before the slide. */
.boot-console.intro .boot-logo-img {
  height: 18em;
}
.boot-logo-text {
  font-size: 1.8em;
  line-height: 1;
  letter-spacing: 2px;
  color: #15ff00;
}
.boot-logo-text small {
  display: block;
  font-size: 0.7em;
  letter-spacing: 6px;
  color: #33ffcc;
}

/* The scrolling boot log — 1em matches the site text, so it fits wherever the
   site content fits (including the phone cap above). */
.boot-log {
  flex: 1 1 auto;
  font-size: 1em;
  line-height: 1.3;
  white-space: pre-wrap;
  overflow: hidden;
}
.boot-log .ok   { color: #33ffcc; }
.boot-log .warn { color: #ffcc33; }
.boot-log .dim  { color: #0aa000; }
.boot-log .bar  { color: #15ff00; }

/* Blinking cursor used while the log "types" */
.boot-cursor::after {
  content: "█";
  margin-left: 2px;
  animation: boot-blink 0.7s steps(1) infinite;
}
@keyframes boot-blink {
  0%, 50% { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* Honour reduced-motion: boot.js skips the cinematics, but make sure no
   stray animation runs if these classes are applied. */
@media (prefers-reduced-motion: reduce) {
  .crt-flash.fire,
  .boot-console.powered,
  .boot-cursor::after { animation: none; }
}

/* ════════════════════════════════════════════════════════════════
   BOOT → SITE and THROUGH-THE-SCREEN (driven by boot.js).
   No dissolve: the boot log CLEARS (erased top→down, old-computer style) then
   the site REDRAWS (drawn back line-by-line). Then charZoom zooms INTO a real
   character of the page, glitches it electronically, and zooms back out clean.
   ════════════════════════════════════════════════════════════════ */

/* SCAN-BLOOM — CRT scanlines that glow and BLUR more and more as we zoom into
   the letter, building to an immense glow as we pass through the screen, then
   fading as the clean letter emerges. Viewport-fixed, so its blur is in screen
   space (predictable), unlike a filter on the deeply-zoomed content. */
.cy-scanbloom {
  position: fixed; inset: 0; z-index: 100040; pointer-events: none;
  background: repeating-linear-gradient(to bottom,
    rgba(150,255,190,0) 0, rgba(150,255,190,0) 30%,
    rgba(150,255,190,0.9) 50%, rgba(150,255,190,0) 70%);
  background-size: 100% 10px;
  opacity: 0;
  will-change: opacity, filter, background-size;
  animation: cy-scanbloom-anim 2600ms ease-in-out forwards;
}
@keyframes cy-scanbloom-anim {
  0%   { opacity: 0;    filter: blur(0) brightness(1);       background-size: 100% 10px; }
  54%  { opacity: 0.95; filter: blur(15px) brightness(2.5);  background-size: 100% 66px; }  /* immense glow @ apex */
  100% { opacity: 0;    filter: blur(52px) brightness(3.6);  background-size: 100% 140px; }
}

/* The letter's own glow surges immense at the apex, then settles. Brightness
   only — a blur here would be multiplied by the huge zoom into a total white-out. */
main.cy-bloom-in,
.boot-logo-wrap.cy-bloom-in { animation: cy-content-bloom 2600ms ease-in-out forwards; }
@keyframes cy-content-bloom {
  0%   { filter: brightness(1); }
  54%  { filter: brightness(3.4); }
  100% { filter: brightness(1); }
}

/* Light burst as we break through into the clean letter — blooms and fades. */
.cy-flash {
  position: fixed; inset: 0; z-index: 100050;
  pointer-events: none; transform-origin: 50% 50%;
  background: radial-gradient(circle at 50% 50%,
    rgba(238,255,244,0.97) 0%, rgba(130,255,180,0.55) 24%, rgba(0,0,0,0) 64%);
  opacity: 0;
  will-change: opacity, transform;
  animation: cy-flash-anim 0.85s ease-out forwards;
}
@keyframes cy-flash-anim {
  0%   { opacity: 0;    transform: scale(0.35); }
  20%  { opacity: 1;    transform: scale(1); }
  100% { opacity: 0;    transform: scale(2.1); }
}

/* Digital noise bursting with the flash — like passing INTO the monitor. Painted
   frame-by-frame on a canvas by boot.js; sits above the flash. */
.cy-noise {
  position: fixed; top: 0; left: 0;
  width: 100vw; height: 100vh;
  z-index: 100055; pointer-events: none;
  opacity: 0;
  animation: cy-noise-anim 780ms ease-out forwards;
}
@keyframes cy-noise-anim {
  0%   { opacity: 0; }
  16%  { opacity: 0.6; }
  55%  { opacity: 0.42; }
  100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .cy-flash, .cy-scanbloom, .cy-noise { display: none; }
  main.cy-bloom-in, .boot-logo-wrap.cy-bloom-in { animation: none !important; }
}

/* ════════════════════════════════════════════════════════════════
   THROUGH-THE-SCREEN — seamless two-stage dissolve (no static).
   Stage 1 (cy-reveal): the real site shows IN the tube, already in the clean
   font (so nothing re-typesets later), CRT skin still on.
   Stage 2 (cy-dissolve): the CRT skin (glow, scanlines, bezel) melts away as
   the camera pushes in. The screen bg is already near-black, so only the glow,
   lines and frame need to fade — a true dissolve. Then boot.js FLIP-settles the
   same text into the clean column.
   ════════════════════════════════════════════════════════════════ */

/* Shared clean font from the reveal onward ⇒ no font swap mid-transition, so
   the FLIP settle is a pure scale and the text lines up. */
html.cy-reveal main, html.cy-reveal pre, html.cy-reveal .console,
html.cy-dissolve main, html.cy-dissolve pre, html.cy-dissolve .console {
  font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}

/* Stage 2 dissolve: melt the CRT skin. */
html.cy-dissolve .terminal-frame { opacity: 0; transition: opacity 1s ease-out; }
html.cy-dissolve #glitchCanvas   { opacity: 0; transition: opacity 0.5s ease-out; }
html.cy-dissolve .terminal-screen::before { opacity: 0; transition: opacity 0.9s ease-out; }
/* Global scanlines run a flicker animation; animation overrides plain opacity,
   so stop it AND fade out. */
html.cy-dissolve body::before { animation: none !important; opacity: 0 !important; transition: opacity 0.6s ease-out; }
html.cy-dissolve .terminal-screen { background: var(--cy-bg); }  /* == clean ground */
/* Match the clean background everywhere so the landing swap has no bg edge. */
html.cy-dissolve, html.cy-dissolve body, html.cy-dissolve .terminal-wrapper { background: var(--cy-bg); }
html.cy-dissolve .glow, html.cy-dissolve .console, html.cy-dissolve pre,
html.cy-dissolve nav.glow, html.cy-dissolve nav a, html.cy-dissolve a,
html.cy-dissolve .char {
  text-shadow: none !important;
  transition: text-shadow 0.7s ease-out, color 1s ease-out;
}
html.cy-dissolve pre, html.cy-dissolve .glow { color: var(--cy-fg); }  /* settle onto the clean body colour */
html.cy-dissolve footer { opacity: 0; transition: opacity 0.4s ease-out; }  /* don't fly the footer */

@media (prefers-reduced-motion: reduce) {
  .cy-fx { display: none !important; animation: none !important; }
}

/* ════════════════════════════════════════════════════════════════
   CLEAN MODE — the real site once you've come "through the screen".
   No CRT frame, scanlines, glow, or glitch. Readable centered column.
   Gated entirely behind html.cy-clean so the CRT rules are untouched.
   ════════════════════════════════════════════════════════════════ */
html.cy-clean,
html.cy-clean body {
  height: auto;
  overflow: visible;
  /* The CRT stage counter-scales the root font-size (16px / --cy-scale) so the
     fixed 3000px terminal fits the viewport. Clean mode has no such stage, and
     leaving that in place made every rem in the theme — measures, spacing,
     type scale — silently shrink by whatever the scale happened to be. Pin it. */
  font-size: 16px;
  background: var(--cy-bg);
  color: var(--cy-fg);               /* softer, high-contrast green */
  font-family: var(--cy-font-body);
}

/* Kill the fixed CRT stage + frame + scanlines + glitch. */
html.cy-clean .terminal-frame,
html.cy-clean #glitchCanvas,
html.cy-clean .cy-egg,
html.cy-clean body::before,
html.cy-clean .terminal-screen::before { display: none !important; }

html.cy-clean .terminal-wrapper {
  height: auto !important;
  background: var(--cy-bg);
  overflow: visible;
}
html.cy-clean .terminal-container {
  position: static;
  width: 100%;
  height: auto;
  transform: none !important;
  overflow: visible;
  z-index: auto;
}
html.cy-clean .terminal-screen {
  position: relative;                /* anchors the measurement rail */
  top: auto; left: auto;
  width: 100%;
  max-width: var(--cy-measure);      /* proportional measure, not 72ch of mono */
  height: auto;
  margin: 0 auto;                    /* centred column */
  padding: 3rem 1.5rem 4rem 2.25rem; /* extra left padding clears the rail */
  background: transparent;
  overflow: visible;
  font-size: var(--cy-fs-body);      /* no counter-scaling in clean mode */
  line-height: var(--cy-lh);
}

/* Body copy lives inside <pre>, and the UA stylesheet forces `monospace` on it,
   so the proportional face has to be pinned explicitly to reach the content.
   This is the single most important rule in the theme: 1,900-word articles are
   not readable in a monospace face. Mono survives on labels and readouts only. */
html.cy-clean pre,
html.cy-clean .console,
html.cy-clean main {
  font-family: var(--cy-font-body);
  font-size: var(--cy-fs-body);
  line-height: var(--cy-lh);
  font-weight: 400;
}

/* Strip all neon glow. */
html.cy-clean .glow,
html.cy-clean .console,
html.cy-clean .project,
html.cy-clean .char,
html.cy-clean .char.foreign,
html.cy-clean a,
html.cy-clean a:hover { text-shadow: none !important; }

/* Readable links + accents (palette kept, glow removed). */
html.cy-clean a { color: var(--cy-link); }
html.cy-clean a:hover { color: var(--cy-white); text-decoration: underline; }

/* Headings: tight grotesque, near-white. Colour is spent on the accents
   (amber signal, cyan data), never on the headings themselves. */
html.cy-clean h1, html.cy-clean h2, html.cy-clean h3,
html.cy-clean .boot-logo-text {
  font-family: var(--cy-font-display);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.1;
  color: var(--cy-fg);
  text-wrap: balance;
}

/* Nav: flat readable bar (the real nav is `.glow`, not `.console`), glow off. */
html.cy-clean nav {
  box-shadow: none;
  text-shadow: none;
  display: flex;
  gap: 1.5rem;
  padding: 0 0 1rem;
  margin-bottom: 1.5rem;
  border-bottom: 1px solid var(--cy-rule);
}

/* Let long pre content wrap so it stays readable without horizontal scroll.
   overflow-x:visible removes the base .console scroll container (content
   already wraps), so no spurious 1px scrollbar and frame corner brackets
   aren't clipped. */
html.cy-clean pre,
html.cy-clean .console { white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; }

/* Reading mode is deliberately still: no scanline flicker, no RGB-split on the
   text. The CRT character lives in the boot cinematic and the render effect —
   once you are through the screen and actually reading, nothing moves. Any
   rule that animates `body::after` or `main` here belongs in the boot stage
   instead; it fights a 2,000-word article. */

/* Blinking terminal cursor — placed inline right after the last character of the
   content by boot.js (placeCursor), so it hugs the last line as if just typed. */
.cy-cursor {
  display: inline-block;
  margin-left: 0.08em;
  color: var(--cy-fg);
  animation: cy-cursor-blink 1.1s steps(1, end) infinite;
}
@keyframes cy-cursor-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .cy-cursor { animation: none; } }

/* ════════════════════════════════════════════════════════════════
   CLEAN MODE — COMPONENT VOCABULARY  (reading experience only)
   The "refined terminal OS": type hierarchy, spacing rhythm, chrome
   primitives, and styled components. Every selector is gated behind
   html.cy-clean so the CRT/boot/render stages are untouched. All
   "chrome" (frames, ▸ markers, dividers) is drawn in CSS — never as
   literal text in <main>, which render.js would type out glyph-by-glyph.
   ════════════════════════════════════════════════════════════════ */

/* ── Type scale (VT323 display headings, sized up for its small x-height) ── */
html.cy-clean main h1 {
  font-family: var(--cy-font-display);
  font-size: var(--cy-fs-h1);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cy-fg);
  margin: 0 0 var(--cy-sp-3);
}
html.cy-clean main h2 {
  font-family: var(--cy-font-display);
  font-size: var(--cy-fs-h2);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cy-fg);
  margin: var(--cy-sp-5) 0 var(--cy-sp-2);
}
html.cy-clean main h3 {
  font-family: var(--cy-font-display);
  font-size: var(--cy-fs-h3);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cy-fg);
  margin: var(--cy-sp-4) 0 var(--cy-sp-1);
}

/* ── Vertical rhythm for prose blocks ── */
html.cy-clean main p,
html.cy-clean main ul,
html.cy-clean main ol,
html.cy-clean main blockquote,
html.cy-clean main table {
  margin: 0 0 var(--cy-sp-3);
}

/* ── Meta / label style — small, uppercase, tracked-out, dim ── */
html.cy-clean .cy-meta {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-fg-dim);
}
/* Semantic colour helpers (replace the old inline style="color:…" spans). */
html.cy-clean .cy-accent { color: var(--cy-accent); }
html.cy-clean .cy-dim    { color: var(--cy-fg-dim); }

/* ── Prose links: undo the global uppercase/bold/margin-right meant for nav,
   so inline links inside body copy read naturally. Card titles & marks opt
   back into uppercase below via their own (higher-specificity) rules. ── */
html.cy-clean main a {
  text-transform: none;
  font-weight: 500;
  letter-spacing: 0;
  margin-right: 0;
}

/* ── Prose detailing: blockquotes, code, lists ── */
html.cy-clean main blockquote {
  border-left: 2px solid var(--cy-accent);
  margin-left: 0;
  padding-left: var(--cy-sp-2);
  color: var(--cy-fg-dim);
}
html.cy-clean main code {
  font-family: var(--cy-font-body);
  font-size: 0.9em;
  background: var(--cy-panel);
  border: 1px solid var(--cy-rule);
  padding: 0.05em 0.35em;
}
html.cy-clean main pre.highlight,
html.cy-clean main pre > code {
  display: block;
  padding: var(--cy-sp-2);
  background: var(--cy-panel);
  border: 1px solid var(--cy-rule);
  overflow-x: auto;
  /* Inline <code> deliberately stays in the body face, so it does not break
     the line it sits in. A block is its own line and reads as a readout, so
     it takes the mono face back. */
  font-family: var(--cy-font-mono);
  font-size: 0.82rem;
  line-height: 1.6;
}
/* A code block is a figure, not a paragraph: it needs the same air around it
   the .project blocks get from each other. */
html.cy-clean main > article > .cy-frame > pre { margin: var(--cy-sp-3) 0; }
/* Prose lists get a phosphor dash marker (cards live in a plain <div>, not a
   <ul>, so they never inherit this). */
html.cy-clean main ul { list-style: none; padding-left: 1.4rem; }
html.cy-clean main ul > li { position: relative; }
html.cy-clean main ul > li::before {
  content: "—";
  position: absolute;
  left: -1.4rem;
  color: var(--cy-accent);
}

/* ── Chrome primitives ─────────────────────────────────────────── */
/* Bracket-cornered frame panel. Corners drawn with pseudo-elements so
   they never get typed by the render effect. */
html.cy-clean .cy-frame {
  position: relative;
  box-sizing: border-box;
  border: 1px solid var(--cy-rule);
  padding: var(--cy-sp-4);
  margin: 0 0 var(--cy-sp-4);
}
html.cy-clean .cy-frame::before,
html.cy-clean .cy-frame::after {
  content: "";
  position: absolute;
  width: 13px;
  height: 13px;
  border: 1px solid var(--cy-link);
  pointer-events: none;
}
html.cy-clean .cy-frame::before { top: -1px;    left: -1px;  border-right: 0; border-bottom: 0; }
html.cy-clean .cy-frame::after  { bottom: -1px; right: -1px; border-left: 0;  border-top: 0; }
html.cy-clean .cy-frame--wip { border-style: dashed; }

/* ▸ marker line (nav-style entries, featured pointers). */
html.cy-clean .cy-mark {
  position: relative;
  display: block;
  padding-left: 1.5rem;
  margin: 0 0 var(--cy-sp-1);
}
html.cy-clean .cy-mark::before {
  content: "▸";
  position: absolute;
  left: 0;
  color: var(--cy-accent);
}

/* Single centred way-out link (the 404's "start over"). The .cy-mark bullet
   and its indent are wrong for a centred line, so this is its own thing. */
html.cy-clean .cy-reroute {
  text-align: center;
  margin: var(--cy-sp-4) 0 var(--cy-sp-5);
}
html.cy-clean .cy-reroute a {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  color: var(--cy-link);
}
html.cy-clean .cy-reroute a:hover { color: var(--cy-white); }

/* Status bar — a flex row of meta chips, e.g. type · status · date. */
html.cy-clean .cy-statusbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.9rem;
  margin: 0 0 var(--cy-sp-3);
}

/* Tag chip + status pill. */
html.cy-clean .cy-tag {
  display: inline-block;
  font-family: var(--cy-font-body);
  font-size: var(--cy-fs-meta);
  letter-spacing: 0.08em;
  color: var(--cy-link);
  border: 1px solid var(--cy-rule);
  padding: 0.08rem 0.5rem;
  margin: 0 0.3rem 0.3rem 0;
  white-space: nowrap;
}

/* ── Project cards ────────────────────────────────────────────────
   One component, two variants. Every project on projects.html is authored
   with identical markup; `cy-feature` (the hero) and `cy-tile` (a grid cell)
   are the only difference, which is what lets project_rotate.js promote a
   project to the feature slot with a node move and a class swap. Anything
   shared lives on .cy-project__*; anything that differs is stated under the
   variant. Keep it that way — a rule that only works in one variant but is
   written on the shared class will break the rotation. */
html.cy-clean .cy-cards { margin: 0 0 var(--cy-sp-4); }

html.cy-clean .cy-project {
  border: 1px solid var(--cy-rule);
  transition: border-color 120ms ease, background-color 120ms ease;
}
html.cy-clean .cy-project:hover {
  border-color: var(--cy-link);
  background: var(--cy-panel);
}

html.cy-clean .cy-project__media {
  display: block;
  position: relative;
  overflow: hidden;
  aspect-ratio: 16 / 9;
  border-bottom: 1px solid var(--cy-rule);
  background: var(--cy-panel);
}
html.cy-clean .cy-project__media > a { display: block; width: 100%; height: 100%; }
html.cy-clean .cy-project__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Photos read as photos. The old duotone pushed everything to phosphor
     green and the images stopped being legible as images. */
  filter: saturate(0.92) contrast(1.02) brightness(0.95);
  transition: filter 150ms ease, transform 300ms ease;
}
html.cy-clean .cy-project:hover .cy-project__img {
  filter: none;
  transform: scale(1.02);
}

html.cy-clean .cy-project__title {
  font-family: var(--cy-font-display);
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--cy-link);
  margin: 0;
}
html.cy-clean .cy-project__title::before {
  content: "▸ ";
  color: var(--cy-accent);
}
html.cy-clean .cy-project:hover .cy-project__title { color: var(--cy-white); }
html.cy-clean .cy-project__eyebrow {
  display: block;
  color: var(--cy-accent);
  margin: 0 0 var(--cy-sp-1);
}

/* ── Variant: the feature (magazine cover story) ──────────────── */
html.cy-clean .cy-feature {
  display: grid;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
  gap: var(--cy-sp-4);
  align-items: start;
  padding: var(--cy-sp-3);
  margin: 0 0 var(--cy-sp-4);
}
html.cy-clean .cy-feature .cy-project__media { border-bottom: 0; }
html.cy-clean .cy-feature .cy-project__body {
  display: flex;
  flex-direction: column;
  justify-content: center;
}
html.cy-clean .cy-feature .cy-project__title {
  font-size: var(--cy-fs-h1);
  line-height: 1.05;
  text-transform: none;
}
/* The ▸ marker is a grid-tile affordance; the hero is already the loudest
   thing on the page and does not need pointing at. */
html.cy-clean .cy-feature .cy-project__title::before { content: none; }
html.cy-clean .cy-feature .cy-project__desc {
  margin: var(--cy-sp-2) 0 0;
  color: var(--cy-fg);
  max-width: 60ch;
}
html.cy-clean .cy-feature .cy-project__meta { margin-top: var(--cy-sp-1); }
@media (max-width: 720px) {
  html.cy-clean .cy-feature { grid-template-columns: 1fr; }
}

/* ── Variant: a grid tile ─────────────────────────────────────── */
html.cy-clean .cy-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
  gap: var(--cy-sp-3);
}
html.cy-clean .cy-tile {
  display: flex;
  flex-direction: column;
}
/* Only the hero announces itself as the featured build. */
html.cy-clean .cy-tile .cy-project__eyebrow { display: none; }
/* Tiles are the page's main content, so they get room to breathe, and the
   meta line is pushed down so cards in a row align along it. */
html.cy-clean .cy-tile .cy-project__body {
  display: flex;
  flex-direction: column;
  gap: var(--cy-sp-1);
  padding: var(--cy-sp-3);
  flex: 1;
}
html.cy-clean .cy-tile .cy-project__title { font-size: 1.15rem; line-height: 1.25; }
html.cy-clean .cy-tile .cy-project__desc { margin: 0; color: var(--cy-fg-dim); }
html.cy-clean .cy-tile .cy-project__meta { margin-top: auto; padding-top: var(--cy-sp-1); }

/* ── Homepage hero ────────────────────────────────────────────── */
html.cy-clean .cy-hero { margin: 0 0 var(--cy-sp-5); }
html.cy-clean .cy-hero__roles { margin: 0 0 var(--cy-sp-3); }
html.cy-clean .cy-hero__tagline {
  color: var(--cy-fg);
  max-width: 60ch;
  margin: 0 0 var(--cy-sp-4);
}

/* ── Responsive video embed (16:9) ────────────────────────────── */
html.cy-clean .cy-video {
  width: 100%;
  margin: var(--cy-sp-4) 0 0;
  border: 1px solid var(--cy-rule);
  background: #000;
  aspect-ratio: 16 / 9;
}
html.cy-clean .cy-video iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* ════════════════════════════════════════════════════════════════
   RENDER MODE — the per-page "materialize" effect (render.js).
   Every char of <main> is wrapped in a .char span and typed out as katakana,
   then converted line-by-line to the real letters. Runs in clean mode on a
   black terminal, so the CRT never appears here.
   ════════════════════════════════════════════════════════════════ */

/* The wrapped .char spans must inherit the clean-mode font (IBM Plex Mono body,
   VT323 headings) — the base `.char { font-family: VT323 }` rule would otherwise
   change the clean page's body font once everything is span-wrapped. */
html.cy-clean .char { font-family: inherit; }
/* ...but a kana glyph mid-conversion keeps the foreign font. */
html.cy-clean .char.foreign { font-family: var(--foreign-font), monospace; color: #cfffe6; }

/* Pre-paint + during typing: black screen, chrome hidden, <main> hidden until
   render.js reveals it (empty). */
html.cy-rendering { background: var(--cy-bg); }
html.cy-rendering main   { visibility: hidden; }
html.cy-rendering header,
html.cy-rendering footer { visibility: hidden; }

/* Per-glyph conversion glitch (kana → real letter). Clean-mode safe: uses
   filter, not text-shadow. */
.cy-flip { animation: cy-flip 0.2s steps(1, end); }
@keyframes cy-flip {
  0%   { filter: brightness(2.2); }
  30%  { filter: drop-shadow(2px 0 rgba(255,0,90,0.7)) drop-shadow(-2px 0 rgba(0,220,255,0.7)); }
  60%  { filter: brightness(1.5); }
  100% { filter: none; }
}
@media (prefers-reduced-motion: reduce) { .cy-flip { animation: none; } }

/* Black cover used at the boot cinematic's apex to fall to black before the
   render types the page. Sits above the CRT frame/overlays. */
.cy-black {
  position: fixed; inset: 0; z-index: 100002;
  background: var(--cy-bg); opacity: 0; pointer-events: none;
}
.cy-black.on { opacity: 1; transition: opacity 260ms ease-in; }
@media (prefers-reduced-motion: reduce) { .cy-black { display: none; } }

/* ════════════════════════════════════════════════════════════════
   INSTRUMENT — theme components (clean mode only)

   Everything here is gated behind html.cy-clean, so the CRT boot
   sequence is untouched. Appended last so it wins the cascade over
   the earlier reading-experience rules without raising specificity.
   ════════════════════════════════════════════════════════════════ */

/* ── The measurement rail ──────────────────────────────────────
   The signature of the theme: the reading column is edged like the
   scale on a piece of test equipment. Purely decorative, so it is
   hidden from assistive tech and drawn with a gradient rather than
   markup. Ticks every 12px, a major tick every 60px. */
html.cy-clean .terminal-screen::after {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 10px;
  pointer-events: none;
  background:
    linear-gradient(to bottom, var(--cy-rule) 0 100%) left / 1px 100% no-repeat,
    repeating-linear-gradient(to bottom,
      var(--cy-rule) 0 1px, transparent 1px 12px) left / 5px 100% no-repeat,
    repeating-linear-gradient(to bottom,
      var(--cy-rule-lit) 0 1px, transparent 1px 60px) left / 10px 100% no-repeat;
}

/* Body copy: ragged right. Justified text in any face opens rivers of
   whitespace; the old rule justified a MONOSPACE face, which is the
   worst case of it. */
html.cy-clean .project,
html.cy-clean .console,
html.cy-clean pre {
  text-align: left;
  text-justify: auto;
  hyphens: none;
}

/* Paragraph rhythm. Copy is authored as blank-line-separated text inside
   pre-wrap blocks, so the spacing comes from the line box, not margins. */
html.cy-clean .project {
  color: var(--cy-fg);
  max-width: 100%;
}

/* Section labels ( ":: like this" ) are the readout of the instrument:
   mono, tracked out, amber. This is where the signal colour lives. */
html.cy-clean .cy-section-label {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-accent);
  margin: var(--cy-sp-5) 0 var(--cy-sp-2);
  padding-top: var(--cy-sp-2);
  border-top: 1px solid var(--cy-rule);
}

/* Links: cyan is data. Underline on hover only, so running text stays calm. */
html.cy-clean .project a,
html.cy-clean main p a {
  color: var(--cy-link);
  text-decoration-color: var(--cy-rule-lit);
  text-underline-offset: 3px;
}

/* Nav reads as an instrument header: mono, tracked, near-white. It is primary
   navigation rather than metadata, so it does not get the dim meta colour. */
html.cy-clean nav a {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-fg);
  margin-right: 0;
}
html.cy-clean nav a:hover { color: var(--cy-accent); text-decoration: none; }

/* ── Return bar ────────────────────────────────────────────────
   The answer to "navigate in and out of landing pages easily".
   Project landing pages keep their own visual world; this is the
   consistent door back. Fixed, small, and out of the way. */
.cy-return {
  position: fixed;
  left: 0; bottom: 0;
  z-index: 90;
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  margin: 0.9rem;
  padding: 0.5rem 0.85rem;
  border: 1px solid rgba(255,255,255,0.14);
  border-radius: 3px;
  background: rgba(10,15,20,0.82);
  backdrop-filter: blur(8px);
  color: #d6e4ec;
  font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-decoration: none;
  transition: border-color .18s, color .18s;
}
.cy-return:hover { border-color: #ffb020; color: #ffb020; }
.cy-return::before { content: "\2190"; font-size: 13px; }
@media (prefers-reduced-motion: reduce) {
  .cy-return { transition: none; }
}


/* The reading panel is not a panel. The measurement rail already edges the
   column, so the article's own frame was a third border around the same
   content; unbordering it also strands the corner brackets and the padding the
   border used to justify. The .cy-frame primitive keeps its border where a
   panel is genuinely a panel (the contact block).

   It is also a WRAPPER, not text. It inherits pre-wrap from .console, which
   made the newlines and indentation between its child elements render as
   literal blank lines — that was the dead space under every article title.
   The wrapper collapses whitespace; only the prose blocks keep it. */
html.cy-clean main > article > .cy-frame,
html.cy-clean .console.cy-frame {
  border: 0;
  padding: 0;
  white-space: normal;
}
html.cy-clean main > article > .cy-frame::before,
html.cy-clean main > article > .cy-frame::after,
html.cy-clean .console.cy-frame::before,
html.cy-clean .console.cy-frame::after { display: none; }
html.cy-clean .console.cy-frame > .project { white-space: pre-wrap; }

/* ── Article figures ───────────────────────────────────────────
   Figures sit BETWEEN .project blocks, never inside one: .project is
   white-space:pre-wrap, so markup nested in it inherits the authored
   line breaks and spaces out unpredictably.

   Images are full-column and edged rather than floated — the reading
   column is ~63rem of text at most, and a float would strand 20-character lines
   beside it. */
html.cy-clean .cy-figure {
  margin: var(--cy-sp-5) 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--cy-sp-2);
  /* The article body is pre-wrap, and a figure nested anywhere under it
     inherits that — which turns the newlines and indentation in the caption
     markup into literal blank lines. Reset it for the figure subtree. */
  white-space: normal;
}
html.cy-clean .cy-figure figcaption,
html.cy-clean .cy-figure figcaption * { white-space: normal; }
html.cy-clean .cy-figure img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--cy-rule);
  border-radius: 2px;
  background: var(--cy-panel);
}
/* Captions are prose, not readouts — tracked-out mono at meta size was the
   hardest text on the page to read. Body face, just quieter than the body. */
html.cy-clean .cy-figure figcaption {
  font-family: var(--cy-font-body);
  font-size: 0.9rem;
  line-height: 1.6;
  color: var(--cy-fg-dim);
  border-left: 1px solid var(--cy-rule-lit);
  padding-left: var(--cy-sp-2);
}

/* ════════════════════════════════════════════════════════════════
   WIDE LAYOUT — gallery pages (projects.html)

   Reading pages are capped at --cy-measure so prose keeps a sane
   measure. A card gallery has the opposite need: squeezed into a
   40rem column the tile grid collapses to one column and reads as
   the vertical list it is trying not to be. Pages opt in with
   <html data-cy-layout="wide"> and get --cy-measure-wide instead.
   ════════════════════════════════════════════════════════════════ */
html.cy-clean[data-cy-layout="wide"] .terminal-screen {
  max-width: var(--cy-measure-wide);
  padding: 3rem 2rem 4rem;          /* symmetric: no rail to clear */
}
/* The measurement rail edges a reading column; against a full-width
   grid it just floats beside nothing. */
html.cy-clean[data-cy-layout="wide"] .terminal-screen::after { display: none; }

/* ── Gallery page header ──────────────────────────────────────── */
/* The gallery header is one line: PROJECTS // BUILD LOG. It is still an <h1>
   so the page keeps a document heading, but it is sized as a label rather
   than a headline — the cards are the content, and a big title above them
   was just a second thing competing with the hero. */
html.cy-clean .cy-gallery-head { margin: 0 0 var(--cy-sp-4); }
html.cy-clean .cy-gallery-head__label {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  font-weight: 400;
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  line-height: 1.4;
  margin: 0;
}
html.cy-clean .cy-gallery-head__name { color: var(--cy-fg); }
html.cy-clean .cy-gallery-head__kicker { color: var(--cy-fg-dim); }

/* ── Feature card at gallery width ────────────────────────────── */
html.cy-clean[data-cy-layout="wide"] .cy-feature {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: var(--cy-sp-4);
  padding: var(--cy-sp-4);
  margin-bottom: var(--cy-sp-5);
}
html.cy-clean[data-cy-layout="wide"] .cy-feature .cy-project__desc {
  font-size: 1.05rem;
  max-width: 46ch;
}
/* The two-column rule above outranks the plain `.cy-feature` stack at
   720px, so on a phone the hero stayed split and its copy ran two words to
   a line. Restate the stack at this specificity. */
@media (max-width: 720px) {
  html.cy-clean[data-cy-layout="wide"] .cy-feature {
    grid-template-columns: 1fr;
    padding: var(--cy-sp-3);
  }
}

/* ── Tile grid: three up at gallery width, never a single column
   until the viewport genuinely can't hold two. ── */
html.cy-clean[data-cy-layout="wide"] .cy-tiles {
  grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
  gap: var(--cy-sp-4) var(--cy-sp-3);
}

/* ── The article plate ────────────────────────────────────────────
   Every project article opens on its gallery photograph. Two decisions
   make it read as the page starting rather than a banner pasted on top:

   1. It bleeds to the edges of the reading panel. The panel is <main>,
      not .terminal-screen, and its inset is --cy-panel-pad — which is why
      that clamp() is a variable now: the plate has to cancel exactly what
      the panel applies, at every viewport, or it floats inside the card
      instead of filling the top of it.
   2. Its bottom half DISSOLVES — a mask, not a dark veil. The column
      background is transparent (the world photograph shows through it),
      so fading to a colour would leave a slab of the wrong dark sitting
      on the landscape. Masking fades the photo to nothing and the dusk
      behind the site comes through it.

   The title then sits IN that dissolve, pulled up by the plate's negative
   bottom margin. It stays ordinary flow text — sharp, selectable, and
   above the photo — because THE RULE holds here too: the scanline wash is
   GLASS and belongs on the photograph, never on the interface text. */
html.cy-clean .cy-plate {
  position: relative;
  margin: calc(-1 * var(--cy-panel-pad))
          calc(-1 * var(--cy-panel-pad))
          -4.5rem;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 0 48%, rgba(0,0,0,0.55) 76%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0 48%, rgba(0,0,0,0.55) 76%, transparent 100%);
}
html.cy-clean .cy-plate img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Graded down toward the tile's resting look. The bright, saturated
     plates (Spirefall's neon street) are what set this: ungraded, they sit
     on the page as a lit rectangle rather than as part of it. */
  filter: saturate(0.86) contrast(1.06) brightness(0.78);
}
/* GLASS: the same scanline-and-vignette wash the gallery tiles wear. */
html.cy-clean .cy-plate::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      rgba(0,0,0,0.26) 0 1px,
      rgba(0,0,0,0) 1px 2px),
    linear-gradient(to bottom,
      rgba(10,15,20,0.50) 0%,
      rgba(10,15,20,0.06) 30%,
      rgba(10,15,20,0.30) 70%,
      rgba(10,15,20,0.55) 100%),
    /* The same cold wash .cy-world__grade puts on the landscape, for the
       same reason: it pulls a photograph toward the site's blue so the
       plate and the interface read as one image. */
    linear-gradient(to bottom,
      rgba(30, 66, 100, 0.26) 0%,
      rgba(22, 46, 72, 0.16) 60%,
      rgba(18, 34, 54, 0.10) 100%);
}
/* The plate supplies the separation, so the header drops its own rule. */
html.cy-clean .cy-page-header--plate {
  padding-bottom: 0;
  border-bottom: 0;
}
/* .cy-plate is positioned, so it paints above a static sibling — the title
   has to be positioned too, or the photograph covers it. */
html.cy-clean .cy-page-header--plate h1 {
  position: relative;
  z-index: 1;
  text-shadow: 0 2px 24px rgba(6, 10, 16, 0.85);
}
@media (max-width: 46rem) {
  html.cy-clean .cy-plate { margin-bottom: -3.25rem; }
}

/* ── The plate's "check it out" overlay ───────────────────────────
   The three projects with their own portal (RE:play, Periphony 8D,
   PagePress) carry a link to it ON the header photograph, so the way
   out is visible before a word is read — the write-up already closes
   with the full .cy-visit block, and this is the same destination
   offered at the top.

   It is a SIBLING of .cy-plate, not a child, for two reasons: the
   plate is masked to nothing across its bottom half and would fade
   the badge with it, and the plate's img filter grades the photo
   down. Chrome stays on the INTERFACE layer, unfiltered and sharp,
   which is THE RULE. The header has to be positioned for that, and
   the badge outranks the title's z-index so it survives the overlap
   at narrow widths. */
html.cy-clean .cy-page-header--plate { position: relative; }
html.cy-clean .cy-plate-go {
  position: absolute;
  z-index: 2;
  /* The plate is full-bleed — it pulls out by --cy-panel-pad on both
     sides — so the offsets undo that pad before insetting from the
     photograph's own edge. */
  top: calc(var(--cy-sp-2) - var(--cy-panel-pad));
  right: calc(var(--cy-sp-2) - var(--cy-panel-pad));
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.55rem 0.9rem;
  border: 1px solid var(--cy-link);
  background: rgba(6, 11, 16, 0.82);
  box-shadow: inset 0 0 0 1px rgba(6, 11, 16, 0.9);
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-link);
  text-decoration: none;
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
/* The arrow is CSS so render.js never types it — same reason as
   .cy-visit__cta. (Project pages opt out of the effect anyway, but
   the chrome/content split holds either way.) */
html.cy-clean .cy-plate-go::after {
  content: "\2197";
  color: var(--cy-accent);
  transition: color 120ms ease;
}
html.cy-clean .cy-plate-go:hover,
html.cy-clean .cy-plate-go:focus-visible {
  background: var(--cy-link);
  border-color: var(--cy-link);
  color: var(--cy-bg);
  text-decoration: none;
}
html.cy-clean .cy-plate-go:hover::after,
html.cy-clean .cy-plate-go:focus-visible::after { color: var(--cy-bg); }
@media (max-width: 46rem) {
  html.cy-clean .cy-plate-go {
    top: calc(var(--cy-sp-1) - var(--cy-panel-pad));
    right: calc(var(--cy-sp-1) - var(--cy-panel-pad));
    padding: 0.45rem 0.7rem;
  }
}

/* ── Article reading refinements ──────────────────────────────────
   Slightly more air between the title block and the body, and between
   sections, so a 2,000-word piece has landmarks rather than one slab. */
html.cy-clean .cy-page-header {
  margin: 0 0 var(--cy-sp-5);
  padding-bottom: var(--cy-sp-3);
  border-bottom: 1px solid var(--cy-rule);
}
html.cy-clean .cy-page-header h1 { margin: 0 0 var(--cy-sp-2); }
html.cy-clean .cy-page-header .cy-statusbar { margin: 0 0 var(--cy-sp-2); }
html.cy-clean .cy-page-header .cy-tags { margin: 0; }

/* Opening paragraph of an article carries a touch more presence. */
html.cy-clean main > article > .cy-frame > .project:first-child {
  font-size: 1.06rem;
  color: var(--cy-fg);
}

/* ── Visit-website block ──────────────────────────────────────────
   Projects that have their own portal (RE:play, Periphony 8D, PagePress)
   close their write-up with a screenshot of that site plus a "visit
   website" line. The whole thing is ONE <a>, so the thumbnail and the
   text are the same click target rather than two links to the same
   place — which is also why the caption is not a separate link.

   It sits inside the article's pre-wrap body, so like .cy-figure it has
   to reset white-space or the newlines in its own markup would render. */
html.cy-clean .cy-visit {
  display: block;
  white-space: normal;
  margin: var(--cy-sp-5) 0;
  border: 1px solid var(--cy-rule);
  text-decoration: none;
  color: inherit;
  transition: border-color 120ms ease, background-color 120ms ease;
}
html.cy-clean .cy-visit:hover {
  border-color: var(--cy-link);
  background: var(--cy-panel);
  text-decoration: none;
}
html.cy-clean .cy-visit__shot {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  object-position: top center;
  border-bottom: 1px solid var(--cy-rule);
  transition: filter 150ms ease;
  filter: saturate(0.92) contrast(1.02) brightness(0.9);
}
html.cy-clean .cy-visit:hover .cy-visit__shot { filter: none; }
/* Stub projects have no site to screenshot yet. This stands in for the
   missing shot rather than shipping a fabricated one, and is shared by the
   visit block and the gallery card's media box. */
html.cy-clean .cy-shot-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  background: var(--cy-panel);
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-fg-dim);
}
/* In the visit block it is the top half, so it carries the divider. */
html.cy-clean .cy-visit .cy-shot-placeholder { border-bottom: 1px solid var(--cy-rule); }
/* In a card the media box already sets the ratio and the divider. */
html.cy-clean .cy-project__media .cy-shot-placeholder { height: 100%; aspect-ratio: auto; }
html.cy-clean .cy-visit__label {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem 0.9rem;
  padding: var(--cy-sp-2) var(--cy-sp-3);
}
html.cy-clean .cy-visit__cta {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-link);
}
/* The arrow is CSS, not literal text — render.js types out every
   character in <main>, and chrome should never be part of that. */
html.cy-clean .cy-visit__cta::after {
  content: " \2192";
  color: var(--cy-accent);
}
html.cy-clean .cy-visit:hover .cy-visit__cta { color: var(--cy-white); }
html.cy-clean .cy-visit__host {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: 0.06em;
  color: var(--cy-fg-dim);
}

/* ── Retro-reflection explainer ────────────────────────────────────
   Only on projects/active-green-screen/. Driven by retro_reflect.js,
   which sets --rr-key (the current key colour) and --rr-b (0..1, how
   much light is finding its way back into the lens) on the root node
   and lets CSS do the rest.

   Controls are hidden until the script adds .cy-retro--live, so a
   reader with no JS gets the static diagram instead of dead inputs. */
html.cy-clean .cy-retro {
  --rr-key: #19e05f;
  --rr-b: 1;
  margin: var(--cy-sp-5) 0;
  white-space: normal;
}
html.cy-clean .cy-retro__panels {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--cy-sp-2);
}
@media (min-width: 60rem) {
  html.cy-clean .cy-retro__panels { grid-template-columns: 1.55fr 1fr; }
}
html.cy-clean .cy-retro__side {
  display: flex;
  flex-direction: column;
  gap: var(--cy-sp-2);
  min-width: 0;
}
html.cy-clean .cy-retro svg {
  display: block;
  width: 100%;
  height: auto;
  background: #070b0f;
  border: 1px solid var(--cy-rule);
}

/* Scene furniture */
html.cy-clean .cy-retro .rr-fabric   { fill: #454e56; }
html.cy-clean .cy-retro .rr-cam-body { fill: #161d24; stroke: var(--cy-rule-lit); stroke-width: 1.5; }
html.cy-clean .cy-retro .rr-cam-lens { fill: #0c1116; stroke: var(--cy-rule-lit); stroke-width: 1.5; }
html.cy-clean .cy-retro .rr-lens-dot { fill: var(--cy-link); }
html.cy-clean .cy-retro .rr-subject  { fill: #10171d; stroke: var(--cy-rule-lit); stroke-width: 1.5; }
html.cy-clean .cy-retro .rr-mount    { stroke: var(--cy-rule-lit); stroke-width: 2; stroke-dasharray: 3 3; }
html.cy-clean .cy-retro .rr-ring     { stroke: var(--rr-key); stroke-width: 2.5; opacity: 0.5; }
html.cy-clean .cy-retro .rr-led      { fill: var(--rr-key); }

/* Light. The outgoing beam is always at full strength — the light
   leaves the ring no matter where the ring is. Only the return fades,
   and that asymmetry is the entire point of the diagram. */
html.cy-clean .cy-retro .rr-out  { stroke: var(--rr-key); stroke-width: 1.1; }
html.cy-clean .cy-retro .rr-cone { fill: var(--rr-key); opacity: 0.07; }
html.cy-clean .cy-retro .rr-back { stroke: var(--rr-key); stroke-width: 2; }
html.cy-clean .cy-retro .rr-dot  { fill: #ffffff; }

html.cy-clean .cy-retro .rr-label {
  font-family: var(--cy-font-mono);
  font-size: 14px;
  letter-spacing: 0.16em;
  fill: var(--cy-fg-dim);
}
html.cy-clean .cy-retro .rr-label--ring { fill: var(--rr-key); opacity: 0.85; }

/* Bead inset */
html.cy-clean .cy-retro .rr-bead-glass  { fill: rgba(79,214,214,0.07); stroke: var(--cy-link); stroke-width: 1.2; }
html.cy-clean .cy-retro .rr-bead-mirror { fill: none; stroke: var(--cy-accent); stroke-width: 3; }
html.cy-clean .cy-retro .rr-bead-path   { fill: none; stroke: var(--rr-key); stroke-width: 2; }
html.cy-clean .cy-retro .rr-bead-in     { fill: none; stroke: var(--cy-fg-dim); stroke-width: 1.6; }
html.cy-clean .cy-retro .rr-bead-out    { fill: none; stroke: #ffffff; stroke-width: 2.4; }
html.cy-clean .cy-retro .rr-label--in   { fill: var(--rr-key); }
html.cy-clean .cy-retro .rr-label--out  { fill: #ffffff; }
html.cy-clean .cy-retro .rr-bead-dot    { fill: #ffffff; }
html.cy-clean .cy-retro .rr-scatter     { stroke: var(--cy-fg-dim); stroke-width: 1.2; opacity: 0.55; }
html.cy-clean .cy-retro .rr-surface     { fill: #2b333a; }
html.cy-clean .cy-retro .rr-divider     { stroke: var(--cy-rule); stroke-width: 1; }

/* Camera view */
/* A faint rim keeps the subject readable against BOTH the blazing
   fabric and the near-black replacement it gets composited onto. */
html.cy-clean .cy-retro .rr-cam-subject {
  fill: #0b1218;
  stroke: rgba(214,228,236,0.30);
  stroke-width: 1.5;
}
/* The shadow is filled black — which reads on the bright fabric — and
   outlined in the key colour, which is what survives on the composite.
   Fringe the keyer could not resolve is exactly what it represents. */
html.cy-clean .cy-retro .rr-cam-shadow  {
  fill: #000000;
  stroke: var(--rr-key);
  stroke-width: 2;
}
html.cy-clean .cy-retro .rr-speck       { fill: var(--rr-key); }
html.cy-clean .cy-retro .rr-note {
  font-family: var(--cy-font-mono);
  font-size: 13px;
  letter-spacing: 0.14em;
  fill: var(--cy-accent);
}

/* Controls */
html.cy-clean .cy-retro__controls { display: none; }
html.cy-clean .cy-retro--live .cy-retro__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--cy-sp-2) var(--cy-sp-3);
  margin-top: var(--cy-sp-2);
  padding: var(--cy-sp-2);
  border: 1px solid var(--cy-rule);
  background: var(--cy-panel);
}
html.cy-clean .cy-retro__ctl {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-fg-dim);
}
html.cy-clean .cy-retro__ctl input[type="range"] {
  width: 11rem;
  accent-color: var(--rr-key);
}
html.cy-clean .cy-retro__ctl input[type="checkbox"] { accent-color: var(--rr-key); }
html.cy-clean .cy-retro .rr-key-btn {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--cy-fg-dim);
  background: transparent;
  border: 1px solid var(--cy-rule);
  padding: 0.4rem 0.9rem;
  cursor: pointer;
  transition: color .18s, border-color .18s;
}
html.cy-clean .cy-retro .rr-key-btn:hover { color: var(--cy-fg); border-color: var(--cy-rule-lit); }
html.cy-clean .cy-retro .rr-key-btn[aria-pressed="true"] {
  color: var(--cy-bg);
  background: var(--cy-fg);
  border-color: var(--cy-fg);
}
html.cy-clean .cy-retro__readout {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: 0.08em;
  color: var(--cy-link);
  margin-top: var(--cy-sp-1);
}

/* ════════════════════════════════════════════════════════════════
   THE BOOTH — 2026-08-21
   See docs/superpowers/specs/2026-08-21-the-booth-redesign-design.md

   The fiction: this site is a public terminal standing at dusk in an
   empty landscape. The boot cinematic is you walking up and powering
   it on; when it ends the camera settles and you are AT THE GLASS,
   operating it. The world stays visible behind the interface.

   Everything below belongs to one of three layers, and the layer is
   the REASON the effect exists:

     z2  INTERFACE   crisp HUD — rails, brackets, records, type
     z1  GLASS       the booth window — sheen, vignette, faint scan
     z0  WORLD       sky, horizon, silhouettes, dust, parallax

   THE RULE: the CRT signature lives on the GLASS, never on the
   INTERFACE. Text stays sharp; atmosphere sits behind it. A blur,
   scanline or glow applied to interface content is a bug, not a
   flourish — that is exactly what made the old clean mode delete the
   entire CRT world rather than live with it.
   ════════════════════════════════════════════════════════════════ */

@font-face {
  font-family: 'IBM Plex Sans Condensed';
  src: url('/assets/fonts/IBMPlexSansCondensed-400.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Sans Condensed';
  src: url('/assets/fonts/IBMPlexSansCondensed-600.woff2') format('woff2');
  font-weight: 600; font-style: normal; font-display: swap;
}

:root {
  /* world — a cold sky over warm dust. Stalenhag's whole trick is that
     the horizon is WARMER than the zenith; drop that and it reads as
     generic dark-mode blue. */
  --w-sky-high: #0c1119;
  --w-sky-mid:  #1b2732;
  --w-sky-low:  #34424c;
  --w-haze:     #6b6152;
  --w-far:      #161d26;
  --w-near:     #0d131a;

  /* interface */
  --i-ink:    #e8f1f5;
  --i-dim:    #7d95a3;
  --i-line:   #22323f;
  --i-signal: #4fd6d6;
  --i-alarm:  #ffb020;
  --i-live:   #15ff00;   /* phosphor — LIVE elements ONLY, never body text */

  --cy-font-cond: 'IBM Plex Sans Condensed', 'IBM Plex Sans', system-ui, sans-serif;

  --rail-h: 2.25rem;
}

/* ── z0 WORLD ─────────────────────────────────────────────────────
   A photograph, not a drawing. The first pass built the landscape out of
   gradients and clip-path; it was cheap and it looked it. A real dusk
   plate carries the atmospheric perspective — haze stacking up between
   ridge lines — that is the entire reason this view reads as deep, and
   that is the one thing hand-rolled gradients are worst at faking.

   Plate: an unlit corridor in Shenzhen — Pexels #25381223 by ". what"
   (@what-640488395), Pexels licence: free for commercial use, modification
   allowed, no attribution required. The same licence the Wadi Rum dusk
   plate shipped under before it.

   It is the third plate here. The desert read as a desert; a supplied data
   canyon read right but came with unclear provenance and only 1536px. This
   one keeps what both earlier plates were chosen for — depth built out of
   receding light rather than out of drawn lines — and the vanishing point
   does the job the horizon used to.

   NOT shipped as it comes off Pexels. The original is a 3728x5600 portrait,
   so it is centre-cropped to 3:2 at a 0.42 vertical offset, then graded:
   gamma 1.62/1.52/1.38 with a 0.70/0.90/1.12 tint, which crushes it toward
   black and cools it into the palette. Re-deriving it means repeating that;
   the numbers are here so it can be.

   Swapping it is a one-file change: drop a new plate in
   assets/images/world/ and repoint the two url()s below.  ── */
.cy-world, .cy-glass { display: none; }

html.cy-clean .cy-world {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  background: #0a0e14;
}

html.cy-clean .cy-world__photo {
  position: absolute;
  inset: 0;
  background-image: url('/assets/images/world/world-corridor-2400.webp');
  background-size: cover;
  /* Parallax pans WITHIN the cover crop rather than moving the element, so
     no matter how far it drifts an edge can never come into view.
     45%: this plate's lit doorways sit at about 41% of the image, and a
     16:9 viewport crops 15.6% of a 3:2 plate away, so anchoring here lands
     them just above centre instead of down in the lower third. */
  background-position: calc(50% + var(--wx, 0px) * 0.6) calc(45% + var(--wy, 0px) * 0.5);
}
@media (max-width: 820px) {
  html.cy-clean .cy-world__photo {
    background-image: url('/assets/images/world/world-corridor-1200.webp');
  }
}

/* The grade now has ONE job: seat the rails. It no longer cools the plate,
   because the plate is already cold, and it no longer buys back contrast,
   because there is no bright horizon band to lose ink against — the whole
   picture sits under L 25.

   Built on pure black, so it can only darken. The previous grade mixed in
   #080c14 / #0a101a to cool a warm desert; against this plate those greys
   were LIGHTER than the image and lifted its blacks (mean L 3.4 -> 8.9 at
   the top, 4.3 -> 10.1 at the foot) while dimming the vanishing point,
   flattening the exact contrast the picture is made of. The middle is left
   alone on purpose: the corridor glow is the subject. */
html.cy-clean .cy-world__grade {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to bottom,
      rgba(0, 0, 0, 0.55) 0%,
      rgba(0, 0, 0, 0.14) 18%,
      rgba(0, 0, 0, 0.00) 42%,
      rgba(0, 0, 0, 0.10) 70%,
      rgba(0, 0, 0, 0.62) 100%);
}

html.cy-clean .cy-world__motes {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  opacity: 0.42;
}

/* ── z1 GLASS ─────────────────────────────────────────────────────
   The booth window. This is where the CRT survives — as a property of
   the glass you are looking THROUGH, not of the text. ── */
html.cy-clean .cy-glass {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  /* vignette + a diagonal sheen across the pane */
  background:
    linear-gradient(115deg,
      rgba(255, 255, 255, 0.045) 0%,
      rgba(255, 255, 255, 0.012) 18%,
      rgba(255, 255, 255, 0) 38%),
    radial-gradient(ellipse 78% 68% at 50% 44%,
      rgba(0, 0, 0, 0) 55%,
      rgba(0, 0, 0, 0.55) 100%);
}

/* Scanlines, but whisper-quiet and on the GLASS. 0.03 alpha: present
   when you look for it, invisible while reading. */
html.cy-clean .cy-glass::after {
  content: '';
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.05) 0px,
    rgba(0, 0, 0, 0.05) 1px,
    rgba(0, 0, 0, 0) 1px,
    rgba(0, 0, 0, 0) 3px);
  opacity: 0.55;
}

/* ── z2 INTERFACE ────────────────────────────────────────────────
   The content column rides above both. ── */
html.cy-clean .terminal-wrapper {
  position: relative;
  z-index: 2;
  background: transparent;   /* was --cy-bg; the world shows through now */
}
html.cy-clean body { background: var(--w-sky-high); }

/* Rails claim the top and bottom edges on every page. */
html.cy-clean .terminal-screen {
  padding-top: calc(var(--rail-h) + 2.5rem);
  padding-bottom: calc(var(--rail-h) + 3rem);
}

/* ── Rails ──────────────────────────────────────────────────────── */
.cy-rail { display: none; }
html.cy-clean .cy-rail {
  display: flex;
  position: fixed;
  left: 0; right: 0;
  z-index: 5;
  align-items: center;
  gap: 1rem;
  height: var(--rail-h);
  padding: 0 clamp(0.75rem, 2vw, 1.5rem);
  font-family: var(--cy-font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--i-dim);
  background: rgba(8, 12, 18, 0.82);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
html.cy-clean .cy-rail--top    { top: 0;    border-bottom: 1px solid var(--i-line); }
html.cy-clean .cy-rail--bottom { bottom: 0; border-top:    1px solid var(--i-line); }

html.cy-clean .cy-rail__id { color: var(--i-ink); letter-spacing: 0.22em; }
/* Local time + zone, so it is wider than the old fixed-width UTC stamp. */
html.cy-clean .cy-clock { white-space: nowrap; }
html.cy-clean .cy-rail__spacer { flex: 1 1 auto; }
html.cy-clean .cy-rail__cell { white-space: nowrap; }
@media (max-width: 46rem) {
  html.cy-clean .cy-rail__cell--opt { display: none; }
}
/* The top rail needs ~456px for id + nav + network menu + clock, and it is
   fixed, so on a phone the clock was simply pushed off the right edge. The
   clock is the one cell that is atmosphere rather than navigation. */
@media (max-width: 30rem) {
  html.cy-clean .cy-rail__cell:has(> .cy-clock) { display: none; }
}
/* Below ~350px even id + nav + network menu is too much at desktop spacing. */
@media (max-width: 24rem) {
  html.cy-clean .cy-rail { gap: 0.5rem; }
  html.cy-clean .cy-rail--top .cy-rail__nav { margin-left: 0.5rem; }
  /* The rail id is already a link home, so HOME is the redundant one. */
  html.cy-clean .cy-rail--top .cy-rail__nav a[href="/"] { display: none; }
}

/* The rail id used to sit beside a pulsing LIVE dot; the dot went with the
   rename to CYBURDINE.com. The clock and the ticker are now the only places
   green — the machine's own voice — is allowed. */
@keyframes cy-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }

html.cy-clean .cy-clock { color: var(--i-live); font-variant-numeric: tabular-nums; }

/* Network menu — the four Cyburdine properties, matching the "Cyburdine ▾"
   header menu on systems.cyburdine.com. It is INTERFACE (z2), so no blur,
   scanline or glow lands on it; only the panel behind the type is atmospheric. */
html.cy-clean .cy-netmenu { position: relative; display: inline-flex; }
html.cy-clean .cy-netmenu__btn {
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  margin: 0;
  padding: 0.35rem 0;
  border: 0;
  background: none;
  color: var(--i-dim);
  cursor: pointer;
}
html.cy-clean .cy-netmenu__btn:hover,
html.cy-clean .cy-netmenu__btn[aria-expanded="true"] { color: var(--i-signal); }
html.cy-clean .cy-netmenu__caret {
  font-size: 0.6em;
  line-height: 1;
  transition: transform 0.2s ease;
}
html.cy-clean .cy-netmenu__btn[aria-expanded="true"] .cy-netmenu__caret { transform: rotate(180deg); }

/* `display: flex` below outranks the UA's [hidden] { display: none }, so the
   attribute has to be honoured explicitly or the menu ships stuck open. */
html.cy-clean .cy-netmenu__list[hidden] { display: none; }
html.cy-clean .cy-netmenu__list {
  position: absolute;
  top: calc(100% + 0.85rem);
  right: 0;
  z-index: 6;
  display: flex;
  flex-direction: column;
  min-width: 15rem;
  padding: 0.25rem;
  border: 1px solid var(--i-line);
  background: rgba(8, 12, 18, 0.96);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
html.cy-clean .cy-netmenu__list a {
  display: block;
  padding: 0.6rem 0.75rem;
  white-space: nowrap;
  color: var(--i-dim);
}
html.cy-clean .cy-netmenu__list a:hover { color: var(--i-ink); background: rgba(255, 255, 255, 0.06); }
html.cy-clean .cy-netmenu__list a.is-here { color: var(--i-ink); }

/* Ticker — the boot log doesn't die, it moves in here and keeps
   running for the life of the page. This is the strongest single tie
   between the cinematic and the site. */
html.cy-clean .cy-ticker {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  color: var(--i-live);
  opacity: 0.75;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
          mask-image: linear-gradient(to right, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
}
html.cy-clean .cy-ticker__line { display: inline-block; padding-right: 3rem; }

html.cy-clean .cy-rail nav,
html.cy-clean .cy-rail a {
  display: inline-flex;
  border: 0;
  margin: 0;
  padding: 0;
  gap: 1.25rem;
  color: var(--i-dim);
  text-decoration: none;
}
html.cy-clean .cy-rail a:hover { color: var(--i-signal); text-decoration: none; }

/* The id is the home button. It is an <a> now, so it has to restate the ink
   colour and letter-spacing the generic `.cy-rail a` rule above would
   otherwise dim it back down to. */
html.cy-clean a.cy-rail__id {
  color: var(--i-ink);
  letter-spacing: 0.22em;
  text-decoration: none;
}
html.cy-clean a.cy-rail__id:hover { color: var(--i-signal); }
html.cy-clean .cy-rail__nav { margin-left: 1.5rem; }

/* The in-page header nav is now redundant with the bottom rail. */
html.cy-clean header { display: none; }

/* ── IDENT ──────────────────────────────────────────────────────── */
html.cy-clean .cy-ident {
  position: relative;
  padding: clamp(1.5rem, 4vw, 3rem) 0 clamp(2rem, 5vw, 4rem);
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
  border-bottom: 1px solid var(--i-line);
}
/* One line: CYBURDINE :: TECHNOLOGIST - ARTIST - VERSATILIST. The size is
   capped so the whole string clears the 74rem gallery column on a desktop;
   below that it wraps rather than overflowing. */
html.cy-clean .cy-ident__roles {
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(1.35rem, 4.4vw, 2.6rem);
  line-height: 1.05;
  letter-spacing: -0.015em;
  text-transform: uppercase;
  color: var(--i-ink);
  margin: 0 0 clamp(1rem, 2.5vw, 1.75rem);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.1em 0.32em;
}
/* NB: these MUST be class-scoped, never `.cy-ident__roles span`. render.js
   wraps every character of <main> in its own <span class="char"> to type the
   page in, so a descendant span selector here also matches each individual
   LETTER — which set `display:block` on all of them and stacked the headline
   one character per line. */
/* The name carries the line: bigger, brightest, and the only part at full ink. */
html.cy-clean .cy-ident__name {
  font-size: 1.15em;
  color: var(--i-ink);
}
html.cy-clean .cy-ident__sep { color: var(--i-signal); opacity: 0.6; }
/* Each role steps further into the haze — a depth cue borrowed from
   the world layer, applied to type. */
html.cy-clean .cy-ident__role { color: #cfe0e8; }
/* Positional, because the separators are spans too: 5 = Artist, 7 = Versatilist. */
html.cy-clean .cy-ident__role:nth-child(5) { color: #b9ccd6; }
html.cy-clean .cy-ident__role:nth-child(7) { color: #8ba4b1; }

html.cy-clean .cy-ident__tagline {
  font-size: clamp(1rem, 1.6vw, 1.2rem);
  line-height: 1.55;
  color: var(--i-dim);
  max-width: 46ch;
  margin: 0;
}
html.cy-clean .cy-ident__tagline strong { color: var(--i-ink); font-weight: 500; }

/* Crosshair marks at the panel corners — surveillance framing. */
html.cy-clean .cy-ident::before,
html.cy-clean .cy-ident::after {
  content: '';
  position: absolute;
  width: 10px; height: 10px;
  border: 1px solid var(--i-signal);
  opacity: 0.55;
}
html.cy-clean .cy-ident::before { top: 0; left: -0.75rem; border-right: 0; border-bottom: 0; }
html.cy-clean .cy-ident::after  { bottom: -1px; right: 0;  border-left: 0;  border-top: 0; }

/* ── BUILD LOG ──────────────────────────────────────────────────── */
html.cy-clean .cy-gallery-head__label {
  font-family: var(--cy-font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
html.cy-clean .cy-gallery-head__label::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--i-line);
}

/* Records. The recognition box is drawn with eight corner gradients on
   ::after so it needs no extra markup and can't be clipped by overflow. */
html.cy-clean .cy-project {
  position: relative;
  background: rgba(12, 18, 25, 0.55);
  border: 1px solid var(--i-line);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition: border-color 160ms ease, background-color 160ms ease, transform 220ms cubic-bezier(.2,.7,.3,1);
}
html.cy-clean .cy-project:hover,
html.cy-clean .cy-project:focus-within {
  border-color: transparent;
  background: rgba(16, 26, 34, 0.72);
}
html.cy-clean .cy-project::after {
  content: '';
  position: absolute;
  inset: -5px;
  pointer-events: none;
  opacity: 0;
  transform: scale(1.012);
  transition: opacity 140ms ease, transform 140ms cubic-bezier(.2,.7,.3,1);
  --c: var(--i-signal);
  --b: 1px;      /* bracket stroke */
  --l: 14px;     /* bracket leg */
  background:
    linear-gradient(var(--c), var(--c)) 0 0    / var(--l) var(--b) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 0    / var(--b) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0 / var(--l) var(--b) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0 / var(--b) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100% / var(--l) var(--b) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100% / var(--b) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100% / var(--l) var(--b) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100% / var(--b) var(--l) no-repeat;
}
html.cy-clean .cy-project:hover::after,
html.cy-clean .cy-project:focus-within::after { opacity: 1; transform: scale(1); }

/* Oversized record number, cropped by the card edge. */
html.cy-clean .cy-project__num {
  position: absolute;
  top: -0.14em; right: 0.4rem;
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(3.5rem, 7vw, 6rem);
  line-height: 1;
  color: transparent;
  -webkit-text-stroke: 1px var(--i-line);
  pointer-events: none;
  user-select: none;
  transition: -webkit-text-stroke-color 200ms ease;
}
html.cy-clean .cy-project:hover .cy-project__num { -webkit-text-stroke-color: rgba(79, 214, 214, 0.35); }

/* Thumbnails resolve from dithered-and-cold to sharp on hover. */
html.cy-clean .cy-project__media { position: relative; overflow: hidden; }
html.cy-clean .cy-project__media::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(to bottom,
      rgba(0,0,0,0.30) 0px, rgba(0,0,0,0.30) 1px,
      rgba(0,0,0,0) 1px, rgba(0,0,0,0) 2px),
    linear-gradient(to top, rgba(12,17,25,0.55), rgba(12,17,25,0.10));
  transition: opacity 260ms ease;
}
html.cy-clean .cy-project:hover .cy-project__media::after { opacity: 0.25; }
html.cy-clean .cy-project__img {
  filter: grayscale(0.55) contrast(1.12) brightness(0.82);
  transition: filter 320ms ease, transform 420ms cubic-bezier(.2,.7,.3,1);
}
html.cy-clean .cy-project:hover .cy-project__img { filter: none; }

html.cy-clean .cy-project__title {
  font-family: var(--cy-font-cond);
  font-weight: 600;
  letter-spacing: -0.01em;
  text-transform: uppercase;
}
html.cy-clean .cy-project__meta {
  font-family: var(--cy-font-mono);
  font-size: 0.64rem;
  letter-spacing: 0.14em;
  color: var(--i-dim);
}

/* Scroll-driven reveal. The finished state is the DEFAULT — the
   animation is layered on top only where supported, so a browser
   without scroll timelines simply shows the records. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    html.cy-clean .cy-project {
      animation: cy-rec-in linear both;
      animation-timeline: view();
      animation-range: entry 2% cover 22%;
    }
    @keyframes cy-rec-in {
      from { opacity: 0; transform: translateY(18px); }
      to   { opacity: 1; transform: none; }
    }
  }
}

/* ── Articles: the world calms down so long-form stays readable ──
   Same booth, but the pane is dimmed and the dust stops. Over-designing
   a 12,000-character article is where this aesthetic dies. */
html.cy-clean:not([data-cy-layout="wide"]) .cy-world { opacity: 0.55; }
html.cy-clean:not([data-cy-layout="wide"]) .cy-world__motes { display: none; }
html.cy-clean:not([data-cy-layout="wide"]) .cy-glass::after { opacity: 0.3; }
html.cy-clean:not([data-cy-layout="wide"]) main {
  background: rgba(10, 15, 21, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  padding: var(--cy-panel-pad);
  border: 1px solid var(--i-line);
}

/* ── Articles on a phone ──
   Two insets stack around the prose — .terminal-screen's padding (with extra
   on the left to clear the measurement rail) and the panel's --cy-panel-pad —
   and at 360px they left 258px of text. On a phone the rail goes, both insets
   tighten, and the plate follows automatically because it is sized off the
   same variable. */
@media (max-width: 46rem) {
  html.cy-clean:not([data-cy-layout="wide"]) { --cy-panel-pad: 1rem; }
  html.cy-clean:not([data-cy-layout="wide"]) .terminal-screen {
    padding-left: 0.75rem;
    padding-right: 0.75rem;
  }
  html.cy-clean:not([data-cy-layout="wide"]) .terminal-screen::after { display: none; }
}

/* ── Home runs full-bleed; no centred measure. ── */
html.cy-clean[data-cy-layout="wide"] .terminal-screen {
  max-width: 82rem;
  padding-left: clamp(1rem, 4vw, 3rem);
  padding-right: clamp(1rem, 4vw, 3rem);
}

/* ── Reduced motion: no parallax, no dust, no pulse. The world stays,
   because a still landscape is not a motion problem. ── */
@media (prefers-reduced-motion: reduce) {
  html.cy-clean .cy-world__motes { display: none; }
  html.cy-clean .cy-world__photo { background-position: 50% 45%; }
  html.cy-clean .cy-project,
  html.cy-clean .cy-project__img,
  html.cy-clean .cy-project::after { transition: none; }
}

/* ── Corrections found in review ─────────────────────────────────── */

/* Project names keep their authored casing everywhere. The tile variant used
   to uppercase them, which turned "RE:play" into "RE:PLAY" and destroyed the
   wordmark; the feature variant already opted out for that reason. The
   condensed face carries the impact without shouting. */
html.cy-clean .cy-project__title { text-transform: none; letter-spacing: -0.01em; }

/* Titles are ink, not cyan. Cyan is the signal colour — spending it on every
   title at rest leaves nothing to say "you are pointing at this", which is
   the job the recognition box and the hover state now do. */
html.cy-clean .cy-project__title { color: var(--i-ink); }
html.cy-clean .cy-project:hover .cy-project__title,
html.cy-clean .cy-project__title:focus-visible { color: var(--i-signal); }

/* The ident sits over the brightest part of the horizon, where dim body copy
   loses contrast. A soft scrim anchored to the text side fixes it without
   drawing a visible panel edge. */
html.cy-clean .cy-ident {
  /* The scrim must reach zero BEFORE the element's own edges, or the point
     where it stops reads as a seam ruled across the landscape. Sizing the
     ellipse to 76% of the box height keeps the fade inside the padding. */
  background: radial-gradient(118% 76% at 0% 28%,
    rgba(8, 12, 18, 0.88) 0%,
    rgba(8, 12, 18, 0.52) 42%,
    rgba(8, 12, 18, 0) 74%);
}

/* The scrim has to bleed past the content column or its own left edge reads as
   a panel border sitting in the middle of the landscape. Pull it out to the
   viewport edges and re-add the padding inside. */
html.cy-clean .cy-ident {
  margin-inline: calc(-1 * clamp(1rem, 4vw, 3rem));
  padding-inline: clamp(1rem, 4vw, 3rem);
}

/* Run the scrim up under the top rail too, so its upper edge doesn't read as
   a seam across the sky. */
html.cy-clean[data-cy-layout="wide"] .cy-ident {
  margin-top: calc(-1 * (var(--rail-h) + 2.5rem));
  padding-top: calc(var(--rail-h) + 2.5rem + clamp(1.5rem, 4vw, 3rem));
}

/* The ident opts out of the katakana pass (data-cy-render="skip"), so it needs
   an entrance of its own rather than simply being there before everything
   else. A short rise reads as the panel coming up with the interface. */
@media (prefers-reduced-motion: no-preference) {
  html.cy-clean .cy-ident { animation: cy-ident-in 620ms cubic-bezier(.2,.7,.3,1) both; }
  @keyframes cy-ident-in {
    from { opacity: 0; transform: translateY(10px); }
    to   { opacity: 1; transform: none; }
  }
}

/* ── Cyrillic ────────────────────────────────────────────────────
   Added for the Таксофон build. The Latin Plex subsets carry no
   Cyrillic, so without these "Таксофон" silently falls back to a
   system face and stops matching the rest of the page.

   `unicode-range` means the browser fetches these ONLY when Cyrillic
   actually appears, so the Latin pages pay nothing for them.

   Note the third block: IBM Plex Sans CONDENSED has no Cyrillic cut at
   all — none is published. Rather than let the display face fall back
   to something arbitrary, the condensed family borrows the regular
   Plex Sans Cyrillic for that range. The result is a Cyrillic title in
   the same superfamily at normal width, which reads as a deliberate
   choice; a fallback to whatever the OS picks does not. ── */
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/assets/fonts/IBMPlexSans-Cyrillic-400.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'IBM Plex Sans';
  src: url('/assets/fonts/IBMPlexSans-Cyrillic-600.woff2') format('woff2');
  font-weight: 600; font-style: normal; font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'IBM Plex Sans Condensed';
  src: url('/assets/fonts/IBMPlexSans-Cyrillic-600.woff2') format('woff2');
  font-weight: 600; font-style: normal; font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/assets/fonts/IBMPlexMono-Cyrillic-400.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

/* ── Status pill — for builds that are not finished ──────────────
   Amber is the palette's status colour, so an unfinished build wears it.
   Used in the page header statusbar and on the gallery record. */
html.cy-clean .cy-status {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--i-alarm);
  border: 1px solid rgba(255, 176, 32, 0.35);
  padding: 0.1rem 0.55rem;
}
html.cy-clean .cy-status::before {
  content: '';
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--i-alarm);
  box-shadow: 0 0 6px var(--i-alarm);
  animation: cy-pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  html.cy-clean .cy-status::before { animation: none; }
}
/* On a gallery record the pill sits in the eyebrow row. */
html.cy-clean .cy-project__eyebrow .cy-status { margin-left: 0.6rem; }

/* ── Таксофон — rotary pulse explainer ──────────────────────────
   Controls are hidden until dial_pulse.js adds .cy-dial--live, so a
   reader without JS gets the caption and never a dead slider. Same
   contract as .cy-retro on the green-screen page. */
html.cy-clean .cy-dial { margin: var(--cy-sp-4) 0; }
html.cy-clean .cy-dial__svg {
  display: block;
  width: 100%;
  height: auto;
  background: rgba(8, 12, 18, 0.5);
  border: 1px solid var(--i-line);
}
html.cy-clean .cy-dial__readout {
  font-family: var(--cy-font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.04em;
  color: var(--i-dim);
  padding: var(--cy-sp-2) 0 0;
}
html.cy-clean .cy-dial__readout strong {
  font-size: 1.15rem;
  letter-spacing: 0.1em;
  color: var(--i-ink);
}
/* The readout takes the colour of whichever reading is current, so the
   verdict and the diagram always agree. */
html.cy-clean .cy-dial__readout[data-state="split"]  strong { color: var(--i-signal); }
html.cy-clean .cy-dial__readout[data-state="merged"] strong { color: var(--i-alarm); }
html.cy-clean .cy-dial__k { color: var(--i-live); }
html.cy-clean .cy-dial__arrow { padding: 0 0.35em; }

html.cy-clean .cy-dial__controls { display: none; }
html.cy-clean .cy-dial--live .cy-dial__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--cy-sp-2) var(--cy-sp-3);
  padding: var(--cy-sp-2) 0 0;
}
html.cy-clean .cy-dial__ctl {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--i-dim);
}
html.cy-clean .cy-dial__ctl input[type="range"] { accent-color: var(--i-signal); width: 12rem; max-width: 45vw; }
html.cy-clean .cy-dial__play {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  text-transform: uppercase;
  color: var(--i-signal);
  background: transparent;
  border: 1px solid var(--i-line);
  padding: 0.3rem 0.8rem;
  cursor: pointer;
}
html.cy-clean .cy-dial__play:hover { border-color: var(--i-signal); color: var(--i-ink); }

/* ── Article structure ──────────────────────────────────────────
   Until now every article carried its sections as ":: SECTION" text
   inside a prose div, which looks the part but gives the document no
   structure at all — no outline, nothing for a screen reader to jump
   between. The Таксофон piece uses real headings. Styled to sit at the
   same volume the ":: " markers did, so the two conventions can coexist
   while the older pieces are still using the old one. */
html.cy-clean .cy-h {
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: 1.5rem;
  letter-spacing: -0.01em;
  line-height: 1.15;
  color: var(--i-ink);
  margin: var(--cy-sp-5) 0 var(--cy-sp-2);
  padding-bottom: var(--cy-sp-1);
  border-bottom: 1px solid var(--i-line);
}
html.cy-clean .cy-h:first-child { margin-top: 0; }
/* The top rail is fixed, so anything scrolled to lands underneath it.
   Applies to in-page anchors and to browser find-on-page jumps alike. */
html.cy-clean .cy-h,
html.cy-clean .cy-figure { scroll-margin-top: calc(var(--rail-h) + 1.5rem); }

/* Lede — the status line that opens an in-progress build log. */
html.cy-clean .cy-lede {
  /* Deliberately NOT flex: the sentence after the pill is a bare text node,
     which a flex container promotes to its own anonymous item and drops onto
     the next line. Inline flow keeps the pill in the sentence where it reads
     as a label rather than a heading. */
  color: var(--i-dim);
  padding-bottom: var(--cy-sp-3);
  margin-bottom: var(--cy-sp-3);
  border-bottom: 1px solid var(--i-line);
}

html.cy-clean .cy-list {
  margin: var(--cy-sp-2) 0 var(--cy-sp-3);
  padding-left: 1.15rem;
  list-style: none;
}
html.cy-clean .cy-list li {
  position: relative;
  margin-bottom: 0.4rem;
  color: var(--i-ink);
}
/* Terminal-style marker, matching .cy-mark elsewhere in the theme. */
html.cy-clean .cy-list li::before {
  content: "▸";
  position: absolute;
  left: -1.15rem;
  color: var(--i-signal);
}

/* ── Sub-heading ────────────────────────────────────────────────
   .cy-h is the section rule. A long piece sometimes needs one level
   below it (the seven control-plane views in the Signal Engine post),
   and jumping straight from a ruled 1.5rem heading to body text made
   every view read as a new section. Same face, no rule, quieter. */
html.cy-clean .cy-h--sub {
  font-size: 1.05rem;
  letter-spacing: 0.01em;
  text-transform: none;
  color: var(--i-ink);
  border-bottom: 0;
  padding-bottom: 0;
  margin: var(--cy-sp-4) 0 var(--cy-sp-1);
}

/* ── Numbered steps ─────────────────────────────────────────────
   An ordered list where the number is part of the readout rather than
   a browser marker: mono, signal-coloured, hung in the gutter next to
   the step it labels. The counter is drawn by CSS, so the markup stays
   a plain <ol> and the numbering survives an inserted step.

   white-space is reset because these sit inside .console, which is
   pre-wrap so that article prose can be authored with blank lines. The
   newlines between <li> tags would otherwise render as real gaps. */
html.cy-clean .cy-steps {
  list-style: none;
  counter-reset: cy-step;
  margin: var(--cy-sp-3) 0;
  padding-left: 3rem;
  white-space: normal;
}
html.cy-clean .cy-steps > li {
  counter-increment: cy-step;
  position: relative;
  margin-bottom: var(--cy-sp-2);
  color: var(--i-dim);
}
html.cy-clean .cy-steps > li::before {
  content: counter(cy-step, decimal-leading-zero);
  position: absolute;
  left: -3rem;
  top: 0.1em;
  width: 2rem;
  text-align: right;
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: var(--cy-tracking-meta);
  color: var(--i-signal);
}
/* The step's own name, ahead of its explanation. */
html.cy-clean .cy-steps > li > b,
html.cy-clean .cy-steps > li > strong {
  display: block;
  font-weight: 600;
  color: var(--i-ink);
}

/* ── Term list ──────────────────────────────────────────────────
   For the runs of "name, then what it does" that a build log collects:
   the exit ladder, the module map. A <dl> rather than a two-column
   table because it wraps to one column on a phone without a horizontal
   scroller, and because the pairs are definitions, not data. */
html.cy-clean .cy-defs {
  margin: var(--cy-sp-3) 0;
  white-space: normal;
  border-top: 1px solid var(--i-line);
}
html.cy-clean .cy-defs > div {
  display: grid;
  grid-template-columns: minmax(9rem, 14rem) 1fr;
  gap: 0 var(--cy-sp-3);
  padding: 0.55rem 0;
  border-bottom: 1px solid var(--i-line);
}
html.cy-clean .cy-defs dt {
  font-family: var(--cy-font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.04em;
  color: var(--i-ink);
  align-self: baseline;
}
html.cy-clean .cy-defs dd {
  margin: 0;
  color: var(--i-dim);
}
@media (max-width: 34rem) {
  html.cy-clean .cy-defs > div { grid-template-columns: 1fr; gap: 0.15rem; }
}

/* ── Pull figure ────────────────────────────────────────────────
   One number, set at display size, where the argument turns. Used once
   per article at most: the moment it stops being a paragraph is the
   moment it stops working. */
html.cy-clean .cy-pull {
  white-space: normal;
  margin: var(--cy-sp-5) 0;
  padding-left: var(--cy-sp-3);
  border-left: 2px solid var(--i-signal);
}
html.cy-clean .cy-pull__n {
  display: block;
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(2rem, 6vw, 3.2rem);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--i-ink);
}
html.cy-clean .cy-pull__t {
  display: block;
  margin-top: var(--cy-sp-1);
  color: var(--i-dim);
}

/* ── Authored-markup blocks inside the article body ─────────────
   .console is white-space: pre-wrap so that prose can be authored as
   blank-line-separated text. Anything whose markup is indented across
   several lines inherits that and renders its own newlines as real
   whitespace, so the structural blocks opt back out. Prose blocks
   (.project, .cy-lede) deliberately do not. */
html.cy-clean .console .cy-list,
html.cy-clean .console .cy-tags,
html.cy-clean .console dl,
html.cy-clean .console ol { white-space: normal; }

/* A chip row used as content, rather than as page-header metadata. */
html.cy-clean .console .cy-tags { margin: var(--cy-sp-2) 0 var(--cy-sp-3); }

/* ════════════════════════════════════════════════════════════════
   FEATURE ARTICLE — .cy-story  (RE:play first; opt-in per page)

   The older articles are one column of pre-wrap text with images the
   full width of that column. At the widened measure that made ~110
   character lines, and every image arrived at the same size and the
   same weight, so nothing told the reader where they were.

   A feature is laid out like a magazine spread instead:

     full   ├──────────────────────────────────────────────┤  opener photo, the spread
     wide       ├────────────────────────────────────┤        figures, the loop, spec
     text       ├──────────────────────┤                      the prose, ~65 characters
     aside                                 ├─────────┤        pull quotes (wide screens)

   The text column is deliberately left of centre: the right margin is
   where pull quotes hang, and an asymmetric column reads as a page
   rather than as a web form. Every child of .cy-story picks a column;
   nothing sits inside a pre-wrap block, so paragraphs are real <p>s.

   THE RULE still holds: the photographs are GLASS (graded, masked into
   the panel), the type is INTERFACE (sharp, never filtered).
   ════════════════════════════════════════════════════════════════ */
html.cy-clean .cy-story {
  --story-text: 38rem;
  --story-gap: 3.5rem;
  --story-aside: 13.5rem;
  --story-edge: var(--cy-panel-pad);
  --story-ink-2: #b4c6d1;           /* between body ink and dim: the dek, captions */

  display: grid;
  grid-template-columns:
    [full-start] minmax(var(--story-edge), 1fr)
    [wide-start text-start] minmax(0, var(--story-text))
    [text-end aside-start aside-end wide-end] minmax(var(--story-edge), 1fr)
    [full-end];
  /* The article runs edge to edge of the reading panel, so the opener
     photograph and the spread can reach the panel's border. */
  margin: calc(-1 * var(--cy-panel-pad)) calc(-1 * var(--cy-panel-pad)) 0;
  white-space: normal;
  color: var(--cy-fg);              /* body inherits the CRT stage's phosphor otherwise */
  font-size: 1.1875rem;             /* 19px: ~65 characters on the 38rem measure */
  line-height: 1.72;
}
html.cy-clean .cy-story > * { grid-column: text; min-width: 0; }

@media (min-width: 66rem) {
  html.cy-clean .cy-story {
    grid-template-columns:
      [full-start] minmax(var(--story-edge), 1fr)
      [wide-start text-start] minmax(0, var(--story-text))
      [text-end] minmax(0, var(--story-gap))
      [aside-start] minmax(0, var(--story-aside))
      [aside-end wide-end] minmax(var(--story-edge), 1fr)
      [full-end];
  }
}

/* ── Prose ── */
html.cy-clean .cy-story p { margin: 0 0 1.3rem; }

/* ── Opener ──────────────────────────────────────────────────────
   Photo full-bleed, dissolving into the panel; the title set big enough
   to be the image's other half, pulled up into the dissolve. */
html.cy-clean .cy-story__open {
  grid-column: full;
  display: grid;
  grid-template-columns: subgrid;
  position: relative;
  margin-bottom: var(--cy-sp-5);
}
html.cy-clean .cy-story__open > * { grid-column: text; min-width: 0; }
html.cy-clean .cy-story__photo {
  grid-column: full;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  position: relative;
  -webkit-mask-image: linear-gradient(to bottom, #000 0 55%, rgba(0,0,0,0.5) 80%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0 55%, rgba(0,0,0,0.5) 80%, transparent 100%);
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story__photo { aspect-ratio: 21 / 9; }
}
html.cy-clean .cy-story__photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Each opener sets its own crop on .cy-story__photo, since the 21:9 band
     keeps a different part of every photograph. */
  object-position: var(--story-pos, 50% 50%);
  filter: saturate(0.9) contrast(1.05) brightness(0.8);
}
html.cy-clean .cy-story__photo::after {   /* GLASS: the tiles' scanline wash */
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(to bottom, rgba(0,0,0,0.24) 0 1px, rgba(0,0,0,0) 1px 2px),
    linear-gradient(to bottom, rgba(10,15,20,0.45) 0%, rgba(10,15,20,0) 35%);
}
html.cy-clean .cy-story .cy-plate-go,
html.cy-clean .cy-story__go {
  position: absolute;
  top: var(--cy-sp-2);
  right: var(--cy-sp-2);
  z-index: 2;
}
html.cy-clean .cy-story__title {
  position: relative;
  z-index: 1;
  grid-column: text-start / full-end;
  margin: -0.62em 0 var(--cy-sp-3);
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(4rem, 12vw, 9rem);
  line-height: 0.86;
  letter-spacing: -0.035em;
  color: var(--i-ink);
  text-shadow: 0 2px 30px rgba(6, 10, 16, 0.9);
}
html.cy-clean .cy-story__dek {
  font-size: 1.3rem;
  line-height: 1.5;
  color: var(--story-ink-2);
  max-width: 34rem;
  margin: 0 0 var(--cy-sp-4);
}

/* The spec sheet: the facts a reader would otherwise dig for, set once
   under the dek so the story itself does not have to stop for them. */
html.cy-clean .cy-story__spec {
  grid-column: wide;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin: 0;
  border-top: 1px solid var(--cy-rule-lit);
  border-bottom: 1px solid var(--cy-rule);
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story__spec { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
html.cy-clean .cy-story__spec > div { padding: 0.8rem 1rem 0.9rem 0; }
html.cy-clean .cy-story__spec dt {
  font-family: var(--cy-font-mono);
  font-size: var(--cy-fs-meta);
  letter-spacing: 0.06em;
  color: var(--cy-accent);
  margin-bottom: 0.2rem;
}
html.cy-clean .cy-story__spec dd {
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.4;
  color: var(--i-ink);
}

/* The opening line is its own paragraph in the text, so it gets its
   own size: the first thing read, at the size of a sentence spoken. */
html.cy-clean .cy-story .cy-story__lede {
  font-family: var(--cy-font-cond);
  font-weight: 400;
  font-size: clamp(1.75rem, 3.2vw, 2.25rem);
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--i-ink);
  margin-bottom: var(--cy-sp-3);
}

/* ── Section heads ── The site's "::" marker, now hung in the margin
   so the headings' text lines up with the prose it introduces. */
html.cy-clean .cy-story__h {
  position: relative;
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(1.75rem, 3vw, 2.25rem);
  line-height: 1.08;
  letter-spacing: -0.02em;
  color: var(--i-ink);
  margin: var(--cy-sp-6) 0 var(--cy-sp-3);
  scroll-margin-top: calc(var(--rail-h) + 1.5rem);
}
html.cy-clean .cy-story__h::before {
  content: "::";
  display: block;
  margin-bottom: 0.35rem;
  font-family: var(--cy-font-mono);
  font-weight: 500;
  font-size: 1rem;
  letter-spacing: 0;
  color: var(--cy-accent);
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story__h::before {
    position: absolute;
    right: calc(100% + 0.9rem);
    top: 0.62em;
    margin: 0;
    line-height: 1;
  }
}

/* ── The motto ── the one line the whole piece turns on, set as display
   type across the text column rather than as a quiet blockquote. */
html.cy-clean .cy-story .cy-story__motto {
  grid-column: text-start / wide-end;
  margin: var(--cy-sp-4) 0 var(--cy-sp-5);
  padding: 0;
  border: 0;
  color: var(--i-ink);
}
html.cy-clean .cy-story .cy-story__motto p {
  margin: 0;
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(2rem, 4.6vw, 3.4rem);
  line-height: 1.02;
  letter-spacing: -0.025em;
  text-wrap: balance;
}
html.cy-clean .cy-story .cy-story__motto p::before { content: "\201C"; color: var(--cy-accent); }
html.cy-clean .cy-story .cy-story__motto p::after  { content: "\201D"; color: var(--cy-accent); }
@media (min-width: 46rem) {
  /* Hang the open quote so the words, not the mark, align with the prose. */
  html.cy-clean .cy-story .cy-story__motto p { text-indent: -0.42em; }
}

/* ── Pull quotes ── In the flow on a narrow screen; hung in the right
   margin beside the passage they come from on a wide one. They repeat
   the text, so they are aria-hidden in the markup. */
html.cy-clean .cy-story__pull {
  margin: var(--cy-sp-2) 0 var(--cy-sp-4);
  padding-top: 0.9rem;
  border-top: 2px solid var(--cy-accent);
  max-width: 24rem;
  font-family: var(--cy-font-cond);
  font-weight: 400;
  font-size: 1.45rem;
  line-height: 1.22;
  color: var(--i-ink);
}
@media (min-width: 66rem) {
  html.cy-clean .cy-story__pull {
    grid-column: aside;
    grid-row: span 3;              /* shares rows with the paragraphs beside it */
    align-self: start;
    margin: 0.4rem 0 0;
  }
}
/* A fact, not a quote: the number carries it. */
html.cy-clean .cy-story__pull--fact b {
  display: block;
  font-weight: 600;
  font-size: 2.6rem;
  line-height: 1;
  letter-spacing: -0.02em;
  margin-bottom: 0.4rem;
}
html.cy-clean .cy-story__pull--fact { font-size: 1.1rem; color: var(--story-ink-2); }
html.cy-clean .cy-story__pull--fact b { color: var(--i-ink); }

/* ── Figures ── wide by default; one full-bleed spread per piece. */
html.cy-clean .cy-story__fig {
  margin: var(--cy-sp-5) 0;
  padding: 0;
}
html.cy-clean .cy-story__fig--wide { grid-column: wide; }
html.cy-clean .cy-story__fig img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--cy-rule);
  background: var(--cy-panel);
}
html.cy-clean .cy-story__fig figcaption {
  max-width: var(--story-text);
  margin-top: 0.75rem;
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--cy-fg-dim);
}
/* The spread: panel edge to panel edge, dissolving top and bottom so it
   reads as a window in the page rather than a rectangle on it. */
html.cy-clean .cy-story__fig--full {
  grid-column: full;
  margin: var(--cy-sp-5) 0 0;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 70%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 18%, #000 70%, transparent 100%);
}
html.cy-clean .cy-story__fig--full img {
  border: 0;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  object-position: 50% 60%;
  filter: saturate(0.9) brightness(0.85);
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story__fig--full img { aspect-ratio: 21 / 9; }
  /* The heading after the spread rides up into its dissolve. */
  html.cy-clean .cy-story__fig--full + .cy-story__h { margin-top: calc(-1 * var(--cy-sp-4)); }
}

/* ── The loop ── the one diagram. A track of stations left to right,
   and a dashed return path over the top from the updater back to the
   form: the round trip the section describes. Vertical on a phone. */
html.cy-clean .cy-loop { margin: var(--cy-sp-4) 0 var(--cy-sp-5); }
html.cy-clean .cy-loop__track {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 0 2.25rem 0 0;
  display: grid;
  gap: 0;
}
html.cy-clean .cy-loop__track > li {
  position: relative;
  padding: 0 0 1.1rem 1.5rem;
  border-left: 1px solid var(--cy-rule-lit);
}
html.cy-clean .cy-loop__track > li::before {       /* station */
  content: "";
  position: absolute;
  left: -4.5px; top: 0.45rem;
  width: 8px; height: 8px;
  background: var(--cy-accent);
}
html.cy-clean .cy-loop__track > li:last-child { border-left-color: transparent; padding-bottom: 0; }
html.cy-clean .cy-loop__track::before {             /* return path */
  content: "";
  position: absolute;
  right: 0;
  top: 0.8rem;
  bottom: 0.8rem;
  width: 1.5rem;
  border: 1px dashed var(--cy-accent);
  border-left: 0;
  opacity: 0.7;
}
html.cy-clean .cy-loop__track b {
  display: block;
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: 1.1rem;
  line-height: 1.3;
  color: var(--i-ink);
}
html.cy-clean .cy-loop__track span {
  display: block;
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--cy-fg-dim);
}
html.cy-clean .cy-loop .cy-loop__back {
  margin-top: 1rem;
  font-family: var(--cy-font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  color: var(--cy-accent);
}
@media (min-width: 46rem) {
  html.cy-clean .cy-loop__track {
    grid-template-columns: repeat(6, minmax(0, 1fr));
    padding: 2.25rem 0 0;
  }
  html.cy-clean .cy-loop__track > li {
    padding: 1.1rem 0.9rem 0 0;
    border-left: 0;
    border-top: 1px solid var(--cy-rule-lit);
  }
  html.cy-clean .cy-loop__track > li:last-child { border-top-color: transparent; }
  html.cy-clean .cy-loop__track > li::before { left: 0; top: -4.5px; }
  html.cy-clean .cy-loop__track::before {
    left: 4px;
    right: auto;
    width: calc((100% - 8px) * 5 / 6);   /* first station to last */
    top: 0.6rem;
    bottom: auto;
    height: 1.65rem;
    border: 1px dashed var(--cy-accent);
    border-bottom: 0;
  }
  /* Arrowhead where the return path lands on the first station. */
  html.cy-clean .cy-loop__track::after {
    content: "";
    position: absolute;
    left: 0;
    top: calc(2.25rem - 12px);
    border: 4.5px solid transparent;
    border-top: 6px solid var(--cy-accent);
  }
  html.cy-clean .cy-loop .cy-loop__back { margin-top: 1.25rem; }
}

/* ── The close ── the last line of the piece, with an end mark. */
html.cy-clean .cy-story .cy-story__last {
  grid-column: text-start / wide-end;   /* room for the end mark on the line */
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: clamp(1.75rem, 3.4vw, 2.4rem);
  line-height: 1.1;
  letter-spacing: -0.015em;
  color: var(--i-ink);
  margin: var(--cy-sp-3) 0 var(--cy-sp-6);
}
html.cy-clean .cy-story .cy-story__last::after {
  content: "";
  display: inline-block;
  width: 0.42em; height: 0.42em;
  margin-left: 0.3em;
  background: var(--cy-accent);
}

/* ── Get it ── one link, image and words together. */
html.cy-clean .cy-story a.cy-story__get {
  grid-column: wide;
  display: grid;
  grid-template-columns: 1fr;
  margin: 0 0 var(--cy-sp-5);
  border: 1px solid var(--cy-rule-lit);
  background: var(--cy-panel);
  color: inherit;
  text-decoration: none;
  transition: border-color 120ms ease;
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story a.cy-story__get { grid-template-columns: 3fr 2fr; align-items: center; }
}
html.cy-clean .cy-story a.cy-story__get:hover,
html.cy-clean .cy-story a.cy-story__get:focus-visible { border-color: var(--cy-link); text-decoration: none; }
html.cy-clean .cy-story__get img {
  display: block;
  width: 100%;
  height: auto;
  filter: saturate(0.92) brightness(0.9);
  transition: filter 150ms ease;
}
html.cy-clean .cy-story a.cy-story__get:hover img { filter: none; }
html.cy-clean .cy-story__get-text {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: var(--cy-sp-3);
}
html.cy-clean .cy-story__get-name {
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: 2rem;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--i-ink);
}
html.cy-clean .cy-story__get-line { font-size: 1rem; line-height: 1.5; color: var(--story-ink-2); }
html.cy-clean .cy-story__get-cta {
  font-family: var(--cy-font-mono);
  font-size: 0.85rem;
  color: var(--cy-link);
}
html.cy-clean .cy-story__get-cta::after { content: " \2197"; color: var(--cy-accent); }

@media (prefers-reduced-motion: reduce) {
  html.cy-clean .cy-story a.cy-story__get,
  html.cy-clean .cy-story__get img { transition: none; }
}

/* ── .cy-story, beyond RE:play ─────────────────────────────────────
   The pieces the other articles need that RE:play did not: a status
   line for work in progress, a quieter sub-head, portrait photographs,
   pairs, tall screenshots, code, an embedded video, and the two
   interactive explainers (.cy-retro, .cy-dial), which take the wide
   column like any other figure. */

/* A note above the story — "in progress", "paper account". Said once,
   quietly, in the voice of the instrument rather than the author. */
html.cy-clean .cy-story .cy-story__note {
  grid-column: wide;
  font-family: var(--cy-font-mono);
  font-size: 0.8rem;
  line-height: 1.55;
  letter-spacing: 0.02em;
  color: var(--cy-fg-dim);
  margin: 0 0 var(--cy-sp-4);
}
html.cy-clean .cy-story__note b { color: var(--cy-accent); font-weight: 500; }

/* One level under .cy-story__h, for a tour through several screens. */
html.cy-clean .cy-story__h3 {
  font-family: var(--cy-font-cond);
  font-weight: 600;
  font-size: 1.35rem;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--i-ink);
  margin: var(--cy-sp-4) 0 var(--cy-sp-1);
  scroll-margin-top: calc(var(--rail-h) + 1.5rem);
}

/* Portrait photographs sit on the text column, capped so a phone photo
   of a payphone does not become a full screen of scrolling. */
html.cy-clean .cy-story__fig--tall img {
  width: auto;
  max-width: 100%;
  max-height: 44rem;
}

/* Two figures side by side on the wide column, one caption under both.
   Stacked on a phone. */
html.cy-clean .cy-story__pair {
  grid-column: wide;
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--cy-sp-2);
  align-items: start;
}
@media (min-width: 46rem) {
  html.cy-clean .cy-story__pair { grid-template-columns: 1fr 1fr; }
}
html.cy-clean .cy-story__pair img { max-height: 40rem; object-fit: cover; }
html.cy-clean .cy-story__pair figcaption { grid-column: 1 / -1; margin-top: 0; }

/* A tall dashboard capture, shown as its top screenful. The caption says
   what the rest of it holds. */
html.cy-clean .cy-story__fig--crop img {
  aspect-ratio: 16 / 10;
  object-fit: cover;
  object-position: 50% 0;
}

/* Code: the one place mono is right in a story. */
html.cy-clean .cy-story pre {
  grid-column: wide;
  margin: 0 0 1.5rem;
  padding: 1rem 1.25rem;
  background: var(--cy-panel);
  border: 1px solid var(--cy-rule);
  border-left: 2px solid var(--cy-accent);
  white-space: pre-wrap;
  font-family: var(--cy-font-mono);
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--i-ink);
}
html.cy-clean .cy-story pre code { font-family: inherit; font-size: inherit; }
html.cy-clean .cy-story :not(pre) > code {
  font-family: var(--cy-font-mono);
  font-size: 0.86em;
  color: var(--i-ink);
  background: rgba(255, 255, 255, 0.05);
  padding: 0.05em 0.3em;
}

/* Lists, when a list really is the clearest way to say it. */
html.cy-clean .cy-story .cy-list { margin: 0 0 1.3rem; }
html.cy-clean .cy-story .cy-list li { margin-bottom: 0.55rem; color: var(--cy-fg); }

html.cy-clean .cy-story .cy-video { grid-column: wide; margin: var(--cy-sp-4) 0 var(--cy-sp-5); }
html.cy-clean .cy-story .cy-retro,
html.cy-clean .cy-story .cy-dial { grid-column: wide; margin: var(--cy-sp-5) 0; }
html.cy-clean .cy-story .cy-retro figcaption,
html.cy-clean .cy-story .cy-dial figcaption { max-width: none; }
