/* boalbasaur — portfolio
   Implementation of design/Boalbasaur.dc.html */

:root {
  /* Ink */
  --ink:          oklch(0.2 0.02 260);
  --ink-strong:   oklch(0.3 0.02 260);
  --ink-body:     oklch(0.35 0.02 260);
  --ink-mid:      oklch(0.4 0.02 260);
  --ink-soft:     oklch(0.42 0.02 260);
  --ink-softer:   oklch(0.45 0.02 260);
  --ink-muted:    oklch(0.5 0.02 260);

  /* Surfaces */
  --surface:      oklch(0.99 0.005 95);
  --surface-alt:  oklch(0.94 0.01 95);
  --border:       oklch(0.9 0.01 95);
  --border-firm:  oklch(0.85 0.02 95);

  /* Greens */
  --green:        oklch(0.5 0.13 155);
  --green-dot:    oklch(0.7 0.14 155);
  --green-eyebrow:oklch(0.55 0.1 155);
  --pill-bg:      oklch(0.94 0.03 155);
  --pill-fg:      oklch(0.35 0.08 155);
  --note-bg:      oklch(0.95 0.03 155);
  --note-label:   oklch(0.32 0.09 155);
  --note-text:    oklch(0.3 0.08 155);
  /* Five leaf tones, spread across lightness and a little across hue: a couple
     catching the light, a couple sitting in shade. Decorative shapes at half
     opacity, so none of them carries a contrast requirement. */
  --leaf-a:       oklch(0.72 0.13 150);
  --leaf-b:       oklch(0.6 0.15 145);
  --leaf-c:       oklch(0.79 0.1 152);
  --leaf-d:       oklch(0.66 0.14 160);
  --leaf-e:       oklch(0.53 0.15 143);

  --link:         oklch(0.42 0.1 155);
  --link-hover:   oklch(0.32 0.1 155);

  /* The mascot's own body teal, averaged over a patch of it rather than picked
     off one pixel: the art is shaded, so a single sample lands on whichever end
     of that gradient you happened to click. One background blob wears it, which
     is what stops it reading as a stray pink shape from another palette. */
  --spot:         #4c9b8f;

  /* Retro console — the mascot's help menu. Its own near-black-on-bone pair,
     deliberately outside the page's ink ramp: the point of the panel is that
     it looks like a different device sitting on top of the page. */
  --retro-bg:     #fdfdf8;
  --retro-ink:    #2b2b2b;

  --display: 'Space Grotesk', system-ui, sans-serif;
  --body: 'Inter', system-ui, sans-serif;

  --shell: 1200px;
  --gutter: 56px;

  /* Driven by main.js */
  --scroll-y: 0;      /* window.scrollY, unitless */
  --mascot-tilt: 0;   /* degrees the mascot leans toward the pointer */
  --mascot-look-x: 0; /* px the face pans inside its frame, horizontally */
  --mascot-look-y: 0; /* px the face pans inside its frame, vertically */
}

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

/* No `scroll-behavior: smooth` on the root: it routes every programmatic scroll
   through the animator, which fights rapid wheel input. main.js asks for smooth
   explicitly on the one interaction that wants it. */

body {
  margin: 0;
  font-family: var(--body);
  color: var(--ink);
  background: linear-gradient(
    180deg,
    oklch(0.97 0.015 145) 0%,
    oklch(0.965 0.02 95) 40%,
    oklch(0.96 0.03 155) 100%
  );
}

body.is-locked { overflow: hidden; }

a { color: var(--link); text-decoration: none; }
a:hover { color: var(--link-hover); }

:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: 3px;
  border-radius: 4px;
}

.page {
  position: relative;
  /* `clip`, not `hidden`. `hidden` still makes this a scroll container — the
     scrollbar is hidden but the box can be scrolled programmatically, and the
     decorative layers overflow it by ~300px. Scrolling anything into view
     scrolls every scrollable ancestor, so an anchor jump left .page itself
     scrolled down with no way to scroll it back: the header and the top of the
     headline became permanently unreachable. `clip` clips identically without
     creating a scroll port. The `hidden` above it is the fallback for Safari
     below 16, which ignores the `clip` line. */
  overflow: hidden;
  overflow: clip;
  min-height: 100vh;
}

/* --- Background blobs ---------------------------------------------------
   The wrapper carries the scroll parallax and the inner <i> carries both the
   shape and the float animation, so the two transforms compose instead of
   overwriting each other. */
.blob {
  position: absolute;
  z-index: 0;
  pointer-events: none;
}
.blob > i {
  display: block;
  width: 100%;
  height: 100%;
}
.blob--1 {
  top: 120px; left: -60px;
  width: 220px; height: 220px;
  transform: translateY(calc(var(--scroll-y) * -0.06px));
}
/* No float here. This one sits high on the left, where the page is not moving
   yet, and a shape bobbing up and down on its own beside still text reads as
   something loading rather than as decoration. The parallax on the wrapper is
   motion enough, and that one at least answers to the scroll. */
.blob--1 > i {
  border-radius: 50%;
  background: oklch(0.9 0.06 155);
  filter: blur(2px);
  opacity: 0.6;
}
.blob--2 {
  top: 900px; right: -80px;
  width: 280px; height: 280px;
  transform: translateY(calc(var(--scroll-y) * 0.05px));
}
/* The one shape on the page that is not a green. It was a pink, which belonged
   to no other colour here; now it is the mascot's teal. Held well under the
   0.5 the pink ran at, because that pink was near-white to begin with and this
   is a mid-tone: at the old weight it stops being a background and becomes a
   teal disc parked beside the projects. */
.blob--2 > i {
  border-radius: 50%;
  background: var(--spot);
  opacity: 0.28;
  animation: float-slow 9s ease-in-out infinite reverse;
}
.blob--3 {
  top: 1700px; left: 30%;
  width: 160px; height: 160px;
  transform: translateY(calc(var(--scroll-y) * -0.04px));
}
.blob--3 > i {
  border-radius: 40% 60% 55% 45%;
  background: oklch(0.88 0.07 155);
  opacity: 0.5;
}

/* --- Falling leaves -----------------------------------------------------
   A page-wide layer so the leaves can settle at the foot of the document.
   Some start there; the rest get their transform from main.js as you scroll. */
.leaves {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
/* A soft band of ground at the foot of the page. Without a surface to lie on,
   leaves parked at the bottom just read as floating low in the background. */
.ground {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 104px;
  background: linear-gradient(
    180deg,
    oklch(0.93 0.05 150 / 0) 0%,
    oklch(0.91 0.055 150 / 0.5) 42%,
    oklch(0.89 0.06 148 / 0.85) 100%
  );
}

.leaf {
  position: absolute;
  opacity: 0.5;
  background: var(--leaf-a);
  border-radius: 0% 100% 0% 100%;
  transform: rotate(calc(var(--rot) * 1deg));
}
/* Landed leaves sit a little heavier than the ones still in the air. */
.leaf--ground { opacity: 0.62; }

/* Shape mirrors on a period of two and tone cycles on a period of five, so the
   two only line up again every tenth leaf. Kept as separate rules rather than
   one alternating block: with the litter line this dense, tying tone to the
   shape would band it into visible stripes. `.leaves` holds no other <i>, so
   nth-of-type counts leaves and nothing else. */
.leaf:nth-of-type(even)  { border-radius: 100% 0% 100% 0%; }
.leaf:nth-of-type(5n+1)  { background: var(--leaf-a); }
.leaf:nth-of-type(5n+2)  { background: var(--leaf-b); }
.leaf:nth-of-type(5n+3)  { background: var(--leaf-c); }
.leaf:nth-of-type(5n+4)  { background: var(--leaf-d); }
.leaf:nth-of-type(5n)    { background: var(--leaf-e); }

@keyframes float-slow {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-18px); }
}

@keyframes gentle-bounce {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50%      { transform: translateY(-10px) rotate(2deg); }
}

/* --- Scroll reveal ------------------------------------------------------ */
[data-reveal] {
  opacity: 0;
  transform: translateY(28px);
  transition:
    opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
    transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
  transition-delay: var(--reveal-delay, 0s);
}
[data-reveal][data-reveal-near] { transform: translateY(16px); }
[data-reveal].is-revealed {
  opacity: 1;
  transform: translateY(0);
}

/* --- Header ------------------------------------------------------------- */
.site-header {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 32px var(--gutter);
  max-width: var(--shell);
  margin: 0 auto;
}

.brand { display: flex; align-items: center; gap: 12px; }
.brand__logo {
  display: block;
  height: 40px;
  width: auto;   /* the mark isn't square — let it keep its ratio */
  flex: none;
}
.brand__name {
  font-family: var(--display);
  font-weight: 700;
  font-size: 20px;
  letter-spacing: -0.02em;
}

.ghost-button {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  border: 1.5px solid var(--border-firm);
  padding: 9px 18px;
  border-radius: 100px;
  white-space: nowrap;
  transition: border-color 0.2s ease, color 0.2s ease;
}
.ghost-button:hover {
  color: var(--ink);
  border-color: var(--green);
}

/* --- Hero --------------------------------------------------------------- */
/* 2, not the 1 every other section carries, and it has to stay ahead of them.
   The console is absolutely positioned inside this section and is now tall
   enough to hang past its bottom edge, over .projects. Sitting at the same
   z-index as .projects, the hero loses the tie to whatever comes later in the
   document, and .projects paints — and hit-tests — over the bottom of the
   console. It has no background, so nothing looks wrong: the panel is fully
   visible and its SEND button quietly stops taking clicks.

   Lifting the hero is safe because these are siblings in flow: their boxes
   never overlap, so the only thing this raises into the section below is the
   console, which is the one thing that has to be reachable there. */
.hero {
  position: relative;
  z-index: 2;
  max-width: var(--shell);
  margin: 0 auto;
  padding: 64px var(--gutter) 120px;
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  padding: 8px 16px;
  border-radius: 100px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-mid);
  margin: 0 0 28px;
}
.badge__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--green-dot);
}

.hero__title {
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(40px, 6vw, 76px);
  line-height: 1.02;
  letter-spacing: -0.03em;
  margin: 0 0 24px;
  max-width: 780px;
}
.hero__title-accent { color: var(--green); }

.hero__sub {
  font-size: 19px;
  line-height: 1.6;
  color: var(--ink-soft);
  max-width: 560px;
  margin: 0 0 8px;
}
.hero__sub + .hero__sub { margin-top: 18px; }
/* Steps back from the line above it — an aside, not part of the introduction. */
.hero__sub--soft {
  font-size: 16.5px;
  color: var(--ink-muted);
}

.mascot {
  position: relative;
  display: inline-block;
  margin-top: 72px;
}
.mascot__button {
  display: inline-block;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.mascot__note {
  position: absolute;
  top: -46px;
  left: 8px;
  z-index: 6;   /* stays above the console panel */
  background: var(--ink);
  color: oklch(0.98 0.005 95);
  font-size: 13px;
  font-weight: 600;
  padding: 9px 14px;
  border-radius: 14px 14px 14px 4px;
  white-space: nowrap;
  /* The line swaps out and back in rather than cross-fading, so the bubble is
     never mid-resize with readable text in it. */
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.mascot__note.is-swapping {
  opacity: 0;
  transform: translateY(4px);
}
/* Three stacked transforms, one per layer, so they never overwrite each other:
   .mascot__tilt leans the head toward the pointer, .mascot__frame carries the
   idle bounce, and the image pans inside the frame to sell the "looking". */
.mascot__tilt {
  display: inline-block;
  transform: rotate(calc(var(--mascot-tilt) * 1deg));
  transition: transform 0.18s ease-out;
}
.mascot__frame {
  display: block;
  width: 96px;
  height: 96px;
  border-radius: 50%;
  /* Same reason as .page — the image overflows this box, so `hidden` would
     leave it scrollable. */
  overflow: hidden;
  overflow: clip;
  /* Only shows before the image loads — mascot-hero.png over-covers the disc.
     This is the colour-managed value the browser paints for the artwork's mint,
     not the raw bytes in the PNG (its ICC profile maps 158,204,182 to this). */
  background: #91ceb5;
  animation: gentle-bounce 3.4s ease-in-out infinite;
}
.mascot__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  /* mascot-hero.png is a square canvas with the face centred inside a mint
     margin, so the face reads smaller than the disc while the image still
     over-covers it: the scale leaves 8.6px of overflow per side against the
     6px max pan, and the image's own edges never enter the frame. */
  transform:
    translate(calc(var(--mascot-look-x) * 1px), calc(var(--mascot-look-y) * 1px))
    scale(1.18);
  transition: transform 0.18s ease-out;
}

/* --- Retro console ------------------------------------------------------
   Clicking the mascot shakes it, then this pops in beside it and types its
   text out a character at a time, like a handheld console's dialogue box. */

/* Applied to the same element the pointer tilt sits on. While an animation is
   running it wins over the element's own transform, so the tilt is suspended
   for the length of the shake and resumes afterwards on its own — no need to
   suppress it from script. */
.mascot__tilt.is-shaking { animation: pixel-shake 0.4s ease-in-out; }

@keyframes pixel-shake {
  0%, 100% { transform: translateX(0)    rotate(0deg); }
  20%      { transform: translateX(-6px) rotate(-6deg); }
  40%      { transform: translateX(6px)  rotate(6deg); }
  60%      { transform: translateX(-4px) rotate(-4deg); }
  80%      { transform: translateX(4px)  rotate(4deg); }
}

.retro {
  position: absolute;
  top: 0;
  left: calc(100% + 28px);
  z-index: 5;
  width: 290px;
  padding: 16px 18px;
  background: var(--retro-bg);
  /* The frame is one `double` border, not a box inside a box: `double` is
     already two lines with a gap, which is exactly the console look. */
  border: 6px double var(--retro-ink);
  border-radius: 4px;
  box-shadow: 0 12px 28px rgba(20, 20, 15, 0.18);
  font-family: 'Courier New', monospace;
  color: var(--retro-ink);
  animation: retro-pop 0.22s ease-out;
}

@keyframes retro-pop {
  from { opacity: 0; transform: scale(0.85) translateY(10px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}

.retro__heading {
  margin: 0 0 14px;
  font-family: 'Press Start 2P', 'Courier New', monospace;
  font-size: 11px;
  line-height: 1.5;
  letter-spacing: 0.02em;
}

.retro__menu {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.retro__row {
  display: flex;
  align-items: baseline;
  gap: 8px;
  width: 100%;
  padding: 0;
  border: none;
  background: none;
  font-family: inherit;
  font-size: 14px;
  color: var(--retro-ink);
  text-align: left;
  cursor: pointer;
}
.retro__row:hover { color: var(--green); }

/* Reserved and hidden rather than added on select, so moving down the menu
   never nudges the labels sideways. Keeps its own colour so a hovered row
   tinting green does not drag the caret green with it. */
.retro__caret {
  flex: none;
  width: 14px;
  visibility: hidden;
  color: var(--retro-ink);
}
.retro__row.is-selected .retro__caret { visibility: visible; }

/* The caret is the entire selection marker — no colour shift, no bolding, and
   no focus ring. Scoped to the menu on purpose: these rows always carry the
   caret, which tracks focus (arrow keys and Tab both move it) and reads more
   clearly than a ring drawn around a text label. BACK and SEND live outside
   this list, have no caret, and so keep the page's normal focus outline. */
.retro__menu .retro__row:focus-visible { outline: none; }

/* BACK on its own line, under content rather than beside it. */
.retro__row--lead {
  width: auto;
  font-size: 13.5px;
  font-weight: 700;
}

.retro__lines {
  display: flex;
  flex-direction: column;
  gap: 9px;
  margin-bottom: 14px;
  font-size: 13.5px;
}
.retro__lines p { margin: 0; }

.retro__body {
  margin: 0 0 14px;
  font-size: 13.5px;
  line-height: 1.6;
}
.retro__body:last-child { margin-bottom: 0; }

.retro__link {
  display: inline-block;
  margin-bottom: 14px;
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 700;
  color: var(--retro-ink);
}
.retro__link:hover { color: var(--green); }

.retro__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.retro__actions .retro__row {
  width: auto;
  font-size: 13.5px;
  font-weight: 700;
}

.retro__input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  margin-bottom: 12px;
  padding: 8px;
  border: 2px solid var(--retro-ink);
  border-radius: 2px;
  background: var(--retro-bg);
  color: var(--retro-ink);
  font-family: inherit;
  font-size: 13px;
  resize: none;
}

/* The block cursor chasing the text as it types. Drawn on the element being
   typed into, so it moves line to line by itself. */
[data-type].is-typing::after {
  content: '';
  display: inline-block;
  width: 0.5em;
  height: 1em;
  margin-left: 1px;
  background: currentColor;
  vertical-align: -0.14em;
  animation: retro-caret 0.6s steps(1) infinite;
}

@keyframes retro-caret {
  0%, 49%   { opacity: 1; }
  50%, 100% { opacity: 0; }
}

/* --- Projects ----------------------------------------------------------- */
.projects {
  position: relative;
  z-index: 1;
  max-width: var(--shell);
  margin: 0 auto;
  padding: 0 var(--gutter) 140px;
  scroll-margin-top: 40px;  /* lands with a little air above the heading */
}

.projects__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 36px;
}
.projects__title {
  font-family: var(--display);
  font-weight: 700;
  font-size: 32px;
  letter-spacing: -0.02em;
  margin: 0;
}
.projects__hint {
  font-size: 14px;
  color: var(--ink-muted);
}

/* auto-fill, not a fixed three: a card is the same width whether there is one
   project or six, and new ones fill the row rather than resizing the ones
   already there. auto-fit would stretch a lone card across the whole shell. */
.projects__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 24px;
}

/* A card is a console screen. The frame, the bone panel and the near-black ink
   are the mascot's, not a second retro look invented alongside it: same
   `6px double` border, same --retro-* pair. `double` is already two lines with
   a gap between them, and the gap shows the element's own background, so one
   border draws the whole frame.

   Square corners, and the shadow is a hard offset block with no blur. Both are
   the point: a soft shadow is a light source, and there is no light source in a
   sprite. */
.card {
  display: flex;
  flex-direction: column;
  border-radius: 0;
  border: 6px double var(--retro-ink);
  background: var(--retro-bg);
  overflow: hidden;
  box-shadow: 7px 7px 0 var(--retro-ink);
  --reveal-delay: calc(var(--card-index) * 0.06s);
}

/* Pressed, like a button on the console. Two things make this work:

   It moves on `translate`, not `transform`. [data-reveal] transitions
   `transform` over 0.7s, and a card is revealed, so pressing on `transform`
   would inherit that timing and the press would take most of a second. It also
   only sets `transform`, which the reveal owns, so writing to it here would
   fight the reveal outright.

   Neither `translate` nor `box-shadow` is in that transition list, so both snap
   with no duration. That is deliberate: easing is a light-and-physics idea, and
   a sprite either is in one frame or the next. The card moves exactly as far as
   the shadow shrinks, so the block stays pinned to the page while the card
   drops onto it. */
.card:hover,
.card:focus-within {
  translate: 4px 4px;
  box-shadow: 3px 3px 0 var(--retro-ink);
}

.card__shot {
  position: relative;
  width: 100%;
  height: 260px;
  /* The screen sits in the frame, so it gets its own line under it rather than
     bleeding into the panel below. */
  border-bottom: 3px solid var(--retro-ink);
  /* Holds two focusable halves; focusing one scrolls its ancestors, so keep
     this out of scroll-container territory too. */
  overflow: hidden;
  overflow: clip;
  flex: none;
}

/* Scanlines, over everything in the screen including the hover label. They come
   up only on hover: held over three screenshots permanently they read as a
   texture on the page, and on the one you are pointing at they read as a
   CRT. The z-index puts them above .card__half, which is positioned but has no
   index of its own. */
.card__shot::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background: repeating-linear-gradient(
    rgba(20, 20, 15, 0) 0 2px,
    rgba(20, 20, 15, 0.17) 2px 3px);
  opacity: 0;
  transition: opacity 0.18s ease;
}
.card:hover .card__shot::after,
.card:focus-within .card__shot::after { opacity: 1; }

.shot {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.shot--empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  background: oklch(0.96 0.006 95);
  border-bottom: 1px dashed var(--border-firm);
  color: var(--ink-muted);
  font-size: 14px;
}
.shot--empty svg { width: 26px; height: 26px; }

/* Two invisible halves over the screenshot: left visits, right opens the doc. */
.card__half {
  position: absolute;
  top: 0;
  height: 100%;
  width: 50%;
  display: flex;
  align-items: flex-end;
  padding: 26px;
  cursor: pointer;
  background: rgba(0, 0, 0, 0);
  transition: background 0.25s ease;

  /* button reset */
  border: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
  -webkit-appearance: none;
  appearance: none;
}
.card__half--left  { left: 0; }
.card__half--right { right: 0; justify-content: flex-end; }

.card__half:hover,
.card__half:focus-visible {
  background: linear-gradient(0deg, rgba(20, 20, 15, 0.55), rgba(20, 20, 15, 0) 55%);
}
.card__half:focus-visible { outline-offset: -3px; }

/* The label on the screen, in the console's own pixel type. The hard offset
   behind it is the same block the card casts, at text size: no blur, so it
   reads as a second sprite drawn one step down and right rather than as a
   glow. It also keeps the label legible over whatever the screenshot happens
   to be, which a thin white type on a dark gradient does not always manage.
   Steps rather than easing, to match the press. */
.card__action {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: 'Press Start 2P', var(--display), monospace;
  font-weight: 400;
  font-size: 13px;
  line-height: 1.4;
  color: #fff;
  text-shadow: 3px 3px 0 rgba(20, 20, 15, 0.85);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.16s steps(3), transform 0.16s steps(3);
}
.card__half:hover .card__action,
.card__half:focus-visible .card__action {
  opacity: 1;
  transform: translateY(0);
}
/* Both glyphs come from the page font, not Press Start 2P, which has neither.
   Sized to sit level with the pixel caps beside them. */
.card__arrow { font-family: var(--body); font-size: 18px; }
.card__glyph { font-family: var(--body); font-size: 16px; }

.card__body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 1;
  padding: 20px 22px 22px;
}
/* Press Start 2P is drawn on an 8px grid and every glyph is one em wide, so a
   title's width is its character count, near enough. 12px holds the longest one
   here (19 characters, so ~228px) inside the narrowest a card ever gets, and
   line-height has to be generous or the caps sit on top of one another. */
.card__title {
  font-family: 'Press Start 2P', var(--display), monospace;
  font-weight: 400;
  font-size: 12px;
  line-height: 1.55;
  letter-spacing: 0.01em;
  color: var(--retro-ink);
  margin: 2px 0 0;
}
/* The tagline stays in the page's own text face. Pixel type is a costume, and
   a paragraph wearing it is a paragraph nobody reads. */
.card__tagline {
  font-size: 14px;
  color: var(--ink-softer);
  margin: 0;
}

/* The project's address, at the foot of the card. `margin-top: auto` pins it
   down there so the line sits level across a row of cards whose taglines run to
   different lengths; `align-self` keeps the link the width of its text rather
   than stretching it across the card, since only the text is the target. */
/* Set as a row of the console's menu: monospace, and the same caret the mascot
   marks its selected line with. `visibility` rather than `display` for the same
   reason it uses there, that the caret holds its column whether or not it is
   showing, so the address does not jump sideways under the pointer. */
.card__url {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: auto;
  align-self: flex-start;
  padding-top: 2px;
  font-family: 'Courier New', monospace;
  font-size: 13.5px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--link);
}
.card__url::before {
  content: '\25B8';
  visibility: hidden;
  font-size: 12px;
}
.card__url:hover { color: var(--link-hover); }
.card__url:hover::before,
.card__url:focus-visible::before { visibility: visible; }
.card__url-arrow {
  font-size: 12px;
  transition: transform 0.18s ease;
}
.card__url:hover .card__url-arrow { transform: translate(2px, -2px); }

/* Cartridge labels: squared off, boxed in the console's ink, and scoped to the
   card. The docs use .tech too, and both dark themes restyle it for their own
   app — none of them want this. */
.card__body .tech li {
  font-family: 'Courier New', monospace;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 4px 8px;
  border-radius: 0;
  border: 2px solid var(--retro-ink);
  background: rgba(0, 0, 0, 0);
  color: var(--retro-ink);
}

.tech {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tech li {
  font-size: 11.5px;
  font-weight: 600;
  padding: 5px 10px;
  border-radius: 100px;
  background: var(--pill-bg);
  color: var(--pill-fg);
}

/* --- Footer ------------------------------------------------------------- */
.site-footer {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  max-width: var(--shell);
  margin: 0 auto;
  padding: 0 var(--gutter) 64px;
  color: var(--ink-muted);
  font-size: 13px;
}

/* --- Doc panel ---------------------------------------------------------- */
.doc-overlay {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: rgba(20, 20, 15, 0.35);
  opacity: 0;
  transition: opacity 0.28s ease;
}
.doc-overlay.is-open { opacity: 1; }

.doc-panel {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 21;
  height: 100%;
  /* 620 keeps the measure around 73 characters at the body size — wide enough
     to give the doc some room, short of the point where lines get hard to
     track back. */
  width: min(620px, 92vw);
  background: var(--surface);
  box-shadow: -24px 0 60px rgba(20, 20, 15, 0.12);
  overflow-y: auto;
  overscroll-behavior: contain;
  transform: translateX(100%);
  transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
.doc-panel.is-open { transform: translateX(0); }

.doc-panel__inner { padding: 36px; }

.doc-panel__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: 28px;
}
.doc-panel__eyebrow {
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--green-eyebrow);
  margin: 0 0 8px;
}
.doc-panel__title {
  font-family: var(--display);
  font-weight: 700;
  font-size: 28px;
  letter-spacing: -0.02em;
  margin: 0;
}
.doc-panel__close {
  flex: none;
  width: 36px;
  height: 36px;
  border: none;
  border-radius: 50%;
  background: var(--surface-alt);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  color: var(--ink);
  transition: background 0.2s ease;
}
.doc-panel__close:hover { background: var(--border); }

.doc-panel__body {
  display: flex;
  flex-direction: column;
  gap: 26px;
}
.doc-panel__body h4 {
  font-family: var(--body);
  font-size: 13px;
  font-weight: 700;
  color: var(--ink-strong);
  margin: 0 0 6px;
}
.doc-panel__body p {
  font-size: 15px;
  line-height: 1.65;
  color: var(--ink-body);
  margin: 0;
}

.doc-note {
  background: var(--note-bg);
  border-radius: 14px;
  padding: 18px 20px;
}
.doc-note h4 { color: var(--note-label); }
.doc-note p  { color: var(--note-text); }

/* Shared building blocks a doc can reach for. Plain by default — a theme
   restyles them without the content markup having to change. */
.doc-lede {
  font-size: 16.5px;
  line-height: 1.55;
  color: var(--ink-strong);
  margin: 0;
}
.doc-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 10px 0 0;
  padding-left: 20px;
}
.doc-list li {
  font-size: 15px;
  line-height: 1.6;
  color: var(--ink-body);
}
.doc-list:first-child { margin-top: 0; }

/* Screenshots inside a doc. Plain by default, like the rest of the primitives.
   The aspect-ratio comes off the img's own width/height attributes, so a shot
   holds its space from first paint and the panel never reflows under a reader
   as the images decode. */
.doc-gallery {
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.doc-shot { margin: 0; }
.doc-shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 12px;
}
.doc-shot figcaption {
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-muted);
  margin-top: 9px;
}
/* A portrait shot at full width would run past the fold on its own. Set beside
   its caption instead, which is also the one place the caption has room to say
   more than a line. */
.doc-shot--phone {
  display: flex;
  align-items: center;
  gap: 20px;
}
.doc-shot--phone img {
  flex: none;
  width: 186px;
}
.doc-shot--phone figcaption { margin-top: 0; }

/* --- Doc panel: boalbasaur theme ----------------------------------------
   The skin for this site's own project doc. It is the page's design language
   turned inward: the same background wash, Press Start 2P headings borrowed
   from the mascot console, leaves for bullets, the console itself as the
   closing block, and the litter line at its foot. Reading the doc should show
   you the site the doc is about. Everything below is scoped to the attribute,
   so it can only ever apply to a doc that asked for it. */
.doc-panel[data-doc-theme="boalbasaur"] {
  background: linear-gradient(
    180deg,
    oklch(0.98 0.012 145) 0%,
    oklch(0.972 0.018 95) 45%,
    oklch(0.955 0.03 152) 100%
  );
}

.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__head {
  padding-bottom: 22px;
  border-bottom: 1px dashed var(--border-firm);
}

/* --- Leaves behind the doc ----------------------------------------------
   The page's background layer, brought inside the panel. The body becomes the
   stacking context and the layer sits at -1 within it, which puts the leaves
   above the panel's background wash but underneath every word — the one
   arrangement where decoration this dense does not have to fight the text. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__body {
  position: relative;
  z-index: 0;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-leaves {
  position: absolute;
  /* Sides and foot bleed out to the panel's own edges — the inset cancels
     .doc-panel__inner's padding exactly, so it reaches them without
     overflowing — and clip cuts the leaves off there, which is what makes them
     read as continuing past the panel instead of stopping inside it. The top
     stays at 0: pulled up, the layer would reach over the head's dashed rule.
     Clipping here rather than on .doc-panel also matters, since that one
     scrolls and would answer an overflowing leaf with a scrollbar. */
  inset: 0 -36px -36px;
  z-index: -1;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
/* Well under the 0.5 they carry on the page. These sit behind running text,
   and the ones marked as spots sit furthest in, so they go fainter still. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-leaves .leaf { opacity: 0.34; }
.doc-panel[data-doc-theme="boalbasaur"] .doc-leaves .leaf--spot { opacity: 0.16; }
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__eyebrow {
  font-family: 'Press Start 2P', 'Courier New', monospace;
  font-size: 9px;
  line-height: 1.6;
  letter-spacing: 0;
  color: var(--green);
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__close {
  background: var(--retro-bg);
  border: 2px solid var(--retro-ink);
  color: var(--retro-ink);
  border-radius: 4px;   /* square, like the console — not the page's soft disc */
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__close:hover {
  background: var(--surface-alt);
}

/* Headings carry the console's selection caret. Drawn in CSS rather than set
   as a ▶ character: Press Start 2P has no glyph for one, and it would land as
   tofu in the middle of every heading. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__body h4 {
  display: flex;
  align-items: center;
  gap: 9px;
  font-family: 'Press Start 2P', 'Courier New', monospace;
  font-size: 10px;
  line-height: 1.7;
  letter-spacing: 0;
  text-transform: uppercase;
  color: var(--green);
  margin: 0 0 14px;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-panel__body h4::before {
  content: '';
  flex: none;
  width: 0;
  height: 0;
  border-left: 7px solid var(--green-dot);
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
}

.doc-panel[data-doc-theme="boalbasaur"] .doc-lede {
  font-family: var(--display);
  font-weight: 600;
  font-size: 19px;
  line-height: 1.45;
  letter-spacing: -0.015em;
  color: var(--ink);
}

/* Bullets are leaves — the page's one signature shape, at list-marker size and
   tilted so they read as fallen rather than as a row of identical ticks. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-list {
  padding-left: 0;
  list-style: none;
  gap: 10px;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-list li {
  position: relative;
  padding-left: 24px;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.52em;
  width: 12px;
  height: 12px;
  margin-top: -6px;
  background: var(--leaf-b);
  border-radius: 0% 100% 0% 100%;
  transform: rotate(-24deg);
}
/* Same five tones as the litter line, on a period that never lines up with a
   three-item list — no section gets the same leaf twice. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-list li:nth-child(4n+2)::before { background: var(--leaf-d); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-list li:nth-child(4n+3)::before { background: var(--leaf-e); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-list li:nth-child(4n)::before   { background: var(--leaf-a); }

/* The design strip: the page's palette, then what it is built out of. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-strip {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 20px;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--surface);
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 9px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  /* An inset ring rather than a border: the bone-white chip is a shade off the
     card behind it and would otherwise have no edge at all. */
  box-shadow: inset 0 0 0 1px rgba(20, 20, 15, 0.12);
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(1) { background: var(--leaf-c); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(2) { background: var(--leaf-a); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(3) { background: var(--leaf-d); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(4) { background: var(--leaf-b); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(5) { background: var(--leaf-e); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(6) { background: var(--ink); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-swatches li:nth-child(7) { background: var(--retro-bg); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-strip__label {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--ink-muted);
  margin: 0;
}

/* --- The closing block, as the mascot's console --------------------------
   Same `double` border, bone background and monospace as .retro. Anything the
   theme sets on .doc-list above is re-set here, so these rules have to come
   after it: the two selectors weigh the same and source order decides. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-note {
  background: var(--retro-bg);
  border: 6px double var(--retro-ink);
  border-radius: 4px;
  padding: 20px 20px 22px;
  box-shadow: 0 12px 28px rgba(20, 20, 15, 0.12);
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-note h4 { color: var(--retro-ink); }
.doc-panel[data-doc-theme="boalbasaur"] .doc-note h4::before {
  border-left-color: var(--retro-ink);
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-note .doc-list li {
  padding-left: 20px;
  font-family: 'Courier New', monospace;
  font-size: 13.5px;
  line-height: 1.65;
  color: var(--retro-ink);
}
/* A leaf inside the console would be the one thing on it that is not typed, so
   the marker goes back to the caret the console's own rows use. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-note .doc-list li::before {
  content: '>';
  top: 0;
  width: auto;
  height: auto;
  margin-top: 0;
  background: none;
  border-radius: 0;
  transform: none;
  font-weight: 700;
  color: var(--retro-ink);
}

/* --- The litter line at the foot of the doc ------------------------------
   The leaves themselves are plain .leaf markup, so they inherit the page's
   shape, tones and alternation with no rules of their own. This only supplies
   the ground they lie on and bleeds the band out to the panel's edges — the
   negative margins exactly cancel .doc-panel__inner's padding, so it reaches
   them without overflowing. */
.doc-panel[data-doc-theme="boalbasaur"] .doc-litter {
  position: relative;
  flex: none;   /* a short flex item in a column, so it must not be shrunk */
  height: 54px;
  margin: 0 -36px -36px;
  overflow: hidden;
  overflow: clip;
}
.doc-panel[data-doc-theme="boalbasaur"] .doc-litter::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    180deg,
    oklch(0.93 0.05 150 / 0) 0%,
    oklch(0.91 0.055 150 / 0.5) 42%,
    oklch(0.89 0.06 148 / 0.85) 100%
  );
}

/* --- Doc panel: junkybox theme ------------------------------------------
   The app is near-black with a Spotify green, a vinyl record behind its
   sign-in and an equalizer under the wordmark, so the doc is too. It is the
   proof the theming is real: same panel, same markup, nothing in common with
   the theme above it.

   The tokens are declared on the themed panel rather than :root — they exist
   only while this doc is open, and cannot leak into the page's own palette.
   Values sampled from the running app. Type stays on Space Grotesk: it is
   already loaded, and it is close to the geometric sans the app brands with —
   not worth a third webfont on a site whose whole point is being fast. */
.doc-panel[data-doc-theme="junkybox"] {
  --jb-bg:        #0b0c0b;
  --jb-surface:   #141614;
  --jb-surface-2: #1d211c;
  --jb-line:      rgba(255, 255, 255, 0.09);
  --jb-green:     #2be36b;
  --jb-green-dim: #1c9c4c;
  --jb-ink:       #f1f4f0;
  --jb-ink-mid:   #b7bfb5;
  --jb-ink-soft:  #8b948a;

  /* The dark green wash bleeding out of the top corner is the app's own. */
  background:
    radial-gradient(120% 55% at 12% 0%, #15341e 0%, rgba(21, 52, 30, 0) 62%),
    var(--jb-bg);
}

/* The page's green is mixed for a bone-white background and goes muddy on
   near-black, so the focus ring switches to the app's own. */
.doc-panel[data-doc-theme="junkybox"] :focus-visible { outline-color: var(--jb-green); }

.doc-panel[data-doc-theme="junkybox"] .doc-panel__head {
  padding-bottom: 22px;
  border-bottom: 1px solid var(--jb-line);
}
.doc-panel[data-doc-theme="junkybox"] .doc-panel__eyebrow {
  font-family: var(--display);
  font-size: 11.5px;
  letter-spacing: 0.18em;
  color: var(--jb-green);
}
.doc-panel[data-doc-theme="junkybox"] .doc-panel__title { color: var(--jb-ink); }
.doc-panel[data-doc-theme="junkybox"] .doc-panel__close {
  background: var(--jb-surface-2);
  border: 1px solid var(--jb-line);
  border-radius: 10px;
  color: var(--jb-ink);
}
.doc-panel[data-doc-theme="junkybox"] .doc-panel__close:hover {
  background: var(--jb-surface);
  border-color: var(--jb-green);
  color: var(--jb-green);
}

.doc-panel[data-doc-theme="junkybox"] .doc-panel__body p { color: var(--jb-ink-mid); }
.doc-panel[data-doc-theme="junkybox"] .doc-lede {
  font-family: var(--display);
  font-weight: 600;
  font-size: 20px;
  line-height: 1.4;
  letter-spacing: -0.015em;
  color: var(--jb-ink);
}

/* Headings wear the app's equalizer as their marker. Three background layers,
   one per bar, each sized differently and anchored to the bottom — the same
   trick as a sprite, without the sprite. */
.doc-panel[data-doc-theme="junkybox"] .doc-panel__body h4 {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--display);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--jb-green);
  margin: 0 0 14px;
}
.doc-panel[data-doc-theme="junkybox"] .doc-panel__body h4::before {
  content: '';
  flex: none;
  width: 13px;
  height: 13px;
  background:
    linear-gradient(var(--jb-green), var(--jb-green)) 0    100% / 3px 55% no-repeat,
    linear-gradient(var(--jb-green), var(--jb-green)) 5px  100% / 3px 100% no-repeat,
    linear-gradient(var(--jb-green), var(--jb-green)) 10px 100% / 3px 35% no-repeat;
}

/* Bullets are the record itself: a groove ring with the green label at its
   centre, at list-marker size. */
.doc-panel[data-doc-theme="junkybox"] .doc-list {
  padding-left: 0;
  list-style: none;
  gap: 11px;
}
.doc-panel[data-doc-theme="junkybox"] .doc-list li {
  position: relative;
  padding-left: 26px;
  color: var(--jb-ink-mid);
}
.doc-panel[data-doc-theme="junkybox"] .doc-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.52em;
  width: 13px;
  height: 13px;
  margin-top: -6.5px;
  border-radius: 50%;
  border: 1px solid var(--jb-line);
  background:
    radial-gradient(circle, var(--jb-green) 0 30%, rgba(0, 0, 0, 0) 32%),
    #191919;
}

/* --- The strip: an equalizer, and what it runs on ----------------------- */
.doc-panel[data-doc-theme="junkybox"] .doc-strip {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 22px;
  border: 1px solid var(--jb-line);
  border-radius: 16px;
  background: var(--jb-surface);
}
.doc-panel[data-doc-theme="junkybox"] .doc-eq {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 34px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.doc-panel[data-doc-theme="junkybox"] .doc-eq li {
  flex: 1;
  min-width: 3px;
  max-width: 7px;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--jb-green), var(--jb-green-dim));
  /* Scaled from the bottom, so a bar grows the way a level meter does rather
     than floating. The height is the bar's ceiling; the animation rides it. */
  transform-origin: 50% 100%;
  animation: jb-eq 1.15s ease-in-out infinite alternate;
}
/* Seven heights and seven delays on co-prime-ish cycles, so no two neighbours
   move together and the row never visibly loops as a block. */
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+1) { height: 40%; animation-delay: -0.9s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+2) { height: 75%; animation-delay: -0.2s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+3) { height: 55%; animation-delay: -1.4s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+4) { height: 100%; animation-delay: -0.5s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+5) { height: 62%; animation-delay: -1.1s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n+6) { height: 34%; animation-delay: -0.35s; }
.doc-panel[data-doc-theme="junkybox"] .doc-eq li:nth-child(7n)   { height: 85%; animation-delay: -1.7s; }

@keyframes jb-eq {
  from { transform: scaleY(0.35); }
  to   { transform: scaleY(1); }
}

.doc-panel[data-doc-theme="junkybox"] .doc-strip__label {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--jb-ink-soft);
  margin: 0;
}
.doc-panel[data-doc-theme="junkybox"] .tech li {
  background: var(--jb-surface-2);
  border: 1px solid var(--jb-line);
  color: var(--jb-green);
}

/* Screenshots get the app's own framing: hairline border, dark mat, and enough
   radius to match its cards. The mat matters — these are dark UI shots on a
   dark panel, and without it their edges dissolve into the background. */
.doc-panel[data-doc-theme="junkybox"] .doc-shot img {
  border: 1px solid var(--jb-line);
  border-radius: 12px;
  background: var(--jb-surface);
}
.doc-panel[data-doc-theme="junkybox"] .doc-shot figcaption {
  color: var(--jb-ink-soft);
}
.doc-panel[data-doc-theme="junkybox"] .doc-gallery { gap: 22px; }

/* --- The closing block, as a highlighted track -------------------------- */
.doc-panel[data-doc-theme="junkybox"] .doc-note {
  position: relative;
  overflow: hidden;
  padding: 22px 22px 24px;
  border: 1px solid var(--jb-line);
  border-radius: 16px;
  background: var(--jb-surface);
}
.doc-panel[data-doc-theme="junkybox"] .doc-note::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: linear-gradient(180deg, var(--jb-green), rgba(43, 227, 107, 0));
}
.doc-panel[data-doc-theme="junkybox"] .doc-note h4 { color: var(--jb-green); }
.doc-panel[data-doc-theme="junkybox"] .doc-note p  { color: var(--jb-ink-mid); }

/* --- Records behind the doc ---------------------------------------------
   Layered exactly like .doc-leaves in the theme above — body is the stacking
   context, the deck sits at -1 inside it, and it clips at the panel's edges so
   a record is cut off by the panel instead of overflowing a box that scrolls. */
.doc-panel[data-doc-theme="junkybox"] .doc-panel__body {
  position: relative;
  z-index: 0;
}
.doc-panel[data-doc-theme="junkybox"] .doc-deck {
  position: absolute;
  inset: 0 -36px -36px;
  z-index: -1;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
.doc-vinyl {
  position: absolute;
  border-radius: 50%;
  /* Label over grooves: two gradients, no image. The groove pitch is fixed in
     px rather than a percentage, so every record has the same grain no matter
     how large it is drawn. */
  background:
    radial-gradient(circle, var(--jb-green) 0 9%, rgba(0, 0, 0, 0) 9.6%),
    radial-gradient(circle, #000 0 3%, rgba(0, 0, 0, 0) 3.4%),
    repeating-radial-gradient(circle, #202020 0 2px, #131313 2px 5px);
}
/* Each is pushed out far enough that its centre — and so the green label —
   clears the panel edge, leaving only grooves inside. Bring one in far enough
   to show its label and it stops reading as a record and starts reading as a
   stray green dot at the margin. */
.doc-panel[data-doc-theme="junkybox"] .doc-vinyl--a {
  top: 1%; right: -210px; width: 340px; height: 340px; opacity: 0.55;
}
.doc-panel[data-doc-theme="junkybox"] .doc-vinyl--b {
  top: 37%; left: -200px; width: 300px; height: 300px; opacity: 0.45;
}
.doc-panel[data-doc-theme="junkybox"] .doc-vinyl--c {
  top: 74%; right: -170px; width: 260px; height: 260px; opacity: 0.38;
}

/* --- Doc panel: betweenus theme -----------------------------------------
   A dark map, and the app's own furniture drawn in CSS: the target it marks
   the exact midpoint with, the pins it drops on people, the rings its search
   walks, and three travel times pulling level. Tokens sampled from the running
   app and declared on the themed panel, like the theme above, so they exist
   only while this doc is open.

   The three bullet colours are the first three of the app's participant
   palette, in its order — a reader who has used it recognises them as person
   one, two and three before reading a word. */
.doc-panel[data-doc-theme="betweenus"] {
  --bu-bg:        #0a0e14;
  --bu-surface:   #111620;
  --bu-surface-2: #161d29;
  --bu-line:      rgba(255, 255, 255, 0.08);
  --bu-line-mid:  rgba(255, 255, 255, 0.16);
  --bu-teal:      #2dd4bf;
  --bu-teal-soft: rgba(45, 212, 191, 0.1);
  --bu-teal-line: rgba(45, 212, 191, 0.4);
  --bu-amber:     #f5b642;
  --bu-violet:    #a78bfa;
  --bu-ink:       #e8edf4;
  --bu-ink-mid:   #93a1b5;
  --bu-ink-soft:  #5f6d80;

  /* The app's own glow, which sits over the meeting point rather than in a
     corner, so this one is centred too. */
  background:
    radial-gradient(105% 44% at 50% 0%, #11242f 0%, rgba(17, 36, 47, 0) 64%),
    var(--bu-bg);
}

.doc-panel[data-doc-theme="betweenus"] :focus-visible { outline-color: var(--bu-teal); }

.doc-panel[data-doc-theme="betweenus"] .doc-panel__head {
  padding-bottom: 22px;
  border-bottom: 1px solid var(--bu-line);
}
.doc-panel[data-doc-theme="betweenus"] .doc-panel__eyebrow {
  font-family: var(--display);
  font-size: 11.5px;
  letter-spacing: 0.18em;
  color: var(--bu-teal);
}
.doc-panel[data-doc-theme="betweenus"] .doc-panel__title { color: var(--bu-ink); }
.doc-panel[data-doc-theme="betweenus"] .doc-panel__close {
  background: var(--bu-surface-2);
  border: 1px solid var(--bu-line);
  border-radius: 10px;
  color: var(--bu-ink);
}
.doc-panel[data-doc-theme="betweenus"] .doc-panel__close:hover {
  background: var(--bu-surface);
  border-color: var(--bu-teal);
  color: var(--bu-teal);
}

.doc-panel[data-doc-theme="betweenus"] .doc-panel__body p { color: var(--bu-ink-mid); }
.doc-panel[data-doc-theme="betweenus"] .doc-lede {
  font-family: var(--display);
  font-weight: 600;
  font-size: 20px;
  line-height: 1.45;
  letter-spacing: -0.015em;
  color: var(--bu-ink);
}

/* Headings carry the target the app marks its exact midpoint with: a ring with
   its centre dotted. Two rings would be truer to the icon and unreadable at
   13px, so it keeps the outer one and the dot. */
.doc-panel[data-doc-theme="betweenus"] .doc-panel__body h4 {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--display);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--bu-teal);
  margin: 0 0 14px;
}
.doc-panel[data-doc-theme="betweenus"] .doc-panel__body h4::before {
  content: '';
  flex: none;
  width: 13px;
  height: 13px;
  border: 1px solid var(--bu-teal);
  border-radius: 50%;
  background: radial-gradient(circle, var(--bu-teal) 0 30%, rgba(0, 0, 0, 0) 31%);
}

/* Bullets are the map pins, in the palette's order. The teardrop is one corner
   of the border-radius left square and the whole thing turned 45deg, so the
   point lands at the bottom; the hole is where the app puts the person's
   number. */
.doc-panel[data-doc-theme="betweenus"] .doc-list {
  padding-left: 0;
  list-style: none;
  gap: 11px;
}
.doc-panel[data-doc-theme="betweenus"] .doc-list li {
  position: relative;
  padding-left: 26px;
  color: var(--bu-ink-mid);
}
.doc-panel[data-doc-theme="betweenus"] .doc-list li::before {
  content: '';
  position: absolute;
  left: 1px;
  top: 0.52em;
  width: 12px;
  height: 12px;
  margin-top: -7px;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  background:
    radial-gradient(circle, var(--bu-bg) 0 26%, rgba(0, 0, 0, 0) 27%),
    var(--bu-teal);
}
.doc-panel[data-doc-theme="betweenus"] .doc-list li:nth-child(3n+2)::before {
  background: radial-gradient(circle, var(--bu-bg) 0 26%, rgba(0, 0, 0, 0) 27%), var(--bu-amber);
}
.doc-panel[data-doc-theme="betweenus"] .doc-list li:nth-child(3n)::before {
  background: radial-gradient(circle, var(--bu-bg) 0 26%, rgba(0, 0, 0, 0) 27%), var(--bu-violet);
}

/* --- The strip: three trips, pulled level ------------------------------- */
.doc-panel[data-doc-theme="betweenus"] .doc-strip {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 22px;
  border: 1px solid var(--bu-line);
  border-radius: 16px;
  background: var(--bu-surface);
}
.doc-panel[data-doc-theme="betweenus"] .doc-legs {
  display: flex;
  flex-direction: column;
  gap: 9px;
  margin: 0;
  padding: 0;
  list-style: none;
}
/* The li is the track, its ::before the trip. Sitting the fill on top of a
   track is what makes a short bar read as a short trip rather than as a bar
   that has not finished drawing. */
.doc-panel[data-doc-theme="betweenus"] .doc-legs li {
  position: relative;
  height: 8px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.055);
}
.doc-panel[data-doc-theme="betweenus"] .doc-legs li::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--bu-teal);
  /* Grown from the left, so the bars share a start line and only their ends
     move — which is the whole comparison. */
  transform-origin: 0 50%;
  animation: bu-balance 3.4s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
}
.doc-panel[data-doc-theme="betweenus"] .doc-legs li:nth-child(2)::before { background: var(--bu-amber); }
.doc-panel[data-doc-theme="betweenus"] .doc-legs li:nth-child(3)::before { background: var(--bu-violet); }

/* Each bar starts at its own length, carried on --from in the markup, and they
   all end at the same one. No delays: they have to arrive together, or it reads
   as three bars animating instead of a gap closing. */
@keyframes bu-balance {
  from { transform: scaleX(var(--from, 0.5)); }
  to   { transform: scaleX(0.86); }
}

.doc-panel[data-doc-theme="betweenus"] .doc-strip__label {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--bu-ink-soft);
  margin: 0;
}
.doc-panel[data-doc-theme="betweenus"] .tech li {
  background: var(--bu-surface-2);
  border: 1px solid var(--bu-line);
  color: var(--bu-teal);
}

/* Same reasoning as the theme above: dark UI shots on a dark panel need a mat
   and a hairline, or their edges dissolve into the background. */
.doc-panel[data-doc-theme="betweenus"] .doc-shot img {
  border: 1px solid var(--bu-line);
  border-radius: 12px;
  background: var(--bu-surface);
}
.doc-panel[data-doc-theme="betweenus"] .doc-shot figcaption {
  color: var(--bu-ink-soft);
}
.doc-panel[data-doc-theme="betweenus"] .doc-gallery { gap: 22px; }

/* --- The closing block, as the answer card ------------------------------
   The app gives its chosen meeting point a teal card and the exact midpoint a
   dashed one, since the dash says provisional. This block is the conclusion,
   so it gets the solid one. */
.doc-panel[data-doc-theme="betweenus"] .doc-note {
  padding: 22px 22px 24px;
  border: 1px solid var(--bu-teal-line);
  border-radius: 16px;
  background: var(--bu-teal-soft);
}
.doc-panel[data-doc-theme="betweenus"] .doc-note h4 { color: var(--bu-teal); }
.doc-panel[data-doc-theme="betweenus"] .doc-note p  { color: var(--bu-ink-mid); }

/* --- The map behind the doc ---------------------------------------------
   Layered like .doc-leaves and .doc-deck before it: the body is the stacking
   context, this sits at -1 inside it, and it clips at the panel's edges.
   The graticule is two repeating gradients on the layer itself; the rings are
   the ones the search walks, dropping candidates around a centre and shrinking
   until the times stop moving. Each is pushed far enough out that the panel
   cuts it, the way the viewport cuts them in the app. */
.doc-panel[data-doc-theme="betweenus"] .doc-panel__body {
  position: relative;
  z-index: 0;
}
.doc-panel[data-doc-theme="betweenus"] .doc-atlas {
  position: absolute;
  inset: 0 -36px -36px;
  z-index: -1;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
  background:
    repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.026) 0 1px, rgba(0, 0, 0, 0) 1px 68px),
    repeating-linear-gradient(0deg,  rgba(255, 255, 255, 0.026) 0 1px, rgba(0, 0, 0, 0) 1px 68px);
}
.doc-ring {
  position: absolute;
  border: 1px dashed var(--bu-teal-line);
  border-radius: 50%;
}
/* The inner ring is the next round of the search: same centre, smaller radius.
   Inset as a percentage, so both rings shrink by the same ratio however large
   the outer one is drawn. */
.doc-ring::before {
  content: '';
  position: absolute;
  inset: 22%;
  border: 1px dashed var(--bu-line-mid);
  border-radius: 50%;
}
.doc-panel[data-doc-theme="betweenus"] .doc-ring--a {
  top: 2%; right: -170px; width: 320px; height: 320px; opacity: 0.5;
}
.doc-panel[data-doc-theme="betweenus"] .doc-ring--b {
  top: 39%; left: -160px; width: 280px; height: 280px; opacity: 0.42;
}
.doc-panel[data-doc-theme="betweenus"] .doc-ring--c {
  top: 76%; right: -140px; width: 240px; height: 240px; opacity: 0.34;
}

/* --- "You already there" ------------------------------------------------
   The full-screen console message behind this site's own card links. Same
   double-line frame and pixel type as the mascot's console, inverted onto a
   dark screen and blown up to fill the window. */
/* The page stays where it is and simply goes out of focus behind this. That is
   the whole point: you never left the site, so nothing here may look like a
   different screen — no takeover, no black, no chrome of its own. */
/* The `hidden` attribute only hides an element by way of a UA rule,
   `[hidden] { display: none }`, which *any* author-level `display` outweighs.
   So the `display: grid` below — there to centre the box — silently defeats it:
   the attribute goes on, the element stays on screen, and script that closes
   this by setting .hidden looks like it does nothing at all. This rule wins on
   specificity (class + attribute), so it holds wherever it sits in the file.
   Everything else here that uses `hidden` leaves display alone and needs no
   equivalent; anything new that sets one does. */
.arcade[hidden] { display: none; }

.arcade {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: grid;
  place-items: center;
  padding: 24px;
  cursor: pointer;
  /* Fallback first, for anything without backdrop-filter: a heavier wash of the
     page's own top-of-gradient green, which on its own is enough to push the
     page back and keep the box legible. */
  background: oklch(0.97 0.02 145 / 0.78);
  animation: arcade-fade 0.2s ease-out;
}

/* With blur available the wash gets thinner, because the blur is doing the
   work — together they read as the site going out of focus rather than as a
   sheet laid over it. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .arcade {
    background: oklch(0.97 0.02 145 / 0.45);
    -webkit-backdrop-filter: blur(7px);
    backdrop-filter: blur(7px);
  }
}

@keyframes arcade-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* The mascot's console, unchanged — same bone, same double line, same pop it
   uses when it opens beside the mascot. It is the site's existing way of
   saying something, so the easter egg borrows it rather than inventing one. */
.arcade__box {
  max-width: min(560px, 100%);
  padding: 26px clamp(20px, 4vw, 32px);
  text-align: center;
  background: var(--retro-bg);
  border: 6px double var(--retro-ink);
  border-radius: 4px;
  box-shadow: 0 16px 38px rgba(20, 20, 15, 0.18);
  animation: retro-pop 0.22s ease-out;
}

.arcade__line {
  margin: 0;
  font-family: 'Press Start 2P', 'Courier New', monospace;
  /* Press Start 2P is a wide face — at 25 characters this has to stay small
     enough on a phone to keep the line from breaking mid-word. */
  font-size: clamp(10px, 2.1vw, 14px);
  line-height: 1.9;
  color: var(--retro-ink);
}

.arcade__hint {
  margin: 20px 0 0;
  font-family: 'Press Start 2P', 'Courier New', monospace;
  font-size: clamp(7px, 1.3vw, 8.5px);
  line-height: 1.8;
  color: var(--green);
  animation: arcade-blink 1.1s steps(1) infinite;
}
@keyframes arcade-blink {
  0%, 60%   { opacity: 1; }
  61%, 100% { opacity: 0.15; }
}

/* --- Responsive ---------------------------------------------------------
   The grid steps itself down on its own — only the single-column break below
   is a decision the track sizing would not make by itself. */
@media (max-width: 760px) {
  :root { --gutter: 24px; }

  .site-header { padding: 24px var(--gutter); }
  .hero { padding: 40px var(--gutter) 96px; }
  .projects { padding: 0 var(--gutter) 100px; }
  .site-footer { padding: 0 var(--gutter) 48px; }

  .projects__grid { grid-template-columns: 1fr; }
  .card__shot { height: 220px; }
  .hero__sub { font-size: 17px; }
  .doc-panel__inner { padding: 28px 24px; }
  /* Keep cancelling the inner padding exactly — see the notes on .doc-litter
     and .doc-leaves. Left at the desktop values, both would reach past the
     panel edge and put a horizontal scrollbar on it. */
  .doc-panel[data-doc-theme="boalbasaur"] .doc-litter { margin: 0 -24px -28px; }
  .doc-panel[data-doc-theme="boalbasaur"] .doc-leaves { inset: 0 -24px -28px; }
  .doc-panel[data-doc-theme="junkybox"]   .doc-deck   { inset: 0 -24px -28px; }
  .doc-panel[data-doc-theme="betweenus"]  .doc-atlas  { inset: 0 -24px -28px; }

  /* No room to set a phone shot beside its caption on a narrow panel. */
  .doc-shot--phone {
    display: block;
  }
  .doc-shot--phone img { width: 160px; }
  .doc-shot--phone figcaption { margin-top: 9px; }

  /* No room for 290px beside a 96px mascot here, so the console drops into
     flow underneath it instead of being absolutely placed off its right edge.
     .mascot goes block so the panel gets the row to itself; the button keeps
     the mascot at its own size rather than stretching across it. */
  .mascot { display: block; }
  .retro {
    position: static;
    width: min(290px, 100%);
    margin-top: 20px;
  }
}

/* Touch devices have no hover, so surface both actions permanently. */
@media (hover: none) {
  .card__half {
    background: linear-gradient(0deg, rgba(20, 20, 15, 0.5), rgba(20, 20, 15, 0) 55%);
  }
  .card__action {
    opacity: 1;
    transform: none;
  }
  /* Both labels are on screen permanently here, and pixel type at the old 17px
     runs into the middle of the shot. 11px is as small as Press Start 2P goes
     before the counters close up. */
  .card__half--left .card__action  { font-size: 11px; }
  .card__half--right .card__action { font-size: 11px; }
  /* Nothing to slide toward without a pointer. */
  .card__url-arrow { transition: none; }
  /* No pointer to press it with, so the caret would never come up. */
  .card__url::before { display: none; }
  .projects__hint { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  /* Leaves keep their resting rotation — main.js never moves them here. */
  .blob, .blob > i, .mascot__frame { animation: none; }
  .blob, .mascot__tilt, .mascot__img { transform: none; transition: none; }
  /* main.js also skips the shake delay and types nothing — the console just
     appears with its text already in place. */
  .mascot__tilt.is-shaking, .retro, [data-type].is-typing::after { animation: none; }
  /* The equalizer holds at its bar heights instead of bouncing. */
  .doc-eq li { animation: none; }
  /* The three trips hold level, which is the state the animation was showing
     you anyway. Cancelling the animation alone would leave them at --from, so
     the balanced width is set here outright. */
  .doc-legs li::before { animation: none; transform: scaleX(0.86); }
  /* No fade, no pop, no blink — the message is simply there, with its line
     already typed. main.js skips the crawl to match. */
  .arcade, .arcade__box, .arcade__hint { animation: none; }
  /* main.js swaps the bubble's line outright here instead of fading it. */
  .mascot__note { transition: none; }
  [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .doc-overlay, .doc-panel { transition: none; }
}
