/* sky-is-green — retro / low-bit theme
   phosphor green on near-black, terminal type, scanlines, a DOS menu,
   and a Fallout-style project tumbler */

/* ---------- fonts (self-hosted, OFL) ---------- */
@font-face { font-family: 'vt323'; src: url('assets/fonts/vt323-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'silkscreen'; src: url('assets/fonts/silkscreen-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'silkscreen'; src: url('assets/fonts/silkscreen-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }

/* ---------- tokens ---------- */
:root {
  --bg: #0b0d0b;
  --bg-panel: #0e130d;
  --ink: #e4ebdf;
  --ink-dim: #93a38f;
  --acc: #5df08a;
  --acc-deep: #2f9e5b;
  --line: #223026;
  --line-bright: #35543e;
  --mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
  --display: 'vt323', var(--mono);
  --label: 'silkscreen', var(--mono);
  color-scheme: dark;
}

/* ---------- base ---------- */
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 15px/1.65 var(--mono);
}
.wrap { max-width: 920px; margin-inline: auto; padding-inline: clamp(16px, 4vw, 32px); }
a { color: var(--acc); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
::selection { background: var(--acc); color: #06120a; }
:focus-visible { outline: 2px dashed var(--acc); outline-offset: 3px; border-radius: 2px; }
.skip { position: absolute; left: -9999px; top: 0; background: var(--acc); color: #06120a; padding: 8px 14px; z-index: 50; font-family: var(--label); font-size: 11px; }
.skip:focus { left: 8px; top: 8px; }

/* scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--line); border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--line-bright); }

/* CRT: scanlines + a whisper of glow (toggleable) */
body::after {
  content: '';
  position: fixed; inset: 0; pointer-events: none; z-index: 40;
  background: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.18) 2px 3px);
}
body::before {
  content: '';
  position: fixed; inset: 0; pointer-events: none; z-index: 39;
  background: radial-gradient(120% 90% at 50% 0%, rgba(93, 240, 138, 0.05), transparent 60%);
}
html.crt-off body::after, html.crt-off body::before { display: none; }
@media (prefers-contrast: more) { body::after, body::before { display: none; } }

/* ---------- header: DOS menu bar ---------- */
.bar {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px 16px;
  padding-block: 12px; border-bottom: 1px solid var(--line);
}
.brand { font-family: var(--label); font-size: 11px; color: var(--ink); letter-spacing: 0.04em; }
.brand:hover { text-decoration: none; color: var(--acc); }
.brand-mark { color: var(--acc); margin-right: 6px; }
.menu { display: flex; flex-wrap: wrap; gap: 4px; margin-left: auto; font-family: var(--label); font-size: 11px; }
.menu a {
  color: var(--ink-dim); padding: 6px 10px; letter-spacing: 0.04em;
  border: 1px solid transparent;
}
.menu a:hover { color: var(--acc); border-color: var(--acc-deep); text-decoration: none; }
.menu a .num { color: var(--acc-deep); margin-right: 7px; }
.crt-toggle {
  font-family: var(--label); font-size: 10px; letter-spacing: 0.04em;
  color: var(--ink-dim); background: none; border: 1px solid var(--line);
  padding: 6px 9px; cursor: pointer;
}
.crt-toggle:hover { color: var(--acc); border-color: var(--acc-deep); }
.crt-toggle[aria-pressed="false"] { color: var(--acc-deep); }

/* ---------- hero ----------
   The hero is as tall as what is in it: the prompt, the title and the portrait,
   with the tag lists just under them, all one block. It used to be stretched to
   fill the first screen, which put the tags a long way below the portrait and
   then left the rest of the screen empty before the dashed divider — the
   padding here is the shared `section` 34px for the same reason, so the divider
   sits as far below the tags as it does above the next heading. The portrait is
   centred against the title rather than against a block, which is why the
   skills are not in the row it lives in — with them in there the row grew to
   500px and the portrait sat low. */
.hero {
  display: grid;
  gap: clamp(24px, 4vh, 44px);
}
.hero-in {
  display: grid; grid-template-columns: 1fr auto;
  gap: clamp(20px, 5vw, 48px); align-items: center;
}
.hero-text { min-width: 0; }
.hello { font-family: var(--label); font-size: 12px; color: var(--ink-dim); margin: 0 0 10px; }
.prompt { color: var(--acc); }
.hero h1 {
  font-family: var(--display); font-weight: 400; margin: 0 0 14px;
  font-size: clamp(30px, 5.4vw, 47px); line-height: 1.06; letter-spacing: 0.01em;
  text-shadow: 0 0 22px rgba(93, 240, 138, 0.28);
}
.hero .sub { color: var(--ink-dim); margin: 0; font-size: 14px; }
.avatar { margin: 0; text-align: center; }
.avatar img {
  display: block; width: clamp(208px, 24vw, 320px); height: auto;
  border: 1px solid var(--line); padding: 8px; background: var(--bg-panel);
}
.avatar figcaption { margin-top: 10px; font-size: 12px; color: var(--ink-dim); }
.scan-line { display: block; font-family: var(--label); font-size: 11px; color: var(--acc); margin-bottom: 4px; letter-spacing: 0.06em; }
@media (max-width: 720px) {
  .hero-in { grid-template-columns: 1fr; }
  .avatar { justify-self: center; order: 2; }
  /* the caption is wider than the portrait, and the figure is centred as a
     whole — so the image needs centring inside it too, or it sits left */
  .avatar img { margin-inline: auto; width: 208px; height: 208px; }
}

/* ---------- skills ---------- */
/* The group is the boxed thing and the tags under it are plain dim text: the
   category is the label, the tags are the detail. The label sits on its own
   line above its tags, so a long list of concepts can start at the block's own
   left edge; the tags sit in their own element (`div.tags`) so the ones that
   wrap hang under the first tag rather than under the box. */
.skills { display: grid; gap: 14px; }
/* the whole block centres — each group's label and its tags — matching the
   page below the hero */
.skills .row { display: grid; justify-items: center; gap: 6px; }
.skills .tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 0; }
.skills .g {
  font-family: var(--label); font-size: 9px; color: var(--ink); text-transform: uppercase;
  border: 1px solid var(--line-bright); padding: 4px 8px; text-align: center;
}
/* a dash between the tags rather than a bare gap: the row reads as one line of
   separated words. The dash belongs to the tag it follows, so a wrap leaves it
   at the end of the line rather than opening the next one with it. A tag never
   breaks inside itself, so a dash never lands alone. */
.skills .chip.static { white-space: nowrap; }
.skills .chip.static:not(:last-child)::after {
  content: '-'; margin-inline: 6px; color: var(--line-bright);
}

/* ---------- chips ---------- */
.chip {
  font-family: var(--label); font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em;
  color: var(--ink-dim); background: transparent; border: 1px solid var(--line);
  padding: 4px 8px; cursor: pointer;
}
.chip:hover { color: var(--acc); border-color: var(--acc-deep); }
.chip.active { background: var(--acc); border-color: var(--acc); color: #06120a; }
/* the skills' tags are not filters: no box and no hover, just quiet text */
.chip.static { cursor: default; border: 0; background: none; padding: 4px 0; }
.chip.static:hover { color: var(--ink-dim); }

/* ---------- sections ---------- */
section { padding-block: 34px; }
.sec-label {
  font-family: var(--label); font-size: 12px; font-weight: 400; letter-spacing: 0.08em;
  color: var(--acc); margin: 0 0 22px; text-transform: uppercase;
}
#about, #work, #open, #contact { border-top: 1px dashed var(--line); }

/* ---------- about ---------- */
/* the paragraphs take the whole column (86ch ≈ the 856px the wrap leaves), so
   the page is as short as the text allows — the request was "wider … which will
   shorten the page-length", 68ch was leaving ~180px of column unused per line */
.about p { margin: 0 auto 16px; max-width: 86ch; }
.about p:first-child { font-size: 16.5px; color: var(--ink); }
.about p + p { color: var(--ink-dim); }

/* ---------- work: 3D drum tumbler (pip-boy style) ---------- */
.filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.count { font-family: var(--label); font-size: 10px; letter-spacing: 0.04em; margin: 16px 0 0; }

.tumbler { display: flex; gap: 10px; align-items: stretch; }
/* The rail (up/down) is a flex item of its own, so the drum's frame is only
   centred by accident: at 1300px it sat 29px left of the line every other
   centred thing on the page sits on (ticks, count, filters, hero, about). This
   pad mirrors the rail — sizeDrum() measures it — and puts the frame back on
   that line, which is the middle of the drum's window. */
.tum-pad { flex: 0 0 auto; width: var(--rail, 0px); }
/* The scroller and the monitor are siblings. The monitor is positioned against
   this frame and never moves; the scroller only generates scroll input. The
   stage used to sit inside the scroller relying on position: sticky, but
   sticky never applied — .scr's later position: relative wins the cascade, so
   the stage scrolled away with the content and the drum read as a blank gap. */
.tum-frame {
  position: relative;
  flex: 1 1 auto; height: var(--tumh, clamp(380px, 62vh, 560px));
  --tumh: clamp(380px, 62vh, 560px);
}
.tum-viewport {
  position: absolute; inset: 0;
  overflow-y: auto; overflow-x: hidden; scrollbar-width: none;
  cursor: grab; user-select: none; outline: none;
}
.tum-viewport::-webkit-scrollbar { display: none; }
.tum-viewport.dragging { cursor: grabbing; }
.tum-space { height: 0; }
/* The frame's direct-child selector outranks .scr (mounted on the stage by
   screen.js, later in this file, setting position: relative) — without it the
   monitor would collapse to height 0 and the cull would hide every card. */
.tum-frame > .tum-stage {
  position: absolute; inset: 0;
  pointer-events: none;   /* the scroller underneath keeps the wheel and drags */
  --sc: 0.72; /* card draw scale (also read by the bezel/compensation maths) */
  /* The cards ride a cylinder, so the far ones project well outside the frame:
     the monitor is the window they are seen *through*. The scroller used to do
     this clipping by being their ancestor; as siblings it has to be explicit,
     or the neighbours spill over the rest of the page.
     overflow alone clips at the stage's padding box, i.e. the *frame's* box,
     which is 4px outside the opaque bezel — so a sliver of the rolling
     neighbours showed in that gutter, ending in a hard straight cut just
     outside the monitor. The clip below lands on the bezel's outline instead
     (the same 4px inset the bezel itself uses, and its radius), so the cards
     run under the plastic and vanish behind the monitor's edge. */
  overflow: hidden;
  clip-path: inset(var(--frame-inset, 4px) round calc(var(--frame-radius, 15px) + 7px));
}
/* the cylinder itself: the perspective stage the cards roll on.
   There is no top/bottom fade any more — it used to hide the edges of the
   stack, but the cards now butt and the drum continues past the frame, so a
   fade here would just dim the seam it is supposed to hide. The bezel's
   vignette does the softening instead. */
.tum-drum {
  position: absolute; inset: 0;
  perspective: var(--persp, 1900px);
}
/* The card's width is set from app.js so that it projects to exactly the screen
   the frame opens — no part of a card is ever cut off by the frame. */
.slide {
  position: absolute; top: 50%; left: 50%;
  width: var(--card-w, 100%);
  margin-left: calc(var(--card-w, 100%) / -2);
  transform: translateY(-50%) rotateX(var(--a, 0deg)) translateZ(var(--r, 480px)) scale(var(--sc, 0.72));
  backface-visibility: hidden;
  will-change: transform, opacity;
  pointer-events: none;
}
.slide.active { pointer-events: auto; }

/* ---------- the monitor: frame, glass, signal ----------
   Mounted by screen.js on any box: the same chrome runs over the project drum
   and over a game canvas, because all of it is screen-space — it sits *over*
   whatever the screen contains and never needs to know what that is. Sizes come
   from --frame-inset / --bezel so a screen can sit inside a larger box. */
.scr {
  position: relative;
  isolation: isolate;        /* keeps the content's z-index off the rest of the page */
  --frame-inset: 0px;
  --bezel: 12px;
  --frame-radius: 15px;
}
.scr-glass, .scr-glitch {
  position: absolute;
  inset: calc(var(--frame-inset) + var(--bezel));
  border-radius: var(--frame-radius);
  pointer-events: none;
}
/* The frame is inset over the content rather than wrapped around it: its border
   *is* the bezel and its padding box is the visible screen. */
.scr-bezel {
  position: absolute; inset: var(--frame-inset);
  box-sizing: border-box;
  border: var(--bezel) solid #0a0f09;
  border-radius: calc(var(--frame-radius) + 7px);
  pointer-events: none; z-index: 700;
  box-shadow:
    inset 0 0 0 2px #1b271a,
    inset 0 0 0 3px rgba(0, 0, 0, 0.65),
    inset 0 2px 0 rgba(93, 240, 138, 0.07),
    0 0 0 1px #040604,
    0 0 34px rgba(93, 240, 138, 0.05),
    0 26px 64px rgba(0, 0, 0, 0.6);
}
/* the recessed screen: soft all-round edges so the picture glows down into the
   frame instead of meeting a hard edge */
.scr-bezel::before {
  content: ''; position: absolute; inset: -2px; border-radius: var(--frame-radius); pointer-events: none;
  background:
    linear-gradient(90deg, rgba(9, 12, 9, 0.8) 0, rgba(9, 12, 9, 0) 3.5%, rgba(9, 12, 9, 0) 96.5%, rgba(9, 12, 9, 0.8) 100%),
    radial-gradient(120% 120% at 50% 50%, rgba(9, 12, 9, 0) 68%, rgba(9, 12, 9, 0.55) 100%);
  box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.45);
}
html.drum-off .scr-bezel { display: none; }

/* The glass: the curvature of a tube is a property of the screen, not of the
   picture on it, so it must not move when the picture does — and it must not sit
   on the cards, whose top and bottom edges are shared with their neighbours. A
   specular sweep high on the face, and an elliptical falloff that pulls the
   corners in like a raster rather than darkening the edges in straight lines. */
.scr-glass {
  z-index: 640;
  background:
    radial-gradient(46% 24% at 40% 26%, rgba(186, 255, 214, 0.05), transparent 74%),
    radial-gradient(120% 82% at 50% 50%, rgba(2, 5, 2, 0) 62%, rgba(2, 5, 2, 0.5) 100%);
  box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.45);
}
html.crt-off .scr-glass { display: none; }

/* Signal: the monitor's own noise. Screen-space, so it sits on the glass rather
   than on any one card — which is what lets a tear cross a seam. */
.scr-glitch {
  z-index: 660;
  overflow: hidden;
  /* Its own composited layer: the scanlines are a transparent stripe pattern
     over the whole glass, so without this the browser repaints the full area
     every frame as the picture moves underneath — measured at p95 24ms against
     6ms with the layer promoted. */
  will-change: transform;
}
.scr-glitch .scan {
  position: absolute; inset: 0;
  background: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 3px, rgba(0, 0, 0, 0.2) 3px 4px);
  opacity: 0.7;
  will-change: transform;
}
.scr-glitch .roll {
  position: absolute; left: 0; right: 0; top: 0; height: 16%;
  background: linear-gradient(180deg, rgba(93, 240, 138, 0), rgba(93, 240, 138, 0.03) 45%, rgba(93, 240, 138, 0.055) 55%, rgba(93, 240, 138, 0));
  animation: scr-roll 9s linear infinite;
}
/* transform, not `top`: an animated offset would relayout and repaint this
   layer every frame, and the layer covers the whole glass */
@keyframes scr-roll { from { transform: translateY(-110%); } to { transform: translateY(730%); } }
/* a jump at this opacity is easy to miss, so it flares as it lands */
.scr-glitch .roll.jumped { filter: brightness(2.4); }
.scr-glitch .tear {
  position: absolute; left: -2%; right: -2%; height: 26px; opacity: 0;
  background: linear-gradient(90deg, rgba(93, 240, 138, 0.07), rgba(255, 255, 255, 0.05) 40%, rgba(93, 240, 138, 0.05));
}
.scr-glitch .tear.on { animation: scr-tear 220ms steps(3, end) 1; }
@keyframes scr-tear {
  0%   { opacity: 0; transform: translateX(0); }
  20%  { opacity: 1; transform: translateX(-7px); }
  55%  { opacity: 0.7; transform: translateX(5px); }
  100% { opacity: 0; transform: translateX(0); }
}
html.crt-off .scr-glitch { display: none; }

/* magnetic interference: the picture is yanked to the right and sheared, then
   snaps back. Keyframes rather than a transition so it can be retriggered at
   any moment and still recover cleanly. */
.scr .interfere { animation: scr-interfere 240ms steps(6, end) 1; }
@keyframes scr-interfere {
  0%   { transform: none; }
  16%  { transform: translateX(22px) skewX(-5deg) scaleX(1.02); }
  33%  { transform: translateX(26px) skewX(-6deg) scaleX(1.02); }
  50%  { transform: translateX(10px) skewX(-2deg); }
  70%  { transform: translateX(-6px) skewX(1.5deg); }
  85%  { transform: translateX(3px) skewX(-0.5deg); }
  100% { transform: none; }
}
/* the whole glass lifts for one frame as the interference hits */
.scr-glitch.flashed { filter: brightness(1.6); }
@media (prefers-reduced-motion: reduce) {
  .scr .interfere { animation: none; }
  .scr-glitch .roll { animation: none; }
  .scr-glitch .tear { display: none; }
}

/* ---------- cards: little CRT faces ----------
   The face is deliberately symmetric top to bottom: the cards now butt edge to
   edge, so the bottom edge of one is physically the top edge of the next, and
   anything asymmetric (a sheen anchored near the top, a bottom-only inner
   shadow, a drop shadow under the card) shows up as a band across the seam. */
.slide-card {
  position: relative;
  height: var(--card-h, 460px);
  border-radius: 6px;
  padding: 15px 34px;
  /* how much of the record's own type scale this face has room for, set per
     card by fitRecords() in app.js */
  --rec: 1;
  /* The CRT silhouette: top and bottom edges bow outward (see crtPath in
     app.js). The sides are off-screen, so only those two edges need the bow —
     and because every card bows identically, adjacent edges follow the same
     curve, overlapping slightly instead of separating at the middle. */
  clip-path: var(--crt-path, none);
  overflow: hidden;   /* a long record is clipped at the face, never spills */
}
.slide-card { --narrow: 0.88; }
/* relative, so the record paints above .face: a positioned layer paints after
   non-positioned content, whatever the tree order says */
.slide-inner { position: relative; }

/* The surface. A card that is not the one in front draws narrower, so the drum
   reads as a lit panel between two set-back ones; because it is a layer of its
   own, narrowing it never touches the text. The side gradients curve it into
   shadow, and the matching top/bottom shadows put a groove at the seam where
   two faces meet. */
.face {
  position: absolute; inset: 0;
  border: 1px solid #1c2b1f;
  transform: scaleX(var(--narrow, 0.88));
  transition: transform 170ms linear;
  /* slightly convex glass: a centre-bright phosphor field, with the highlight
     in the middle rather than near the top */
  background:
    radial-gradient(92% 58% at 50% 50%, rgba(93, 240, 138, 0.05), transparent 64%),
    radial-gradient(150% 135% at 50% 50%, #131d13 0%, #0e140d 60%, #080c07 100%);
  box-shadow:
    inset 0 0 0 1px rgba(93, 240, 138, 0.035),
    inset 0 0 46px rgba(0, 0, 0, 0.5),
    inset 0 12px 20px -12px rgba(0, 0, 0, 0.85),
    inset 0 -12px 20px -12px rgba(0, 0, 0, 0.85);
}
.face::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg,
    rgba(4, 7, 4, 0.92) 0, rgba(4, 7, 4, 0.45) 4%, rgba(4, 7, 4, 0) 13%,
    rgba(4, 7, 4, 0) 87%, rgba(4, 7, 4, 0.45) 96%, rgba(4, 7, 4, 0.92) 100%);
}
.slide.awake .slide-card { --narrow: 1; }
.slide.awake .face { border-color: var(--line-bright); }
.slide.awake .slide-card::before, .slide.awake .slide-card::after {
  content: ''; position: absolute; width: 12px; height: 12px; border: 2px solid var(--acc-deep); pointer-events: none;
}
.slide.awake .slide-card::before { top: -2px; left: -2px; border-right: 0; border-bottom: 0; }
.slide.awake .slide-card::after { bottom: -2px; right: -2px; border-left: 0; border-top: 0; }
.slide-card .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: calc(6px * var(--rec)); }
.head-right { display: flex; gap: calc(10px * var(--rec)); align-items: baseline; }

/* ---------- standby: what a card shows until it is the one in front ----------
   Number and title alone, at the top of the face — the only part of a
   neighbour you can see while rolling is its first/last ~44px, so a vertically
   centred title would never be glimpsed coming in. The bar at the foot gives
   the outgoing edge something to show. */
.plate {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center;
  padding: 20px 0 26px;
  transition: opacity 160ms linear;
}
.plate-num { font-family: var(--label); font-size: 11px; color: var(--acc-deep); letter-spacing: 0.08em; margin-bottom: 6px; }
.plate-title {
  font-family: var(--display); font-weight: 400; margin: 0;
  font-size: clamp(30px, 3.4vw, 50px); line-height: 1;
  color: var(--ink-dim); text-align: center;
  text-shadow: 0 0 30px rgba(93, 240, 138, 0.18);
}
.plate-foot { margin-top: auto; display: flex; gap: 5px; align-items: flex-end; height: 12px; }
.plate-foot i { width: 9px; background: var(--acc-deep); opacity: 0.5; }
.plate-foot i:nth-child(1) { height: 12px; }
.plate-foot i:nth-child(2) { height: 7px; opacity: 0.34; }
.plate-foot i:nth-child(3) { height: 10px; opacity: 0.44; }
.plate-foot i:nth-child(4) { height: 5px; opacity: 0.26; }
.plate-foot i:nth-child(5) { height: 9px; opacity: 0.4; }
.plate-foot i:nth-child(6) { height: 6px; opacity: 0.3; }
.plate-foot i:nth-child(7) { height: 11px; opacity: 0.46; }
.slide.awake .plate { opacity: 0; }

/* awake: the record, generated on view */
.body { transition: opacity 220ms linear; }
.slide:not(.awake) .body { opacity: 0; }
/* the readout row rides the same `--rec` scale as the record: the badge and the
   tok/s readout share a 244px-wide face with the number, and at full size they
   wrap onto a second line (and make the head 16px taller) on a phone */
.gen-rate { font-family: var(--label); font-size: calc(9px * var(--rec)); letter-spacing: 0.05em; color: var(--acc-deep); }
.gen-rate.gen::after { content: '▮'; margin-left: 4px; animation: gen-blink 700ms steps(1, end) infinite; }
@keyframes gen-blink { 50% { opacity: 0; } }
.proj-num { font-family: var(--label); font-size: calc(11px * var(--rec)); color: var(--acc-deep); letter-spacing: 0.06em; }
.proj-num .star { color: var(--acc); }
.kind-badge {
  font-family: var(--label); font-size: calc(9px * var(--rec)); letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--acc); border: 1px dashed var(--acc-deep); padding: calc(2px * var(--rec)) calc(7px * var(--rec));
}
/* The record. Every size here is multiplied by the card's `--rec` (1 unless
   fitRecords() in app.js had to shrink it): a record that is too long for the
   face is shown smaller, rather than run off the bottom of the card. Margins
   take the same factor, so the shape of the record is unchanged. */
.proj-title { font-family: var(--display); font-size: calc(clamp(26px, 3.8vw, 34px) * var(--rec)); font-weight: 400; margin: 0 0 calc(4px * var(--rec)); line-height: 1.05; }
.proj-title a { color: var(--ink); }
.proj-title a:hover { color: var(--acc); text-decoration: none; }
.proj-hook { font-size: calc(15px * var(--rec)); margin: calc(8px * var(--rec)) 0 calc(12px * var(--rec)); color: var(--ink); max-width: 60ch; line-height: 1.55; }
.proj-bullets { margin: 0 0 calc(14px * var(--rec)); padding: 0; list-style: none; max-width: 60ch; }
.proj-bullets li { position: relative; padding-left: calc(18px * var(--rec)); margin-bottom: calc(6px * var(--rec)); color: var(--ink-dim); font-size: calc(13.5px * var(--rec)); line-height: 1.55; }
.proj-bullets li::before { content: '·'; position: absolute; left: 4px; color: var(--acc); }
.proj-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: calc(12px * var(--rec)); }
.proj-links { display: flex; flex-wrap: wrap; gap: calc(16px * var(--rec)); font-family: var(--label); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
.proj-links a::after { content: ' ↗'; color: var(--acc-deep); }
/* short windows used to shrink the record by a fixed amount; the measured fit
   does that now, and only when the record actually needs it */
@media (max-width: 620px) { .slide-card { padding: 16px 16px 18px; } }

.tum-side { display: flex; flex-direction: column; justify-content: center; gap: 10px; }
.tum-btn {
  font-family: var(--label); font-size: 13px; color: var(--acc);
  background: none; border: 0; cursor: pointer; padding: 8px 6px; letter-spacing: 0.04em;
}
.tum-btn:hover { color: var(--ink); }

/* A phone cannot spare a rail's width (48px + the gap) beside the drum: at
   390px that left the screen at 229px — inside the 200px floor the drum sizes
   itself to — and Scion's record lost a bullet there. Under 880px the rail
   moves *under* the frame as a pair of buttons instead: the frame takes the
   whole column (centred on the line by being the whole column, so the pad is
   redundant and goes), and the screen gets its 117px back. */
@media (max-width: 880px) {
  .tumbler { flex-wrap: wrap; }
  .tum-pad { display: none; }
  .tum-frame { flex: 1 1 100%; }
  .tum-side { flex-direction: row; gap: 4px; margin: 4px auto 0; }
}

.ticks { display: flex; flex-wrap: wrap; gap: 2px 6px; justify-content: center; margin-top: 16px; }
.tick {
  font-family: var(--label); font-size: 10px; letter-spacing: 0.05em;
  color: var(--ink-dim); background: none; border: 0; border-bottom: 1px solid transparent;
  padding: 3px 5px; cursor: pointer;
}
.tick:hover { color: var(--ink); }
.tick.active { color: var(--acc); border-bottom-color: var(--acc); }

/* ---------- open projects: the drum's cards, slid sideways ----------
   Same face, same record, same monitor — the motion is the only difference.
   Nothing rolls: this is a scroll-snap track with one card centred at a time
   and a sliver of each neighbour left showing, so the strip reads as a strip.
   The centred card is the awake one; the slivers hold their standby plates,
   exactly as the cards off the front of the drum do.

   The track is inset by the bezel's own width (its 4px inset plus its 12px
   border), so a card is never covered by the plastic and a sliver is cut
   exactly at the screen's opening rather than under the frame.

   --peek is how much of a neighbour shows. Each slide carries half the gap as
   padding and the track carries a spacer at either end of the same width, so
   the first and last cards centre like the one in the middle: the spacer is
   (track - card - gap) / 2, which comes out as peek less the half-gap. */
.caro {
  position: relative;
  --gap: clamp(8px, 1.2vw, 18px);
  --peek: clamp(30px, 5vw, 54px);
  --caro-w: calc(100% - 2 * var(--peek));
  --card-h: clamp(360px, 54vh, 460px);
  height: calc(var(--card-h) + 32px);
  text-align: left;   /* the records read left, the way the drum's do */
}
.caro-track {
  position: absolute; inset: 16px;
  display: flex; align-items: stretch;
  overflow-x: auto; overflow-y: hidden;
  border-radius: 10px;             /* the bezel's inner radius, 22 - 12 */
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  cursor: grab;
  outline: none;
}
.caro-track::-webkit-scrollbar { display: none; }
.caro-track.dragging { cursor: grabbing; scroll-snap-type: none; }
.caro-track::before, .caro-track::after {
  content: ''; flex: 0 0 calc((100% - var(--caro-w) - var(--gap)) / 2);
}
.caro .slide {
  position: relative; top: auto; left: auto;
  flex: 0 0 calc(var(--caro-w) + var(--gap));
  width: auto; margin: 0;
  padding-inline: calc(var(--gap) / 2);
  transform: none;
  scroll-snap-align: center;
}
/* no cylinder here, so a neighbour is not narrower: on the drum only the front
   card is a lit panel; on the strip every card is square on */
.caro .slide-card { --narrow: 1; }
/* and nothing curves: a neighbour is a panel the screen happens to cut off, so
   its sides are only half in shadow — dark enough to read as the far edge of
   the glass, light enough that the sliver reads as a panel rather than a gap.
   The brightness lives out at the edges rather than in the middle, because a
   sliver is all edge and no middle. */
.caro .face {
  background:
    radial-gradient(92% 58% at 50% 50%, rgba(93, 240, 138, 0.05), transparent 64%),
    radial-gradient(150% 135% at 50% 50%, #131d13 0%, #101a10 58%, #0e170e 100%);
}
.caro .face::after {
  background: linear-gradient(90deg,
    rgba(4, 7, 4, 0.45) 0, rgba(4, 7, 4, 0.2) 5%, rgba(4, 7, 4, 0) 14%,
    rgba(4, 7, 4, 0) 86%, rgba(4, 7, 4, 0.2) 95%, rgba(4, 7, 4, 0.45) 100%);
}
/* with clones at both ends the peek comes from the neighbouring card, so the
   spacers that centred the first/last card in the finite layout are not needed */
.caro.has-wrap .caro-track::before,
.caro.has-wrap .caro-track::after { display: none; }
.caro-side { display: flex; justify-content: center; gap: 4px; margin-top: 8px; }

/* ---------- contact: a relay address, so the inbox stays private ----------
   The address on this page is a Firefox Relay alias: mail sent to it is
   forwarded to the real inbox, and the inbox itself is in no file in this
   repo — only the alias is published. */
.contact { display: grid; justify-items: center; gap: 20px; }
.contact-intro { max-width: 64ch; margin: 0; color: var(--ink-dim); }
.contact-box {
  display: grid; justify-items: center; gap: 10px;
  width: min(100%, 62ch);
  border: 1px dashed var(--line-bright);
  background: var(--bg-panel);
  padding: 18px 22px 16px;
}
.contact-label {
  font-family: var(--label); font-size: 10px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--ink-dim);
}
.contact-addr {
  font-family: var(--display); font-size: clamp(20px, 4.4vw, 32px); line-height: 1.25;
  color: var(--acc); word-break: break-all; text-align: center;
  text-shadow: 0 0 18px rgba(93, 240, 138, 0.25);
}
.contact-addr:hover { text-decoration: underline; text-underline-offset: 4px; }
.contact-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.contact-btn {
  font-family: var(--label); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
  color: var(--acc); background: none; border: 1px solid var(--line-bright);
  padding: 6px 11px; cursor: pointer;
}
.contact-btn:hover { border-color: var(--acc); color: var(--ink); }
.contact-btn:focus-visible { outline: 2px dashed var(--acc); outline-offset: 2px; }
.contact-note { max-width: 64ch; margin: 0; font-size: 13px; color: var(--ink-dim); }

/* ---------- footer ---------- */
footer { border-top: 1px dashed var(--line); margin-top: 30px; padding-block: 30px 40px; }
.links { display: flex; flex-wrap: wrap; gap: 18px; font-family: var(--label); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 16px; }
.eof { font-family: var(--label); font-size: 10px; letter-spacing: 0.04em; margin: 0; }
.key { font-family: var(--label); font-size: 10px; border: 1px solid var(--line-bright); padding: 1px 5px; border-radius: 2px; color: var(--ink-dim); }
.cursor { display: inline-block; margin-left: 6px; color: var(--acc); animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .cursor { animation: none; } html { scroll-behavior: auto; } }

/* flash target when jumped to */
@keyframes flash { from { outline: 2px solid var(--acc); outline-offset: 4px; } to { outline: 2px solid transparent; outline-offset: 4px; } }
.flash { animation: flash 1.4s ease-out 1; }
@media (prefers-reduced-motion: reduce) { .flash { animation: none; } }

/* ---------- centred from the about down ----------
   The hero opens flush left; below it the page reads centred — headings,
   prose, the filters, the monitors and the footer. Scoped by id deliberately:
   the games page reuses the footer but not these ids, so it keeps its own
   left-aligned chrome. One thing still reads left even here: what is *inside*
   a card. A record's bullets are marked with absolutely positioned glyphs and a
   centred line leaves those stranded at the card's edge, so the carousel and
   the drum both keep their own records flush left. */
#about, #work, #open, #contact, #colophon { text-align: center; }
.tumbler, .caro { text-align: left; }
.filters { justify-content: center; }
.links { justify-content: center; }

/* ---------- games: cabinets on the same glass ----------
   This page is wider than the reading sections: the toy in the right column is
   an application, and it needs the room. */
html[data-page="games"] .wrap { max-width: none; padding-inline: clamp(12px, 1.4vw, 26px); }

   Each cabinet is a .scr (screen.js mounts the frame, glass and signal on it),
   holding a small fixed-size canvas that CSS scales up. image-rendering keeps
   the phosphor dots square instead of blurring them. */
.cade-intro { max-width: 100%; margin: 0 0 22px; }
.cade {
  display: grid;
  /* the games are a rail at the left edge of the screen and the toy is the
     stage. The third column is empty and deliberately the same width as the
     rail: with one on each side the stage ends up centred on the *screen*
     rather than pushed off to the right by the rail. */
  grid-template-columns: var(--rail) minmax(0, 1fr) var(--rail);
  gap: clamp(14px, 1.6vw, 28px);
  align-items: start;
}
.cade-games { display: grid; gap: 18px; }
.cade { --rail: clamp(210px, 16vw, 290px); }
@media (max-width: 880px) {
  .cade { grid-template-columns: 1fr; }
  .cab .scr { aspect-ratio: 16 / 9; }
}

.cab { margin: 0; }
/* A finger cannot hover, so the two playable screens own the gesture: without
   this the browser claims a drag for scrolling and cancels the pointer before
   the paddle or the swipe ever hears about it. The toy's screen is left alone —
   it needs to scroll. */
.scr[data-game="snake"], .scr[data-game="breakout"] { touch-action: none; }
.cab .scr {
  aspect-ratio: 8 / 5;
  background: #050805;
  outline: none;
}
.cab .scr:focus-visible { outline: 2px dashed var(--acc); outline-offset: 4px; }
.cab-canvas {
  position: absolute;
  inset: calc(var(--frame-inset, 0px) + var(--bezel, 12px));
  width: calc(100% - 2 * (var(--frame-inset, 0px) + var(--bezel, 12px)));
  height: calc(100% - 2 * (var(--frame-inset, 0px) + var(--bezel, 12px)));
  image-rendering: pixelated;
  cursor: crosshair;
}
.cab-hud {
  position: absolute; z-index: 5;
  top: calc(var(--frame-inset, 0px) + var(--bezel, 12px) + 8px);
  left: calc(var(--frame-inset, 0px) + var(--bezel, 12px) + 10px);
  right: calc(var(--frame-inset, 0px) + var(--bezel, 12px) + 10px);
  display: flex; justify-content: space-between;
  font-family: var(--label); font-size: 10px; letter-spacing: 0.06em;
  color: var(--acc-deep); pointer-events: none;
}
.cab-plate {
  position: absolute; inset: calc(var(--frame-inset, 0px) + var(--bezel, 12px));
  z-index: 5;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
  text-align: center;
  background: radial-gradient(120% 90% at 50% 40%, rgba(6, 10, 6, 0.86), rgba(4, 7, 4, 0.96));
  transition: opacity 220ms linear;
}
.scr.playing .cab-plate { opacity: 0; pointer-events: none; }
.cab-hint { margin: 4px 0 0; font-family: var(--label); font-size: 10px; letter-spacing: 0.05em; color: var(--ink-dim); }
.cab-hint .key { color: var(--acc); border-bottom: 1px solid var(--acc-deep); padding-bottom: 1px; }
.cab-foot { margin: 10px 0 0; font-size: 12.5px; max-width: 60ch; }
.cab-foot strong { color: var(--ink); font-weight: 400; }

/* the embedded toy: its own document, drawn inside the same frame */
.cab-wide { margin-top: 0; justify-self: center; width: min(100%, 1240px); }
.cab-wide .scr { aspect-ratio: auto; height: min(88vh, 920px); }
.cab-frame {
  position: absolute;
  inset: calc(var(--frame-inset, 0px) + var(--bezel, 12px));
  width: calc(100% - 2 * (var(--frame-inset, 0px) + var(--bezel, 12px)));
  height: calc(100% - 2 * (var(--frame-inset, 0px) + var(--bezel, 12px)));
  border: 0; background: #0b0d0b;
}
/* The toy lives in its own repository and is served from its own project page,
   which can genuinely be absent. A frame that cannot load still fires `load` —
   on GitHub's 404 page — so games.js checks the status and, when it is not
   there, swaps the frame for this note: an honest notice in the bezel beats a
   404 presented as if it were the toy. The class lands on the screen itself
   (cab.el is the .scr), matching how .scr.playing works. */
.cab-fallback { display: none; }
.scr.missing .cab-frame { display: none; }
.scr.missing .cab-fallback {
  display: block;
  position: absolute;
  inset: calc(var(--frame-inset, 0px) + var(--bezel, 12px));
  margin: 0; padding: 24px;
  font-size: 13px; line-height: 1.7; max-width: 60ch;
}
.cab .scr.missing { aspect-ratio: 16 / 7; height: auto; }

/* One screen at a time sits in the middle of the arcade, and a screen that is
   in use takes it: start a game and it swaps into the stage, while the toy drops
   into the empty right-hand rail — the same width as the left one, which is what
   keeps the stage centred on the screen rather than on the space beside the
   rail. Only the games move in the DOM (games.js); a canvas keeps its pixels
   across a move, but reparenting the toy's frame would reload it and throw the
   drawing away, so the toy is placed by these rules instead. The class names
   come and go either way, which is why the placement lives here and not in a
   style attribute.

   Under the single-column breakpoint there is no rail to swap into and every
   screen is already the full width of the screen, so none of it applies — the
   toy keeps .cab-wide and simply sits in the stack where it is. */
@media (min-width: 881px) {
  .cade.cade-swapped .cade-games { grid-column: 1; grid-row: 1; }
  .cade.cade-swapped .cab-staged {
    grid-column: 2; grid-row: 1;
    justify-self: center; width: min(100%, 1240px);
  }
  .cade.cade-swapped .cab-railed {
    grid-column: 3; grid-row: 1;
    justify-self: stretch; width: auto;
  }
  /* the rail is a strip of small fixed-aspect plates: the fitted height and the
     stage sizing of .cab-wide do not belong in it */
  .cade.cade-swapped .cab-railed .scr { aspect-ratio: 8 / 5; height: auto; }
  .cade.cade-swapped .cab-railed .cab-return { display: inline; }
}
/* how the toy gets back to the middle, said where it is only true */
.cab-return { display: none; }
