/* Elliot's website — all styling lives here. */

/* ===================================================================
   1) Design tokens
   =================================================================== */

:root {
  /* One spacing knob for the whole site: the gap between every box — across
     columns and down them — plus the breathing room to the page edges. Bump
     this single value to loosen or tighten everything at once. */
  --gap: 48px;

  /* The dim behind a "fill screen" view — shared by the slot spotlight and the
     movie-stills lightbox so both darken the page to the same depth. A single
     layer (the spotlight no longer stacks two of these): enough to recede the
     page without blacking it out. Tune this one value to taste. */
  --overlay-dark: rgba(20, 12, 30, 0.78);

  /* ---- Structural Colors (Shifted Purple) ---- */
  --accent-primary: #b3bded;   /* Window frames, tabs, main borders */
  --accent-secondary: #c5cdfc; /* Inactive tabs, lighter areas */
  --accent-light: #d8dffc;     /* Hover states for tabs */
  --accent-dark: #8a95d1;      /* Hard wall under tabs/structural borders, dimmed text */
  --accent-ink: #5b67a1;       /* Deep purple accent ink: jackpot result, achievement headings */

  /* ---- Interactive Colors (Lemon) ---- */
  --action-face: #fce588;      /* Primary action button face and badge backgrounds */
  --action-hover: #fdf0b5;     /* Hover state for primary action */
  --action-edge: #d4bc57;      /* Wall/shadow for primary action buttons */
  --action-ink: #333333;       /* Dark text ink for high contrast on yellow */

  /* ---- Surface / line roles, one token each ----
     A herd of near-identical one-off tints used to live across the file;
     they were merged into these. Reach for a role, not a new hex. */
  --pane: #fbf9ff;        /* the calm light content surface: the window body, panels, note walls */
  --pane-inset: #efeaf8;  /* sunk into the pane: scrollbar tracks, chips, flat buttons, hover washes */
  --hairline: #e0d6f3;    /* faint mauve separators and quiet borders around scroll boxes */
  --mauve-dim: #bac2eb;   /* the dimmed purple of dashed "locked" outlines */

  /* Z-index scale */
  --z-bottom: -2;
  --z-bg: -1;
  --z-default: 1;
  --z-above: 2;
  --z-elevated: 3;
  --z-tabbar: 10;
  --z-dropdown: 20;
  --z-tooltip: 80;
  --z-modal: 9000;
  --z-toast: 9999;


  /* The site is predominantly dark by design. Declaring it dark tells the browser not to
     force-darken the page, and pairs with the <meta name="darkreader-lock">
     in each head that asks the Dark Reader extension to leave the site alone. */
  color-scheme: dark;

  /* ---- App-window shell (starry desktop + framed windows) ----
     The starfield is the desktop wallpaper; the site lives in framed windows
     floating on it. --star-margin is the desktop padding the wallpaper shows in;
     --star-gap is the space between the radio window and the app window.
     The 10px double-mauve border is off #tab-panel content
     (borderless .section / inset .panel), but still on the window frame (--win-border)
     AND on .box — which survives as the title box and not_found.html's trail boxes. */
  --star-margin: 28px;
  --star-gap: 22px;
  --win-border: double var(--accent-primary) 10px;
}

/* ===================================================================
   2) Base & typography
   The page's voice: body text, links, and the three content containers
   (.section / .panel / .box) sharing one heading + prose scale.
   =================================================================== */

/* Kaomoji fallback font. Piper's face uses two Hangul-jamo "spider leg" glyphs
   (ᄽ U+113D, ᄿ U+113F). No iOS font carries them — on macOS only Arial Unicode
   MS does, and that isn't on iOS, so they render as tofu there. This ~370-byte
   font is hand-built (fontTools; two glyphs, straight-line contours only),
   drawn to Arial Unicode MS's full-height straight-legged design — every real
   Korean font (Noto/Nanum/UnFonts/KoPub were all tried) draws these jamo small,
   curved, and floated high in syllable-block position, nothing like the spider
   legs. unicode-range confines it to the two codepoints, so it touches nothing
   else on the site. Rebuild with build_kaolegs.py (docs/build-scripts.md); if
   other kaomoji turn up missing on a device, they need their own fallback. */
@font-face {
  font-family: 'KaoFallback';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("data:font/woff2;base64,d09GMgABAAAAAAFwAAoAAAAAAxgAAAElAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAAPAqBEIEFATYCJAMKCwgABCAFgQ4HIBtSAgCO02X2ojS6eIAfR76PEzS7yU1CKstmWgqL11Z3KQvp4/M7007CKKHCkH6AgQdWIHkYSKAB5XvZTppPfOCc0quXIBbDjv1FKuwIK5CZoxpdPqVmac3yov5vQB8FCADoBhEJ9ZKAetkwCmVBfCVOTaF+8vfkL/H0uBAZzVgMyIAoFlq68/rRbYeu7E/69WsHdhy/euLxl3l9D+9/WHRg/cynnWte/5ned/H+s4mD6zquds556cTzogItCFAeDe+nF2hBgPLoeyAgsGLlqqu7q5b8K07F4O8dw3nfV0VFqwo+Iokg8DWhGEG+lsohxl2zDL0CknIRkmmVzZOEXCKo5KZRVqqsUzst3F5krb1OW+egw87borGLTtjrXF/fvbaJ9sQAAAA=") format("woff2");
  unicode-range: U+113D, U+113F;
}

body {
  margin: 0;
  background: #020204;  /* deep space — the animated starfield canvas (stargaze.js) paints over this */
  color: black;
  font-family: 'KaoFallback', Verdana, Geneva, sans-serif;
  font-size: 14px;
}

/* Links: dark periwinkle text + underline. Hover doesn't change color. */
a {
  color: inherit;
}

.section a, .panel a, .box a, .blog-post-body a {
  color: var(--accent-ink);
  text-decoration: underline;
}

.read-more-toggle {
  background: var(--accent-primary);
  color: #000;
  border: 1px solid var(--accent-dark);
  border-radius: 4px;
  padding: 2px 6px;
  cursor: pointer;
  white-space: nowrap;
  font-size: 0.9em;
  font-weight: bold;
  display: inline-block;
}

/* Responsive Read More */
@media (max-width: 1150px) {
  .hide-on-mobile:not(.expanded) { display: none; }
  .desktop-only { display: none !important; }
}
@media (min-width: 1151px) {
  .hide-on-desktop:not(.expanded) { display: none; }
  .mobile-only { display: none !important; }
}

a.key:hover {
  color: var(--action-ink);
}



/* ---- Content units ---- */
.section {
  text-align: left;
  word-wrap: break-word;
}
/* One typographic voice for the three content containers (.section, .panel,
   .box): the same heading margins, sizes and prose rhythm, stated once. h2 is
   the page/block heading, h3 a sub-heading; the only centered heading on the
   site is the title-box h1 (chrome, not content). Deliberate divergences: .box
   h2 is the title box's small 1.15em subtitle (and .box has no h3 scale), and
   .box centers itself, so its p/ul re-assert left below. */
.section h1, .section h2, .section h3,
.panel h1, .panel h2, .panel h3,
.box h1, .box h2 { margin: 4px 0 8px; }
.section h1, .section h2, .section h3,
.panel h1, .panel h2, .panel h3 { text-align: left; }
.section h1, .panel h1, .box h1 { font-size: 1.7em; }
.section h2, .panel h2 { font-size: 1.5em; }
.section h3, .panel h3 { font-size: 1.2em; }
.box h2 { font-size: 1.15em; }
.section p, .panel p, .box p { margin: 8px 0; line-height: 1.5; }
.panel p, .box p { text-align: left; }
.section ul, .panel ul, .box ul { margin: 8px 0; padding-left: 20px; line-height: 1.6; }
.panel ul, .box ul { text-align: left; }
/* Kaomoji faces inside a bold heading: drop to normal weight so exotic glyphs
   (Hangul/Greek/Latin-diacritic) render uniformly instead of a heavier
   fallback bold on some of them. */
.kao { font-weight: normal; }
/* A kaomoji must never be chopped across a line break — a face with inner
   spaces (or CJK arms) splits mid-face on a narrow line otherwise. It wraps to
   the next line whole; a very long face slightly overflowing a very narrow
   line is fine, chopping is not. .nobr is the weight-neutral wrapper for faces
   in ordinary text (bold headings/prose keep their weight — .kao would
   visibly thin them). */
.kao, .nobr { white-space: nowrap; }

.panel {
  background: var(--pane);
  border: 1px solid var(--accent-primary);
  border-radius: 4px;
  padding: 12px 16px;
  text-align: left;
  word-wrap: break-word;
  box-sizing: border-box;
}

/* The box: white fill, mauve double-border outline, barely rounded. Its
   heading/prose typography lives in the shared content-container block up top. */
.box {
  background: white;
  border: double var(--accent-primary) 10px;
  border-radius: 4px;
  padding: 12px 16px;
  text-align: center;
  word-wrap: break-word;
  box-sizing: border-box;
}

/* ===================================================================
   3) Shell — the starry desktop (>1150)
   The starfield is the wallpaper; a left console (title box + bare radio)
   sits beside one framed app window whose tab bar swaps #tab-panel.
   =================================================================== */

/* ---- Left console + content column ---- */

/* The left console is 378px — wider than a plain sidebar — so the radio keeps its
   original side-by-side design (brand to the left of the LCD). The 362px slab sits
   flush-left inside it; the extra ~16px on the right is where the side-volume nubs
   live fully off the device silhouette (see radio.html). */
.col-left {
  width: 378px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  align-self: flex-start;
}

.col-center {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

/* The menu now rides as a horizontal bar across the top of the content area, so
   everything to the right of the left column is its own column: the top-nav, then
   the content beneath it. */
/* The radio: a self-contained iframe (radio.html) sitting under the title in the
   left column on every page. Transparent and borderless — the aluminium device
   just floats over the starfield, no box. Its height is fixed (the device's
   rendered height at this width); it carries the audio that survives navigation. */
.radio-frame {
  display: block;
  width: 100%;
  max-width: 378px;
  height: 556px;
  margin: 0 auto;
  border: 0;
  background: transparent;
  color-scheme: dark;
}

/* not_found.html's leftover wrapper: dissolve it on narrow screens so its
   boxes stack as #tab-panel's own flex items. It's the last `.col-center` on
   the site. */
@media (max-width: 1150px) {
  .col-center { display: contents; }
}

/* ===================================================================
   App-window shell. Scoped to <body class="os">, which every real page
   carries (radio.html and the kept-but-unused files stay outside it).
   The desktop owns the viewport; #tab-panel is the only scroll region.
   =================================================================== */
html:has(.desktop) { height: 100%; overscroll-behavior: none; }
body.os {
  /* Pinned, not just overflow:hidden — iOS Safari rubber-band-scrolls the whole
     document even when the body says overflow:hidden, letting a visitor drag the
     starry shell around. Fixing the body to the viewport leaves no scrollable
     document to bounce, so #tab-panel stays the one scroll region. 100dvh (not
     top/bottom:0) so the box tracks the dynamic viewport as the address bar
     comes and goes. */
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
  overscroll-behavior: none;
  --gap: 26px;                      /* tighter than the old 48px airy desktop gap */
}

/* The desktop stacks the window row over the footer band, with star gaps. */
.desktop {
  height: 100dvh;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--star-gap);
  padding: var(--star-margin);
}
.desktop-stage {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: stretch;
  gap: var(--star-gap);
}

/* Left console: the title box (stargaze toggle) over the bare radio, floating on
   the wallpaper — no window frame. */
body.os .col-left {
  position: static;
  align-self: flex-start;
}
.radio-scale {
  width: 378px;
  height: 556px;
  overflow: hidden;
}
.radio-scale .radio-frame {
  width: 378px;
  height: 556px;
  max-width: none;
  margin: 0;
  transform-origin: top left;       /* JS sets transform: scale(s) on short screens */
}

/* App window: a framed pane filling the rest of the stage. The heavy double-mauve
   border lives here (the window frame), not on content boxes. */
.win {
  border: var(--win-border);
  border-radius: 6px;
  background: white;
  box-sizing: border-box;
  min-height: 0;
}

/* App window: fills the rest of the desktop, full height, as a column. */
.win-app {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

/* The tab bar is the app window's title bar: mauve chrome, browser-style tabs.
   overflow stays visible so the secrets dropdown isn't clipped; the inner tab
   list wraps onto extra rows instead (main.js flags 2+ rows with .is-wrapped —
   see the wrapped-bar block below). */
.win-tabbar {
  flex: 0 0 auto;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  padding: 8px 10px 0;
  background: var(--accent-primary);
  border-bottom: 4px solid var(--accent-dark);
  overflow: visible;
  position: relative;
  z-index: var(--z-dropdown);
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.win-tabs {
  list-style: none;
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 4px;
  margin: 0;
  padding: 0;
  min-width: 0;
}
.win-tabs li { display: block; }
/* Every tab carries a real bottom border and then hangs 4px past the bar's content
   box, so on the desktop's single row that bottom lands exactly on the bar's own 4px
   bottom border and vanishes into it — the tabs read as open-bottomed, the way a tab
   should. The moment they wrap, only the last row can do that trick, and an upper-row
   tab without a bottom is a U hanging in the mauve. Giving them all bottoms costs the
   desktop nothing and closes the wrapped ones into proper tab shapes. */
.win-tabs a,
.secrets-toggle {
  display: block;
  background: var(--accent-secondary);
  color: #333;
  text-decoration: none;
  font-weight: bold;
  font: inherit;
  white-space: nowrap;
  padding: 6px 14px;
  border: 4px solid var(--accent-dark);
  border-radius: 8px 8px 0 0;
  margin-bottom: -4px;
  cursor: pointer;
  transition: background 0.2s, color 0.2s;
}
.win-tabs a:hover,
.secrets-toggle:hover { 
  background: var(--accent-light); 
  color: #000;
}
/* The active tab opens into the pane: it keeps a bottom border like every other tab,
   but paints it the content colour, so the 4px it hangs over the bar's bottom border
   cuts the notch that makes the whole tab metaphor work. (Removing the border instead
   would let the mauve bar show through and close the notch.) The shorter bottom
   padding pays for the border, so it stands exactly as tall as it did before. */
.win-tabs a.active,
.win-tabs a[aria-current="page"],
.secrets-toggle.active {
  background: var(--pane);              /* matches the content surface */
  color: #000;
  padding-bottom: 8px;
  border-bottom-color: var(--pane);
}

/* The wrapped desktop bar (main.js toggles .is-wrapped on 2+ rows): with the
   secrets dropdown and the draft link in, the bar wraps well above the 1150
   breakpoint, and an active tab on an upper row would paint its open pane-white
   bottom into raw mauve. Mirror the ≤1150 treatment — every row keeps a closed
   bottom, and the active tab returns to the shared padding (no notch to pay
   for) so all the tabs stand the same height. Scoped above the breakpoint so
   the fluid ≤1150 sizes below stay the only voice on a phone. */
@media (min-width: 1151px) {
  .win-tabbar.is-wrapped .win-tabs a,
  .win-tabbar.is-wrapped .secrets-toggle {
    margin-bottom: 0;
  }
  .win-tabbar.is-wrapped .win-tabs a.active,
  .win-tabbar.is-wrapped .win-tabs a[aria-current="page"],
  .win-tabbar.is-wrapped .secrets-toggle.active {
    padding-bottom: 6px;
    border-bottom-color: var(--accent-dark);  /* closed too; the pane-white fill marks it */
  }
}

/* Secrets dropdown: a tab-styled toggle dropped in after the last tab once ≥1
   secret is unlocked, showing "n / 3"; clicking opens the rack of slots. */
.secrets-dd { position: relative; flex: 0 0 auto; }
.secrets-count { font-weight: normal; opacity: 0.85; }
.secrets-caret { margin-left: 3px; }
.secrets-menu {
  position: absolute;
  top: 100%;
  right: 0;
  z-index: var(--z-tooltip);
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 150px;
  padding: 10px;
  background: var(--accent-primary);
  border: 4px solid var(--accent-dark);
  border-top: 0;
  border-radius: 0 0 6px 6px;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
.secrets-menu[hidden] { display: none; }

.win-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--pane);             /* the calm light content surface */
  position: relative;
  z-index: var(--z-tabbar);
}
#tab-panel {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--gap);
  scrollbar-color: var(--accent-primary) var(--pane-inset);
  /* The swap crossfade: router.js adds .is-fading, waits 120ms (match the
     duration here), swaps the content, then removes the class — so the old
     pane fades out and the new one fades back in.
     The animation is the FIRST-load fade-in: it plays once when the panel
     first renders (even before scripts run), and never again — swaps reuse
     the element, so only the transition is in play after that. */
  transition: opacity 0.12s ease;
  animation: inject-fade 0.12s ease;
}
#tab-panel.is-fading { opacity: 0; }
/* Content that arrives AFTER the pane faded in (relay fetches: blog posts,
   guestbook notes, the draft editor's post list) used to pop. A CSS animation
   plays automatically when a node is inserted, so freshly created children of
   these containers fade in with no help from the modules that make them.
   Duration matches the crossfade — keep the two in step. */
@keyframes inject-fade { from { opacity: 0; } }
.blog-post, #blog-status > *,
#post-list > *, #gbadmin-list > *,
#gb-notes > *,
#about-slideshow, #email-gif,
#poster-grid > *,
#tab-panel[data-route="podcasts"] .card { animation: inject-fade 0.12s ease; }
@media (prefers-reduced-motion: reduce) {
  #tab-panel { transition: none; animation: none; }   /* hard cut, like the site's other fades */
  .blog-post, #blog-status > *,
  #post-list > *, #gbadmin-list > *,
  #gb-notes > *,
  #about-slideshow, #email-gif,
  #poster-grid > *,
  #tab-panel[data-route="podcasts"] .card { animation: none; }
}
/* Two scrollbar vocabularies, and the -webkit- half is unverified rather than dead —
   don't trim it on sight. Chrome honours the standard scrollbar-color above and
   ignores these outright (measured in Chrome 150: a probe with a red thumb and a lime
   track painted neither), but WebKit is the one place they'd still bite and Chrome is
   the only engine we can drive here. Widening the bar isn't on offer either way: on
   macOS/iOS it's a system overlay, so it takes no layout space (#tab-panel's gutter
   measures 0) and no CSS can make it thicker. */
#tab-panel::-webkit-scrollbar { width: 12px; }
#tab-panel::-webkit-scrollbar-track { background: var(--pane-inset); }
#tab-panel::-webkit-scrollbar-thumb {
  background: var(--accent-primary);
  border-radius: 6px;
  border: 2px solid var(--pane-inset);
}

/* The inner light scroll boxes tint their bars mauve too — the root is
   color-scheme: dark, so without this a Windows/Linux visitor gets a dark
   system scrollbar against these pale panels (#tab-panel already sets its own).
   On macOS/iOS the bar is a system overlay, so this is near-invisible there. */
.gb-notes, .poster-grid, .post-list-ul, .gbadmin-list, .kao-menu {
  scrollbar-color: var(--accent-primary) var(--pane-inset);
}

/* Status bar = the footer band (train rail): the last row of the desktop column,
   bled out past the desktop padding to the viewport edges and flush to the very
   bottom (full-width, no margin) so it reads as the page's bottom rail like the
   old footer. Transparent on purpose — the train crosses the open middle over the
   starfield, and the black tunnel-mouth illusion comes from the black-filled back
   PNGs, not a band fill. Still a <footer>, so the footer rules + train JS apply. */
.desktop > .win-statusbar {
  flex: 0 0 auto;
  margin: 0 calc(-1 * var(--star-margin)) calc(-1 * var(--star-margin));
}

/* The title is a plain black heading that toggles stargaze (stargaze.js wires the
   whole box, not just the link) — clickable, but never a mauve button and never
   underlined. The pointer cursor is on the whole box so the entire card reads as
   the button, not only the text. */
.title-box { cursor: pointer; }
.title-box .title-link {
  display: block;
  text-decoration: none;
  color: inherit;
  cursor: pointer;
}
.title-link h1 { margin: 0; }

/* ---- Inner layouts for #tab-panel ---- */
.layout-wide {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

.layout-rail {
  display: grid;
  grid-template-columns: 1fr 280px;
  gap: var(--gap);
  align-items: start;
}
.rail-main {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  min-width: 0;
}
/* min-width: 0 for the same reason .rail-main has it: a grid item defaults to
   min-width: auto, which floors the track at the item's min-content width. Without
   it the 1fr track can't shrink below the widest thing in the rail and the panel
   scrolls sideways on a phone. */
.rail-side {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  min-width: 0;
}
/* Collapse the rail layout on small screens. */
@media (max-width: 500px) {
  .layout-rail {
    grid-template-columns: 1fr;
  }
}

/* ===================================================================
   4) Shell — mobile: the 2-page slide (<=1150)
   Start page (stargazing + radio) <-> app window; each page carries its own
   switch. See docs/layout.md for the four load-bearing couplings here.
   =================================================================== */

/* ---- The two mobile page switches (shape only; the ≤1150 block places them) ----
   A matched pair now: both wear the house .key (light-mauve tile, purple label,
   deeper mauve wall). The light face is what lets one key read on the dark
   wallpaper (come look around) and its twin on the mauve tab bar (go back) — a
   base-mauve key went muddy on the mauve bar. In the bar the lighter key also
   reads as "not a tab" against the flat mauve tabs, which is exactly what it is:
   both are <button>s that only slide the stage, never navigate. Off by default:
   above 1150 both pages are on screen and there's nothing to switch. */
.tabbar-start-btn,
.console-site-btn {
  display: none;
}
/* Go back: a compact key sized to sit in the tight tab bar (thin border, low
   wall, minimal side padding) — the bar is full at two rows on a 375 phone, so
   it can't spend width. */
.tabbar-start-btn {
  --key-border: 2px;
  --key-wall: 3px;
  --key-press: 3px;
  padding: 3px 5px;
}
/* Come look around: the roomier key on the wallpaper. */
.console-site-btn {
  padding: 4px 12px;
}

/* Mobile: the 2-page horizontal slide between Left Console and App Window */
@media (max-width: 1150px) {
  body.os {
    --gap: 16px;
  }
  .desktop {
    /* Drive the padding through the var, not a literal: .win-statusbar cancels this
       same var with a negative margin to bleed the train to the edges, so a hardcoded
       padding here would leave it bleeding by the desktop's 28px and clip the band.
       This is the wallpaper the app window floats on, so it's also the "space either
       side" knob — but every px here is a px the tab bar doesn't get to wrap in, and
       it is NOT free above ~12: at 14 the console drops to 347px on a 375px phone,
       which is under the width the title box needs to hold its wrap, and the extra
       29px of title pushes the radio onto SCALE_FLOOR and the link off the stage. */
    --star-margin: 12px;
    padding: var(--star-margin);
    gap: 8px;
    overflow: hidden; /* Hide the off-screen page */
    position: relative;
  }
  .desktop-stage {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
    gap: 16px; /* Gap between pages */
    transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
  }
  /* The second page (app window) is exactly 100% + gap away */
  body.os.show-app-page .desktop-stage {
    transform: translate3d(calc(-100% - 16px), 0, 0); 
  }
  
  body.os .col-left,
  body.os .win-app {
    flex: 0 0 100%;
    width: 100%;
    min-width: 0;
    max-width: none;
    position: static;
    transform: none;
  }
  /* The console is bare furniture standing on the wallpaper — the title box brings
     its own border and the radio is its own device, so a frame around them would be
     a second box around two boxes. The app window keeps .win's 10px double-mauve
     frame it wears on the desktop: it's a window on both. .win is border-box, so the
     frame is drawn inside the flex: 0 0 100% page and the slide distance is untouched. */
  body.os .col-left {
    box-shadow: none;
    border: none;
    padding: 0;
  }

  /* Left console tweaks */
  body.os .col-left {
    display: flex;
    flex-direction: column;
    align-items: center; /* Center the title and radio */
  }
  .title-box {
    width: 100%;
    max-width: 400px;
    /* The console centres its children; the title box opts out and runs the
       full width (what's left of the old staggered-column layout). */
    align-self: center;
  }
  .radio-scale {
    flex: 1 1 auto;
    width: 100%;
    max-width: 400px;
    display: flex;
    /* Anchor top-left, don't centre: the frame keeps its unscaled 556px layout box, so
       centring it in the JS-sized box offsets it by half the difference — and since it
       paints from transform-origin: top left, overflow:hidden then crops the radio's
       head. .col-left already centres this box in the column. */
    align-items: flex-start;
    justify-content: flex-start;
    margin: 0;
  }
  /* The frame is a flex item here, and flex-shrink would squeeze its 378px
     width toward the JS-sized box — flooring at the iframe's 300px intrinsic
     minimum. That re-lays-out the device at a 300px internal viewport: device
     narrow and left of centre, LCD pushed past the rounded corner (the iPhone
     radio bug — it wasn't iOS-specific). The frame must ALWAYS keep 378px;
     only the transform makes it smaller. */
  .radio-scale .radio-frame { flex-shrink: 0; }
  
  /* Dissolve the <ul> so the tabs, the secrets dropdown and the start link all wrap
     in ONE flow across the bar's full width. As real siblings of the ul, the dropdown
     and the link took their width off the top before the tabs got any, on every row —
     which starved the tabs to a couple per row (and to one per row, seven rows deep,
     with secrets unlocked). display: contents costs the ul its box, and it carries no
     styling worth keeping — the bar below re-declares the gaps. */
  .win-tabs { display: contents; }
  .win-tabbar {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    column-gap: 4px;
    row-gap: 4px;
    padding: 8px 6px 0;
  }
  /* Fluid, not phone-only: this breakpoint also catches a scrunched desktop window,
     so the tabs reach full size by the wide end and only tighten near a phone.
     Verdana is a wide face — the whole rack is ~700px of tabs against ~330px of bar
     at 390 wide, so the padding and border tighten alongside the text. */
  .win-tabs a,
  .secrets-toggle {
    font-size: clamp(12px, 9.5px + 0.64vw, 14px);
    padding: 5px clamp(6px, -4.9px + 2.8vw, 14px);
    border-width: 2px;
    /* Only the last row could hide its bottom in the bar's bottom border, so no row
       does: every tab keeps its own bottom and reads as a closed shape. */
    margin-bottom: 0;
  }
  .win-tabs a.active,
  .win-tabs a[aria-current="page"],
  .secrets-toggle.active {
    padding-bottom: 5px;
    border-bottom-color: var(--accent-dark);  /* closed too; the pane-white fill marks it */
  }
  /* The dropdown reads "secrets ⌄" here, not "secrets 3 / 3 ⌄". Those four characters
     are 30px — the difference between the bar closing at two rows and spilling the
     start-page link onto a third, since the tabs, the dropdown and the link all wrap
     in one flow now. The count is a badge, not navigation: the desktop bar still shows
     it, and the menu below still lists every slot. Measured at 390 and 375 wide with
     all 3 unlocked — restore it and both go back to three rows. */
  .secrets-count { display: none; }

  /* The two page switches, each in its own page's chrome (main.js injects them).
     Nothing is position:fixed, so neither can land on the radio's transport keys and
     fitRadio can simply subtract the console key's height. */
  /* .win-tabbar prefix (0,2,0) so this font-size beats the .key recipe's
     `font: inherit` (0,1,0) — otherwise the key inherits body 14px and blows
     past the ~12px tabs, widening the go-back key ~13px in the tightest bar. */
  .win-tabbar .tabbar-start-btn {
    display: block;
    flex: 0 0 auto;
    /* Bottom-right of the bar: an auto margin eats its row's free space, and the row
       it lands on is the last one, since main.js appends it after the dropdown. */
    margin-left: auto;
    align-self: flex-end;
    margin-bottom: 7px;     /* lift it off the bar's bottom border, clear of the tabs */
    font-size: clamp(12px, 9.5px + 0.64vw, 14px);   /* track the tabs it sits beside */
  }
  .console-site-btn {
    display: block;
    flex: 0 0 auto;
    align-self: center;     /* shrink-wrapped: a key, not a full-width bar */
    margin-bottom: 5px;     /* the key's wall lives here — fitRadio reads it back */
  }
}

/* ===================================================================
   5) Components
   In rough page order: the .key recipe first (per-key rules override it,
   so it must stay above them), then per-feature blocks, each with its own
   narrow-screen overrides.
   =================================================================== */

/* ---- The retro key: the raised primary-action button, one recipe ----
   A light-mauve tile (--key-face) with a purple (--accent) label on a hard
   box-shadow "wall" (--btn-edge, a deeper mauve) — lighter than the var(--accent-primary) tab
   bar so it lifts off it, saturated enough to read on the white panels, and
   bright against the dark wallpaper: one face that works on every surface.
   Reserved for PRIMARY ACTIONS (spin, post, come look around / go back, the
   podcast + editor buttons, the toast link, clear all); flat controls (tabs,
   .md-btn, .kao-item, the viewer arrows, the secrets-menu slots) stay flat.
   hover deepens the face (lightening would wash it into the white panels),
   :active flips to inset and drops the key onto the wall. Applied ADDITIVELY —
   class="key" in the markup / classList in the JS that creates each button — so
   this rule is the pattern and each key's own rule keeps its real size
   differences via the three knobs (border thickness, wall height, press depth).
   Override the knobs per key; don't fork the recipe. Keep this rule below any
   `font: inherit` block that shares an element with it (the shorthand would
   reset the bold at equal specificity), and above the per-key overrides that
   must beat it in source order (.btn-danger, .copied). */
.key {
  font: inherit;
  font-weight: bold;
  cursor: pointer;
  text-decoration: none;
  text-align: center;
  background: var(--action-face);
  color: var(--action-ink);
  border: 4px solid var(--action-edge);
  border-radius: 0 0 8px 8px;
}
.key:hover { background: var(--action-hover); }
.key:active {
  background: #e6ce67;
}

a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
  outline: 2px solid var(--accent-primary);
  outline-offset: 2px;
}

/* ---- Secret-pages buttons (used inside the tab-bar "secrets" dropdown) ---- */

/* Unlocked slots are .key retro buttons (main.js adds the class — never to a
   .locked placeholder, which stays flat); this rule keeps only what both forms
   share. */
.secret-btn {
  display: block;
  padding: 4px 12px;
  border-radius: 3px;
}

.secrets-menu .secret-btn {
  font: inherit;
  font-weight: bold;
  text-align: left;
  padding: 6px 14px;
  background: var(--accent-secondary);
  color: #333;
  border: 4px solid var(--accent-dark);
  border-radius: 4px;
  box-shadow: none;
  transition: background 0.2s, color 0.2s;
}
.secrets-menu .secret-btn:not(.locked):not(.active):not([aria-current="page"]):hover {
  background: var(--accent-light);
  color: #000;
}
.secrets-menu .secret-btn:not(.locked):active {
  transform: none;
}
.secrets-menu .secret-btn.active,
.secrets-menu .secret-btn[aria-current="page"] {
  background: var(--pane);
  color: #000;
  border-style: solid;
}

/* Empty slots: a dashed, dimmed "? ? ?" placeholder for the not-yet-found. It's
   not a real button, so it stays flat — no raised wall under it. */
.secret-btn.locked {
  background: var(--pane-inset);
  border: 2px dashed var(--mauve-dim);
  color: var(--accent-dark);
  font-weight: normal;
  cursor: default;
  letter-spacing: 2px;
  box-shadow: none;
}

/* ---- Reset easter egg: a soft purple glow that zooms out from center ---- */
.reset-glow {
  position: fixed;
  top: 50%;
  left: 50%;
  width: 40vmax;
  height: 40vmax;
  margin: -20vmax 0 0 -20vmax;
  border-radius: 50%;
  pointer-events: none;
  z-index: var(--z-toast);
  background: radial-gradient(circle,
    rgba(201, 179, 237, 0.85) 0%,
    rgba(201, 179, 237, 0.45) 38%,
    rgba(201, 179, 237, 0) 70%);
  animation: reset-glow-zoom 1.5s ease-out forwards;
}

@keyframes reset-glow-zoom {
  0%   { transform: scale(0.1); opacity: 0; }
  18%  { opacity: 1; }
  100% { transform: scale(3.4); opacity: 0; }
}

/* ---- Cat ---- */

.cat-box img {
  width: 100%;
  display: block;
  /* Clickable (pet the cat) but not selectable/draggable — no blue highlight. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
}

/* A gentle bob when the cat is pet, so the click feels acknowledged. */
@keyframes cat-pet {
  0%, 100% { transform: translateY(0); }
  40%      { transform: translateY(-5px); }
}
.cat-box img.petted {
  animation: cat-pet 0.3s ease-out;
}

/* The slot machine + cat are widgets, not prose: their internals (reels, result
   readout, spin button) read best centered even though the panel is left by
   default. Scoped so it doesn't re-center other panels. */
.slot-box, .cat-box { text-align: center; }

/* ---- Slot machine ---- */

.slot-reels {
  display: flex;
  gap: 6px;
  margin: 10px 0;
}

/* Reels fill the box evenly, so a wider column gives bigger reels. */
.slot-reels img {
  flex: 1 1 0;
  min-width: 0;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border: 1px solid #999;
  background: #f4f4f4;
}

@keyframes reel-land {
  0%   { transform: translateY(-9px); }
  55%  { transform: translateY(3px); }
  100% { transform: translateY(0); }
}

.slot-reels img.spinning {
  filter: blur(1px);
  opacity: 0.9;
}

.slot-reels img.landed {
  animation: reel-land 0.2s ease-out;
}

#slot-result {
  font-weight: bold;
  line-height: 1.3;
  min-height: 2.9em;   /* reserve 2 lines so the box keeps its height each spin */
}

#slot-hint {
  font-size: 0.82em;
  color: gray;
  font-style: italic;
  min-height: 1.5em;   /* reserve its line too */
}

#slot-stats {
  font-size: 0.76em;
  color: gray;
  margin-top: 3px;
  display: none;
}
#slot-stats.shown {
  display: block;
  line-height: 1.3;
  min-height: 2.9em;   /* reserve its two lines so the box stays put during spins */
}
@keyframes stats-slide-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
#slot-stats.reveal {
  animation: stats-slide-in 0.5s ease-out;
}

/* "spin again" — a .key; always shown. */
#slot-spin-btn {
  --key-border: 3px;
  --key-wall: 4px;
  --key-press: 3px;
  display: block;
  margin: 8px auto 0;
  font-size: 0.9em;
  padding: 6px 16px;
}

/* winner's-lounge invite button (shown once you pass 10 jackpots) — a .key. */
#slot-lounge-btn {
  --key-wall: 4px;
  --key-press: 3px;
  display: none;
  margin: 8px auto 0;
  font-size: 0.9em;
  padding: 6px 14px;
}


/* Jackpot celebration: a mauve glow on the box + a happy wiggle on the reels. */
@keyframes jackpot-glow {
  0%, 100% { box-shadow: 0 0 0 0 rgba(252, 229, 136, 0); }
  50%      { box-shadow: 0 0 16px 4px rgba(252, 229, 136, 0.9); }
}
@keyframes jackpot-wiggle {
  0%, 100% { transform: translateY(0) rotate(0); }
  25%      { transform: translateY(-4px) rotate(-4deg); }
  75%      { transform: translateY(-4px) rotate(4deg); }
}
.slot-box.jackpot {
  animation: jackpot-glow 0.6s ease-in-out 3;
}
.slot-box.jackpot .slot-reels img {
  animation: jackpot-wiggle 0.4s ease-in-out 3;
}
.slot-box.jackpot #slot-result {
  color: var(--accent-ink);
}

/* Near-miss: a small disappointed shake on the third reel as it slips off. */
@keyframes near-miss-shake {
  0%, 100% { transform: translateX(0); }
  25%      { transform: translateX(-3px); }
  75%      { transform: translateX(3px); }
}
.slot-box.near-miss .slot-reels img:last-child {
  animation: near-miss-shake 0.28s ease-in-out 2;
}

/* Narrow screens: the rail keeps the old staggered-column rhythm — slots ride
   above the cat, both narrower than the rail and hugging its right edge. */
@media (max-width: 1150px) {
  .slot-box { width: 72%; align-self: flex-end; order: 4; }
  .cat-box  { width: 60%; align-self: flex-end; order: 6; }
}

/* ---- Greeting ----
   main.js fills #greeting in after load, so reserve its line's height up front
   or the whole box visibly grows when the text lands. One line is enough on the
   wide center column; the message wraps to two on narrow screens (reserved in
   the mobile block). em units so it scales with zoom and font size — no brittle
   saved pixel value to go stale. */
.greeting > p:first-child { min-height: 1.5em; }

/* Reserve one line here too, like the desktop: a one-line greeting is the
   common case, and a taller reserve left a visible hole under it on phones. A
   long holiday greeting still wraps to two lines and nudges the box down as it
   lands — the rarer, smaller cost, taken over a permanent gap under every
   short greeting. */
@media (max-width: 1150px) {
  .greeting > p:first-child { min-height: 1.5em; }
}

/* ---- Click-to-reveal, scraper-proof email + random email gif ---- */

#email {
  cursor: pointer;
  text-decoration: underline;
  color: gray;
}

#email.revealed {
  color: black;
  font-weight: bold;
}

#email-hint {
  display: none;
  color: gray;
  font-style: italic;
}

/* A curated email gif sits under the reveal on the About page. The about column
   is full-width, so there's room to show it at a comfortable size. */
.email-gif {
  display: block;
  margin: 12px auto 4px;
  max-width: 320px;
  max-height: 240px;
  width: auto;
  height: auto;
}

/* ===================================================================
   About-page photo slideshow (about.html). A FIXED-SHAPE stage so photos
   of any size/orientation never resize the box. The stage is sized to a
   standard 3:2 landscape; portrait and panorama shots scale down to fit
   and sit centered, letterboxed against the white box.
   =================================================================== */
.slideshow-stage {
  position: relative;
  width: 100%;
  max-width: 820px;              /* roomier on desktop; phones are width-bound so they're unchanged */
  aspect-ratio: 3 / 2;            /* the shape never changes → the box never resizes */
  margin: 14px auto 4px;
  background: #f4f4f4;            /* light frame behind portrait / odd-ratio photos */
  border-radius: 4px;
}
.slideshow-stage img {
  position: absolute;
  inset: 0;
  margin: auto;                   /* center within the stage, both axes */
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;                   /* contain: scale down, keep aspect ratio */
}
/* Date + caption under the photo, kept to the stage width and centered. A min
   height for ~2 lines keeps short captions from making the box bob as they cycle. */
.slideshow-caption {
  max-width: 820px;
  margin: 10px auto 2px;
  text-align: center;
  line-height: 1.45;
  min-height: 2.9em;
}
.slideshow-date { color: gray; font-style: italic; }

/* ===================================================================
   Guestbook (home): a no-login "leave a note". Form on the left, the
   scrolling wall of notes on the right; stacks to one column on phones.
   Notes are plain text (white-space: pre-wrap keeps the writer's returns).
   =================================================================== */
.gb-cols {
  display: flex;
  gap: 16px;
  text-align: left;
  margin-top: 8px;
}
.gb-form {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.gb-label { font-weight: bold; margin: 6px 0 3px; }
.gb-opt { font-weight: normal; color: gray; font-style: italic; font-size: 0.85em; }

.gb-form input[type="text"],
.gb-form textarea {
  width: 100%;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 1em;
  padding: 6px 8px;
  border: 2px solid var(--accent-primary);
  border-radius: 3px;
  background: white;
  color: black;
}
.gb-form textarea { resize: vertical; line-height: 1.45; min-height: 96px; }
.gb-form input:focus, .gb-form textarea:focus { outline: 2px solid var(--accent-primary); }

.gb-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
}
.gb-counter { color: gray; font-size: 0.78em; font-style: italic; }

/* The post button is a .key. */
.gb-post {
  --key-border: 3px;
  --key-wall: 4px;
  --key-press: 3px;
  font-size: 0.9em;
  padding: 5px 16px;
}
.gb-post:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: 0 4px 0 var(--action-edge); }

.gb-status { font-size: 0.8em; color: gray; min-height: 1.2em; margin-top: 6px; }
.gb-status.is-error { color: black; font-weight: bold; }

/* Honeypot: parked off-screen, never shown to a human. A bot that fills it gets
   its post silently dropped. */
.gb-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* The notes wall is height-capped so the box can't grow tall — a few notes show
   and the rest scroll. Tweak max-height to show more/fewer at once. */
.gb-notes {
  flex: 1.15 1 0;
  min-width: 0;
  max-height: 320px;
  overflow-y: auto;
  padding: 4px 10px;
  border: 1px solid var(--hairline);
  border-radius: 4px;
  background: var(--pane);
}
.gb-note { padding: 7px 0; border-bottom: 1px dashed var(--hairline); }
.gb-note:last-child { border-bottom: none; }
.gb-note-head {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  align-items: baseline;
  margin-bottom: 2px;
}
.gb-note-name { font-weight: bold; min-width: 0; overflow-wrap: anywhere; }
.gb-note-date { color: gray; font-size: 0.76em; font-style: italic; flex-shrink: 0; white-space: nowrap; }
/* Keep one note from running tall: clamp the message to a few lines (a newline-heavy
   note gets a trailing "…"); the writer's returns still show within the clamp.
   Bump --gb-note-lines to allow taller notes. */
.gb-note-msg {
  line-height: 1.45;
  white-space: pre-wrap;
  word-break: break-word;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  line-clamp: 6;
  overflow: hidden;
}
.gb-empty { color: gray; font-style: italic; }

/* On phones the two columns stack; the notes wall gets a little shorter.
   Both children must drop back to auto sizing: their flex-basis:0 above splits
   the row's *width*, but flex-basis is main-axis, so once this is a column it
   splits the height instead — and the wall collapsed to its own 10px padding,
   which is why the notes looked missing on a phone rather than merely stacked. */
@media (max-width: 1150px) {
  .gb-cols { flex-direction: column; }
  .gb-form,
  .gb-notes { flex: 0 0 auto; }
  .gb-notes { max-height: 260px; }
}

/* ---- Footer ---- */

/* The footer sits in normal flow at the bottom of the 100dvh desktop column (NOT
   fixed) — the shell never scrolls (only #tab-panel does), so the band is simply
   always there below the window row. It's a
   single full-width band — the name/year sit centered and the train runs across
   the whole thing, passing OVER the text and emerging from the black arch of each
   tunnel. The band is opaque deep-space, so the transparent arch reads as a solid
   black hole (the train, behind the tunnel facade, shows through it). The whole
   band is the overflow-clipped rail. */
footer {
  position: relative;
  height: 50px;
  overflow: hidden;
  color: gray;
}
.footer-credit {
  position: absolute;
  left: 0;
  right: 0;
  top: 24px;
  height: 26px;
  line-height: 26px;
  text-align: center;
  font-size: 0.9em;
  z-index: var(--z-default);
}
/* The train is animated in JS (stargaze.js "Footer train" IIFE) at a CONSTANT
   px/s, so it never jumps when the window is resized — the position is advanced
   incrementally (x += speed·dt), not interpolated to a viewport-width target the
   way a CSS `translateX(100vw)` keyframe would be. One transform write per frame,
   so it's compositor-cheap and always running. The transform below is just the
   parked start (off-screen left) for the no-JS / pre-script case. */
.footer-train {
  position: absolute;
  left: 0;
  top: 24px;
  height: 26px;
  width: auto;
  z-index: var(--z-above);                       /* over the name, under the tunnel facades */
  transform: translate3d(-561px, 0, 0);
  will-change: transform;
}
.footer-tunnel {
  position: absolute;
  top: 0;
  height: 50px;
  width: auto;
}
.footer-tunnel-left-front { left: 0; z-index: var(--z-elevated); }
.footer-tunnel-left-back { left: 0; z-index: var(--z-default); }
.footer-tunnel-right-front { right: 0; z-index: var(--z-elevated); }
.footer-tunnel-right-back { right: 0; z-index: var(--z-default); }
@media (prefers-reduced-motion: reduce) {
  .footer-train { transform: translate3d(22vw, 0, 0); }   /* parked mid-rail; stargaze.js skips the loop */
}

/* ---- Podcasts ----
   Horizontal cards: a moderately large square cover on the left, the show /
   episode details on the right. Each card is a .panel, so it picks up the
   shared content-unit look for free. */

.podcast-card {
  display: flex;
  flex-direction: row;
  gap: 20px;
  align-items: flex-start;
  text-align: left;
}
.podcast-cover {
  flex: 0 0 auto;
  width: 200px;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
  background: var(--pane-inset);
}
.podcast-info {
  flex: 1 1 auto;
  min-width: 0;
}
.podcast-title {
  margin: 0 0 12px;
  font-size: 1.3em;
}
.podcast-ep-label {
  display: block;
  font-size: 0.72em;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: gray;
  margin-bottom: 5px;
}
.podcast-ep { margin-bottom: 14px; }
.podcast-ep-title {
  display: block;
  font-weight: bold;
  line-height: 1.35;
  margin-bottom: 3px;
}
.podcast-ep-date {
  display: block;
  color: gray;
  font-size: 0.82em;
  font-style: italic;
  margin-bottom: 7px;
}
.podcast-ep-desc {
  margin: 0;
  line-height: 1.55;
  white-space: pre-wrap;   /* keep the episode's own line breaks / bullets / dashes */
}
.podcast-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 4px;
}
/* Same chunky retro mauve .key as the nav / spin buttons. */
.podcast-btn {
  --key-border: 3px;
  --key-wall: 4px;
  --key-press: 3px;
  display: inline-block;
  font-size: 0.9em;
  padding: 6px 16px;
}
.podcast-btn.copied { background: var(--action-hover); }
/* "other assorted favorites" — shows I recommend but don't have a favorite
   episode for. One box, just cover + name, smaller than the full cards. */
.podcast-fav-note {
  color: gray;
  font-style: italic;
  text-align: center;
  margin: 0 0 16px;
}
.podcast-fav-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 32px;
}
.podcast-fav {
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
.podcast-fav-cover {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 4px;
  background: var(--pane-inset);
}
.podcast-fav-name {
  display: block;
  margin: 7px 0 8px;
  font-weight: bold;
  font-size: 0.8em;
  line-height: 1.3;
  flex: 1 1 auto;          /* push the button to a shared baseline */
}
.podcast-fav .podcast-btn {
  font-size: 0.7em;
  padding: 4px 8px;
}

/* Phones: cover rides on top, details below. */
@media (max-width: 600px) {
  .podcast-card { flex-direction: column; align-items: center; }
  .podcast-cover { width: 100%; max-width: 240px; height: auto; aspect-ratio: 1 / 1; }
  .podcast-info { width: 100%; }
  .podcast-fav-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
}

/* ===================================================================
   Movie-stills page (movie_stills.html): wide poster grid + one viewer.
   =================================================================== */


.grid-hint {
  color: gray;
  font-size: 0.85em;
  font-style: italic;
  margin: 4px 0 0;
}

/* poster wall — scrolls inside its own box, ~5 posters tall */
.poster-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
  gap: 8px;
  max-height: 560px;
  max-width: 720px;
  margin: 10px auto 0;
  overflow-y: auto;
  padding: 6px;
}

.poster-tile {
  padding: 0;
  margin: 0;
  border: 2px solid transparent;
  background: none;
  cursor: pointer;
  line-height: 0;
}

.poster-tile img {
  width: 100%;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  display: block;
}

.poster-tile:hover {
  border-color: var(--accent-primary);
}

.poster-tile.active {
  border-color: var(--accent-primary);
  outline: 2px solid var(--accent-primary);
}

/* the single viewer */
.viewer-title {
  font-size: 1.3em;
  font-weight: bold;
  margin-bottom: 8px;
}

/* Leave a little breathing room above the viewer when a poster scrolls it into
   view, instead of jamming it against the very top of the window. */
.viewer-box { scroll-margin-top: 24px; }

/* A FIXED-SHAPE stage (like the about-page slideshow) so stills of any ratio
   never resize the box: the widescreen 16:9 arena holds the image, and portrait
   or 4:3 shots scale down and letterbox against the light frame. */
.viewer-main {
  position: relative;
  aspect-ratio: 16 / 9;
  background: #f4f4f4;
  border-radius: 4px;
  overflow: hidden;
}
/* The image is wrapped in the lightbox link; fill the stage and center within it. */
.viewer-main > a {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* When either viewer holds keyboard focus, show it in mauve — that's the cue that
   ←/→ page the stills (movies also: Space opens the fill-screen lightbox). */
.viewer-main:focus,
.slideshow-stage:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }

.viewer-main img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  display: block;
  cursor: zoom-in;
}

/* The house's light-outset voice (shared with .md-btn / .kao-item): a flat
   pane-inset chip with a bevelled mauve border, no raised wall — quiet enough
   to sit over a photo, still unmistakably a site control. The page sets
   color-scheme: dark, so a bare <button>'s glyph defaults to white; the
   explicit dark color keeps the arrow from going white-on-light and vanishing. */
.viewer-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: var(--z-above);
  font-size: 2em;
  line-height: 1;
  padding: 2px 12px;
  color: black;
  background: var(--pane-inset);
  border: 2px outset var(--accent-primary);
  cursor: pointer;
  font-family: inherit;
}
.viewer-arrow:hover { background: var(--accent-secondary); }
.viewer-arrow:active { border-style: inset; }

.viewer-arrow.prev { left: 6px; }
.viewer-arrow.next { right: 6px; }

.viewer-thumbs {
  display: flex;
  gap: 4px;
  overflow-x: auto;
  margin-top: 4px;
}

.viewer-thumbs img {
  height: 54px;
  width: auto;
  cursor: pointer;
  opacity: 0.5;
  border: 1px solid #bbb;
}

.viewer-thumbs img.active {
  opacity: 1;
  outline: 2px solid var(--accent-primary);
}

/* On phones the poster wall is cramped at the desktop track size — pin it to a
   tidy 4 across. (Desktop keeps its auto-fill flow of more, smaller posters.) */
@media (max-width: 1150px) {
  .poster-grid { grid-template-columns: repeat(4, 1fr); }
}

/* GLightbox "fill screen" backdrop: match the slot spotlight's darkness instead
   of GLightbox's near-black default. !important because GLightbox's stylesheet
   loads after this one. */
.goverlay { background: var(--overlay-dark) !important; }

.funny-text {
  white-space: pre-wrap;
  text-align: left;
  line-height: 1.7;
}

/* Piper's terrarium: a grey box (not white) so the pale web silk and the grey
   spider read against it. The <canvas> fills the card; piper.js sizes its pixel
   grid to this width (never below 800x500). clearRect leaves the canvas
   transparent, so this grey shows through as her sky. */
.spider-box {
  background: #2d2d30;
  padding: 8px;
}
.spider-box canvas {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 4px;
  cursor: default;
}

/* ===================================================================
   Achievements: bottom-right pop-up toasts + the achievements page list.
   =================================================================== */

/* Toast stack — fixed to the bottom-right corner. The newest card is appended
   last so it sits nearest the corner, in front; older un-dismissed ones ride up
   above it. The wrapper ignores clicks so it never eats the page; the cards
   themselves re-enable pointer events. */
.achv-toasts {
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: var(--z-modal);
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 320px;
  max-width: calc(100vw - 32px);
  /* If a pile of toasts ever stacks taller than the screen, keep the oldest ones
     reachable instead of letting them run off the top edge. */
  max-height: calc(100vh - 32px);
  overflow-y: auto;
  pointer-events: none;
}

.achv-toast {
  pointer-events: auto;
  cursor: pointer;   /* the whole card is click-to-dismiss */
  background: white;
  border: double var(--accent-primary) 8px;
  border-radius: 4px;
  padding: 8px 11px 11px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
  text-align: left;
  transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.32s ease;
}

/* Slide in from the right (and slide back out on dismiss). */
.achv-toast.enter,
.achv-toast.leaving {
  transform: translateX(360px);
  opacity: 0;
}

.achv-toast-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 0.7em;
  font-weight: bold;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  color: var(--accent-ink);
  margin-bottom: 4px;
}

.achv-x {
  border: none;
  background: none;
  cursor: pointer;
  font: inherit;
  font-size: 1.9em;            /* bigger glyph */
  line-height: 1;
  color: gray;
  padding: 2px 9px;           /* bigger hit area... */
  margin: -5px -6px -5px 0;   /* ...without nudging the header layout */
  border-radius: 3px;
}
.achv-x:hover { color: black; background: var(--pane-inset); }

.achv-toast-name {
  font-weight: bold;
  font-size: 1.02em;
  margin-bottom: 2px;
}

.achv-toast-desc {
  font-size: 0.82em;
  line-height: 1.4;
  color: #333;
}

/* The meta "unlock achievements" toast carries a .key to the new page. */
.achv-toast-link {
  --key-border: 3px;
  --key-wall: 4px;
  --key-press: 3px;
  display: inline-block;
  margin-top: 9px;
  font-size: 0.82em;
  padding: 5px 11px;
}

/* The completionist toast glows like the jackpot. */
.achv-toast.complete {
  box-shadow: 0 0 18px 4px rgba(252, 229, 136, 0.85);
}

/* "clear all" — only present when 2+ toasts are stacked. It's inserted as the
   container's first child, so it rides at the top of the stack. A .key (the
   recipe paints the face/label/wall); this rule only sizes and places it, and
   re-asserts pointer-events since the .achv-toasts wrapper is click-through. */
.achv-clear-all {
  align-self: flex-end;
  pointer-events: auto;
  --key-border: 2px;
  --key-wall: 4px;
  --key-press: 3px;
  font-size: 0.9em;
  padding: 6px 14px;
}

/* On phones the stack spans the bottom of the screen. */
@media (max-width: 1150px) {
  .achv-toasts {
    left: 12px;
    right: 12px;
    bottom: 12px;
    width: auto;
    max-width: none;
  }
  /* On the start page, lift the stack clear of the injected "come look around!"
     key — the start page's only way across, whose top sits ~96px off the
     bottom — and of the train band below it, so a toast can't bury the exit.
     The app page has no such key, so it keeps the low 12px anchor. */
  body.os:not(.show-app-page) .achv-toasts { bottom: 104px; }
}

/* ---- Achievements page list ---- */
.achv-counter {
  text-align: center;
  font-weight: bold;
  color: var(--accent-ink);
  font-size: 1.05em;
  margin: 4px 0 2px;
}

.achv-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 12px;
}

.achv-row {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  text-align: left;
  background: var(--pane);
  border: 2px solid var(--accent-primary);
  border-radius: 4px;
  padding: 9px 11px;
}

/* Still-locked rows: dashed + dimmed, showing the hint instead of the name. */
.achv-row.locked {
  background: var(--pane-inset);
  border-style: dashed;
  border-color: var(--mauve-dim);
  color: #555;
}

.achv-row-icon {
  font-size: 1.2em;
  line-height: 1.35;
  flex-shrink: 0;
}

.achv-row-name {
  font-weight: bold;
  margin-bottom: 2px;
}
.achv-row.locked .achv-row-name {
  color: var(--accent-dark);
  font-weight: normal;
  letter-spacing: 2px;
}

.achv-row-desc {
  font-size: 0.85em;
  line-height: 1.45;
}

/* The completionist row, pinned to the top once earned. */
.achv-row.complete {
  border-width: 3px;
  background: #fff;
  box-shadow: 0 0 12px 2px rgba(252, 229, 136, 0.7);
}

/* ===================================================================
   Blog (blog.html): a wall of post cards, each its own white box. Posts
   are injected as direct .col-center children so they keep the shared
   mobile stacking order. Content is left-aligned and rendered from
   Markdown, so it needs the usual prose typography.
   =================================================================== */

.blog-post { text-align: left; }
.blog-post .blog-post-title { text-align: left; margin: 0 0 2px; font-size: 1.3em; }
.blog-post-date { color: gray; font-size: 0.82em; font-style: italic; margin-bottom: 12px; }

.blog-post-body { text-align: left; line-height: 1.6; }
.blog-post-body > :first-child { margin-top: 0; }
.blog-post-body p { margin: 10px 0; }
.blog-post-body h1, .blog-post-body h2, .blog-post-body h3 { margin: 16px 0 6px; }
.blog-post-body h1 { font-size: 1.3em; }
.blog-post-body h2 { font-size: 1.15em; }
.blog-post-body h3 { font-size: 1.03em; }
.blog-post-body a { text-decoration: underline; }
.blog-post-body img {
  max-width: 66%; height: auto; display: block; margin: 14px 0;
  border: 1px solid var(--accent-primary); border-radius: 3px;
}
.blog-post-body ul, .blog-post-body ol { padding-left: 22px; line-height: 1.6; margin: 10px 0; }
.blog-post-body blockquote {
  margin: 12px 0; padding: 4px 14px; border-left: 4px solid var(--accent-primary);
  color: #555; background: var(--pane);
}
.blog-post-body code {
  font-family: "Courier New", monospace; font-size: 0.92em;
  background: var(--pane-inset); padding: 1px 4px; border-radius: 3px;
}
.blog-post-body pre {
  background: var(--pane-inset); padding: 10px 12px; border-radius: 4px;
  overflow-x: auto; border: 1px solid var(--hairline);
}
.blog-post-body pre code { background: none; padding: 0; }
.blog-post-body hr { border: none; border-top: 1px dashed var(--accent-primary); margin: 16px 0; }

/* On the narrow phone column the 2/3 width would read as tiny, so let images
   fill it. The desktop 66% (left-aligned) is restored above 1150px. */
@media (max-width: 1150px) {
  .blog-post-body img { max-width: 100%; }
}

/* ===================================================================
   Draft editor (draft.html): a single centered column. Reuses the box +
   retro-button look; adds form fields, a Markdown toolbar, and a live
   preview. Stays mauve-only (greys for neutral/danger; no new palette).
   =================================================================== */

.editor-col {
  width: 100%;
  max-width: 760px;
  display: flex;
  flex-direction: column;
  gap: 28px;
  margin: 0 auto;
}

/* General-purpose editor button — a .key at full size. */
.btn {
  display: inline-block;
  font-size: 1em;
  padding: 6px 16px;
}
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.btn-small { font-size: 0.85em; padding: 3px 11px; --key-border: 3px; --key-wall: 4px; }
/* The one deliberately-neutral key: a grey delete button. Re-assert black ink
   (the recipe's purple label is for the mauve-walled primary keys, not this). */
.btn-danger { background: #dcdcdc; border-color: #dcdcdc; color: black; box-shadow: 0 5px 0 #b0b0b0; }
.btn-danger:hover { background: #e8e8e8; }
.btn-danger:active { box-shadow: 0 1px 0 #b0b0b0; }

.editor-bar {
  display: flex; justify-content: space-between; align-items: center; gap: 10px;
  margin-bottom: 12px; font-size: 0.85em; color: gray;
}

.editor-label { display: block; text-align: left; font-weight: bold; margin: 12px 0 4px; }

.editor-col input[type="text"],
.editor-col input[type="password"],
.editor-col input[type="email"],
.editor-col input[type="datetime-local"],
.editor-col textarea {
  width: 100%;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 1em;
  font-weight: normal;
  padding: 7px 9px;
  border: 2px solid var(--accent-primary);
  border-radius: 3px;
  background: white;
  color: black;
}
.editor-col textarea { resize: vertical; line-height: 1.5; min-height: 240px; }
.editor-col input:focus, .editor-col textarea:focus { outline: 2px solid var(--accent-primary); }

.md-toolbar { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0 6px; }
.md-btn {
  font-family: inherit; font-size: 0.9em; min-width: 30px; padding: 4px 8px;
  background: var(--pane-inset); color: black; cursor: pointer;
  border: 2px outset var(--accent-primary); border-radius: 3px;
}
.md-btn:hover { background: var(--accent-secondary); }
.md-btn:active { border-style: inset; }

/* Kaomoji picker: one button drops a labelled grid of faces below the toolbar.
   The [hidden] rule has to out-specify the `display: grid` below, or the menu
   would never collapse (a bare `display` beats the UA [hidden] { display:none }). */
.kao-menu[hidden] { display: none; }
.kao-menu {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 5px;
  margin: 0 0 8px;
  padding: 8px;
  max-height: 280px;
  overflow-y: auto;
  background: var(--pane);
  border: 2px solid var(--accent-primary);
  border-radius: 4px;
}
.kao-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  min-height: 48px;
  padding: 5px 4px;
  font-family: inherit;
  background: white;
  cursor: pointer;
  border: 2px outset var(--accent-primary);
  border-radius: 3px;
}
.kao-item:hover { background: var(--pane-inset); }
.kao-item:active { border-style: inset; }
.kao-name {
  font-size: 0.7em;
  color: gray;
  text-transform: lowercase;
  letter-spacing: 0.02em;
}
.kao-face { font-size: 0.95em; color: black; white-space: nowrap; }

.editor-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }

.editor-rule { border: none; border-top: 1px dashed var(--accent-primary); margin: 18px 0; }

.editor-msg { text-align: left; color: gray; font-size: 0.88em; margin-top: 10px; min-height: 1.2em; }
.editor-msg.is-error { color: black; font-weight: bold; }

.post-list { text-align: left; }
/* The list of past posts is capped and scrolls on its own so it can't grow
   unbounded and shove the title/body fields off the page. The "+ new post"
   button sits outside this box, so it always stays in view above the scroll. */
.post-list-ul {
  list-style: none; margin: 12px 0 0; padding: 2px 12px;
  max-height: 220px; overflow-y: auto;
  border: 1px solid var(--hairline); border-radius: 4px;
}
.post-list-ul li { padding: 5px 0; border-bottom: 1px dashed var(--hairline); }
.post-list-ul a { text-decoration: underline; }
.post-list-date { color: gray; font-size: 0.82em; }
.badge {
  font-size: 0.72em; vertical-align: middle;
  background: var(--action-face); border: 1px solid var(--action-edge); color: var(--action-ink);
  padding: 1px 6px; border-radius: 8px;
}

#preview:empty::before { content: "(nothing yet)"; color: gray; }

/* Guestbook moderation list in the editor: each note with a delete button, the
   whole list capped + scrollable like the post list. */
.gbadmin-list {
  text-align: left;
  margin-top: 12px;
  max-height: 320px;
  overflow-y: auto;
  border: 1px solid var(--hairline);
  border-radius: 4px;
  padding: 2px 12px;
}
.gbadmin-row { padding: 9px 0; border-bottom: 1px dashed var(--hairline); }
.gbadmin-row:last-child { border-bottom: none; }
.gbadmin-meta { font-weight: bold; font-size: 0.85em; }
.gbadmin-msg { white-space: pre-wrap; word-break: break-word; line-height: 1.4; margin: 3px 0 6px; }
.gbadmin-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.gbadmin-editing .editor-label { margin-top: 6px; }

/* On-screen diagnostics for the editor (only shown when something goes wrong). */
.diag {
  text-align: left;
  background: var(--pane-inset);
  border: 2px solid var(--accent-primary);
  border-radius: 4px;
  padding: 10px 12px;
  font-size: 0.85em;
  line-height: 1.5;
  word-break: break-word;
}
.diag .diag-err { color: black; font-weight: bold; }

/* ---- Reduced motion: still the site, minus the movement ----
   Consolidated for the components above: the mobile 2-page slide snaps
   instead of sliding, toasts appear and leave in place, the celebration /
   feedback animations hold still, and the reel blur drops. The starfield,
   train and stargaze fades handle reduced motion in their own sections
   (and stargaze.js draws a static frame). */
@media (prefers-reduced-motion: reduce) {
  .desktop-stage { transition: none; }
  .achv-toast { transition: none; }
  .slot-box.jackpot,
  .slot-box.jackpot .slot-reels img,
  .slot-box.near-miss .slot-reels img:last-child,
  .cat-box img.petted,
  .reset-glow,
  #slot-stats.reveal { animation: none; }
  .slot-reels img.spinning { filter: none; }
}

/* ===================================================================
   6) Starfield & stargaze
   =================================================================== */

/* ===================================================================
   Starfield background + "stargaze" mode (stargaze.js, on every page).
   A fixed full-viewport canvas of drifting, twinkling parallax stars sits
   behind everything; a faint vignette over it adds planetarium-dome depth.
   Clicking the title box enters stargaze: the boxes fade out, then the title
   itself fades, leaving just the stars. It pops back when the cursor nears it
   (desktop) and any tap/drag/Escape exits.
   =================================================================== */

#starfield-bg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  height: 100lvh;
  z-index: var(--z-bottom);            /* behind the page content (and the vignette) */
  pointer-events: none;
  display: block;
}

.starfield-vignette {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  height: 100lvh;
  z-index: var(--z-bg);            /* over the canvas, still behind the content */
  pointer-events: none;
  background: radial-gradient(circle at center,
    rgba(2, 2, 4, 0) 45%, rgba(2, 2, 4, 0.85) 100%);
}

/* The injected hint at the bottom of the title box. Same look as the
   "N / 3 secret pages unlocked" counter: small, gray, italic. */
.stargaze-hint {
  font-size: 0.82em;
  color: gray;
  font-style: italic;
  margin-top: 10px;
}

/* Fade speeds. Everything but the title box clears quickly; the title box
   lingers, then fades slowly, so the boxes go away first and the toggle last. */
.box:not(.title-box),
footer,
.achv-toasts,
.win-app,
.console-site-btn,
.radio-frame {
  transition: opacity 0.7s ease;
}
.title-box { transition: opacity 1.6s ease; }

/* Stargaze on: drop the boxes / app window (and any toasts), then the title box.
   .console-site-btn is the mobile start-page link and stands on the wallpaper on its
   own — it has to be listed, or it hangs over the empty starfield with everything
   else gone. Its opposite number lives inside .win-app and fades with it. */
body.stargazing .box:not(.title-box),
body.stargazing footer,
body.stargazing .achv-toasts,
body.stargazing .win-app,
body.stargazing .console-site-btn,
body.stargazing .radio-frame {
  opacity: 0;
  pointer-events: none;
}
body.stargazing .title-box {
  opacity: 0;
  transition-delay: 0.85s;   /* hold while the other boxes clear, then fade */
}
/* Pointer nears the (faded) title box → it pops back, cleanly and quickly, so
   it reads as the toggle. Moving away lets it fade out again. */
body.stargazing .title-box.peek {
  opacity: 1;
  transition: opacity 0.3s ease;
  transition-delay: 0s;
}

/* Honor reduced-motion: no fade transitions (stargaze.js also draws the
   starfield as a single static frame instead of animating). */
@media (prefers-reduced-motion: reduce) {
  .box:not(.title-box),
  .title-box,
  footer,
  .radio-frame {
    transition: none;
  }
}

/* ===================================================================
   7) Theme lock, part of four
   The other pieces: the metas in every page head (check.py enforces them),
   :root { color-scheme: dark } in the tokens, and .radio-frame's
   color-scheme. All load-bearing — see docs/layout.md.
   =================================================================== */

/* Noir Safari extension heuristic: it disables auto-darkening when it detects 
   this native dark mode media query. The site is already dark by design. */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
  }
}
