/* ---------------------------------------------------------------
   Scroll-reveal demo — reboot.studio-style word-by-word reading
   --------------------------------------------------------------- */

/* ---- typed custom properties -----------------------------------
   These three are written from JS on every animation frame: --w per word
   by paintWave (main.js), --logo-fill by the scroll loop, --sat per card
   by the carousel. Unregistered custom properties are parsed as untyped
   token streams *and they inherit*, so each write invalidated the whole
   subtree and forced the color-mix()/calc() that consumes it to be
   re-resolved from scratch. Declaring them typed and non-inheriting is
   what stops a per-frame write from dirtying anything but its own
   element. */
@property --w {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

@property --logo-fill {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

/* inherits: true, unlike the two above — and it matters. --sat is written on
   .carousel-item but read by .carousel-item img, a child, so the value has to
   reach it by inheritance. With inherits: false the img fell back to the
   initial value and every photo rendered permanently grayscale. The other two
   are written and read on the same element, so they can stay non-inheriting;
   the subtree under a carousel item is one figure and one img, so inheriting
   here costs nothing measurable. */
@property --sat {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

/* ---- the theme colours, registered so the flip can be animated ----
   These four are the whole light/dark palette, and every surface on the page
   is some expression of them. Registering them as <color> is what makes a
   theme change animatable *at the source*: an unregistered custom property
   is a token stream, so it can only ever snap, and each consumer had to
   carry its own transition to hide that — which is precisely why the flip
   used to arrive in pieces. body eased over 400ms, the phone's fade strips
   were gradients and couldn't ease at all, the panels had no transition and
   snapped, and the story's words snapped too, because both ends of their
   colour-mix are these variables.

   Animating the variables instead means there is one fade and everything is
   downstream of it. Nothing needs to opt in, including the words: their mix
   re-resolves each frame from the interpolating --ink, while --w keeps being
   written by the read-head on the same frame, untouched. That's the part a
   transition on .word could never do — a per-frame --w write would restart
   the colour interpolation every frame and drag the sweep behind the head.

   inherits: true, unlike --w and --logo-fill: these are read all over the
   tree from a single declaration on :root, which is the whole point. */
@property --bg {
  syntax: "<color>";
  inherits: true;
  initial-value: #f4f2ee;
}

@property --ink {
  syntax: "<color>";
  inherits: true;
  initial-value: #232323;
}

@property --ink-dim {
  syntax: "<color>";
  inherits: true;
  initial-value: rgba(35, 35, 35, 0.063);
}

@property --ink-soft {
  syntax: "<color>";
  inherits: true;
  initial-value: rgba(35, 35, 35, 0.45);
}

:root {
  --bg: #f4f2ee;
  --ink: #232323;
  --ink-dim: rgba(35, 35, 35, 0.063);
  --ink-soft: rgba(35, 35, 35, 0.45);
  --accent: #ff4d00;
  --word-fade: 500ms;
}

/* The fade itself, and the only place it is declared. Held by the theme
   toggle in index.html for exactly its duration, rather than left standing
   on :root — the boot script sets data-theme while the document is still
   parsing, and a standing transition would make a dark-mode visitor watch
   the page fade in from light on every load. */
html.is-theme-switching {
  transition: --bg 400ms ease,
              --ink 400ms ease,
              --ink-dim 400ms ease,
              --ink-soft 400ms ease;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  /* No background-color/color transition here on purpose: --bg and --ink are
     already interpolating during a flip (see @property above), so easing the
     resolved colour on top of an easing value would make this one surface
     lag everything that just reads the variable straight. */
}

/* The intro loader that used to sit here — the glyph-matrix "myang.dev" start
   screen — is in archive/ along with a note on restoring it. The transitions
   just below were its reveal choreography, and are kept because they're
   ordinary element transitions that still apply. */

.nav {
  transition: opacity 800ms ease 150ms;
}

.footer, .scroll-hint {
  transition: opacity 800ms ease 400ms;
}

.track {
  transition: opacity 900ms ease 50ms;
}

/* ---- first paint ------------------------------------------------
   The loader is gone but the page still shouldn't snap into place: the
   chrome starts a few pixels off and clear, and main.js drops .is-booting
   once the story is laid out and every other script has run. Opacity and
   transform only, both compositor properties, no blur or filter — so it is
   a fade, not a load screen, and there is no wait in front of it.

   .is-booting lives on <html> in the markup rather than being set by a
   script, because anything a script adds lands after first paint and the
   flash it is meant to hide has already happened by then. */
html.is-booting .nav,
html.is-booting .track,
html.is-booting .footer,
html.is-booting .scroll-hint {
  opacity: 0;
}

/* The offsets read as one gesture: the story rises, the chrome closes in on
   it from the top and bottom edges. Scaled to the durations below — over a
   slow move a very short travel reads as creeping rather than gliding, so
   these went up when the timing did. */
html.is-booting .track   { transform: translateY(14px); }
html.is-booting .nav     { transform: translateY(-11px); }
html.is-booting .footer  { transform: translateY(11px); }

/* hint-bob is an infinite transform on a fixed element; no reason to have it
   ticking under an invisible cue. */
html.is-booting .scroll-hint { animation: none; }

/* The globe joins the same gate. sphere.js reveals it on a timer of its own
   that starts when its script runs — earlier than this — so without being
   pinned here it could arrive on screen ahead of the story it belongs to.
   Both properties are held, not just opacity, so the drift in from 0.94
   still happens rather than being spent while invisible. Its own
   .is-entering survives the wait: sphere.js drops that class on the opacity
   transitionend, which cannot fire while the value is pinned. */
html.is-booting .sphere {
  opacity: 0;
  transform: scale(0.94);
}

/* ---- the reveal itself ------------------------------------------
   .is-revealing is added in the same frame .is-booting comes off, so these
   are the transitions the browser actually runs — the base rules above stay
   as they are for the section retreat, which wants different timing.

   One easing throughout (a decelerating ease-out that covers most of the
   distance early and settles slowly), staggered by delay rather than by
   duration so everything lands on the same curve. will-change is declared
   on both the booting and revealing states so the layers exist before the
   first animated frame instead of being promoted during it — that promotion
   mid-transition is what makes a fade hitch on its first few frames. */
html.is-booting .nav,
html.is-booting .track,
html.is-booting .footer,
html.is-booting .sphere,
html.is-revealing .nav,
html.is-revealing .track,
html.is-revealing .footer,
html.is-revealing .sphere {
  will-change: opacity, transform;
}

/* Transform runs longer than opacity on each element on purpose: the fade
   finishes while the element is still easing the last couple of pixels into
   place, so it arrives settling rather than stopping. */
html.is-revealing .track {
  transition: opacity 1000ms cubic-bezier(0.22, 1, 0.36, 1),
              transform 1250ms cubic-bezier(0.22, 1, 0.36, 1);
}

html.is-revealing .nav {
  transition: opacity 1000ms cubic-bezier(0.22, 1, 0.36, 1) 260ms,
              transform 1250ms cubic-bezier(0.22, 1, 0.36, 1) 260ms;
}

html.is-revealing .footer {
  transition: opacity 1000ms cubic-bezier(0.22, 1, 0.36, 1) 420ms,
              transform 1250ms cubic-bezier(0.22, 1, 0.36, 1) 420ms;
}

/* The cue is the last thing to arrive — it is an instruction, and it means
   more once there is something on screen to act on. Scoped to the reveal so
   the delay doesn't also apply to .is-hidden, which has to fade the moment
   the reader starts scrolling. */
html.is-revealing .scroll-hint {
  transition: opacity 1000ms cubic-bezier(0.22, 1, 0.36, 1) 720ms;
}

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

.nav {
  position: fixed;
  inset: 0 0 auto 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28px 32px;
  z-index: 10;
  pointer-events: none;
}

.nav-logo {
  font-weight: 700;
  font-size: 22px;
  letter-spacing: -0.02em;
  --logo-fill: 0%;
  background: linear-gradient(90deg,
    var(--ink) var(--logo-fill),
    var(--ink-dim) var(--logo-fill));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  color: transparent;
}

.nav-right {
  display: flex;
  align-items: center;
  gap: 12px;
  pointer-events: auto;
}

.nav-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  color: var(--ink);
  perspective: 200px;
  transition: background-color 300ms ease, color 300ms ease;
}

.nav-icon svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
  transition: transform 720ms cubic-bezier(0.16, 1, 0.3, 1);
  transform-style: preserve-3d;
}

.nav-icon:hover svg {
  transform: rotateY(360deg);
}

.nav-icon:hover {
  background: rgba(0, 0, 0, 0.06);
}

.nav-chip {
  pointer-events: auto;
  font-size: 12px;
  font-weight: 500;
  color: var(--ink);
  text-decoration: none;
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 18px;
  padding: 8px 14px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  transition: background-color 300ms ease, opacity 250ms ease, transform 250ms ease;
  background: transparent;
}

.nav-chip:hover { background: rgba(0, 0, 0, 0.04); }

.nav-chip.is-hidden {
  opacity: 0;
  transform: scale(0.9);
  pointer-events: none;
}

/* -- now playing: spotify logo that expands on hover -- */
.now-playing {
  display: flex;
  align-items: center;
  height: 34px;
  border-radius: 18px;
  border: 1px solid transparent;
  pointer-events: auto;
  cursor: default;
  perspective: 200px;
  overflow: hidden;
  transition: background-color 300ms ease, border-color 300ms ease;
}

.now-playing:hover {
  border-color: rgba(0, 0, 0, 0.12);
}


.spotify-logo {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  padding: 8px;
  box-sizing: content-box;
  color: var(--ink-soft);
  /* no filter in this list — nothing ever sets a filter on this element */
  transition: color 400ms ease;
}

.now-playing:not(.is-idle) .spotify-logo {
  color: var(--ink);
}

.np-expand {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  padding-right: 0;
  transition: max-width 400ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 300ms ease,
              padding-right 400ms ease;
}

.now-playing:hover .np-expand {
  max-width: 220px;
  opacity: 1;
  padding-right: 12px;
}

.np-expand-text {
  font-size: 11px;
  font-weight: 500;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  text-decoration: none;
  transition: opacity 200ms ease;
}

.np-expand-text:hover { opacity: 0.7; }


.now-playing.is-idle .now-playing-bars { display: none; }

.now-playing-bars {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 12px;
  flex-shrink: 0;
}

.now-playing-bars span {
  width: 2px;
  background: #1DB954;
  border-radius: 1px;
  animation: eq 1.2s ease-in-out infinite;
}

.now-playing-bars span:nth-child(1) { height: 40%; animation-delay: 0ms; }
.now-playing-bars span:nth-child(2) { height: 70%; animation-delay: 200ms; }
.now-playing-bars span:nth-child(3) { height: 50%; animation-delay: 400ms; }

@keyframes eq {
  0%, 100% { transform: scaleY(1); }
  50%      { transform: scaleY(0.3); }
}

.footer {
  position: fixed;
  inset: auto 0 0 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24px 32px;
  z-index: 10;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-soft);
  pointer-events: none;
}

/* The reading percentage is the only thing left down here, and
   space-between parks a lone child at the *start* — so it is pushed over
   rather than left to the justification, which no longer has two things to
   space apart. */
.footer-progress {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  display: inline-flex;
  align-items: baseline;
}

/* odometer: each digit is a masked 1em-tall window; a 0-9 strip
   inside rolls vertically to the target number with a spring. */
.odo-col {
  display: inline-block;
  width: 0.62em;
  height: 1em;
  overflow: hidden;
  vertical-align: baseline;
}

.odo-strip {
  display: flex;
  flex-direction: column;
  transform: translateY(0%);
  transition: transform 620ms cubic-bezier(0.22, 1.7, 0.36, 1);
  will-change: transform;
}

.odo-cell {
  height: 1em;
  line-height: 1;
  text-align: center;
}

.odo-pct { margin-left: 0.04em; }

@media (prefers-reduced-motion: reduce) {
  .odo-strip { transition-duration: 1ms; }
}

.scroll-hint {
  position: fixed;
  bottom: 48px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 15.6px;
  font-weight: 500;
  color: var(--ink-soft);
  z-index: 10;
  transition: opacity 800ms ease;
  animation: hint-bob 2.2s ease-in-out infinite;
}

/* animation: none, not just opacity: 0 — hint-bob is infinite, so hiding
   the cue with opacity alone left it ticking a transform on a fixed element
   for the rest of the session. */
.scroll-hint.is-hidden {
  opacity: 0;
  pointer-events: none;
  animation: none;
}

@keyframes hint-bob {
  0%, 100% { transform: translate(-50%, 0); }
  50%      { transform: translate(-50%, 5px); }
}

/* ---- the reading track ----------------------------------------- */

.track {
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
}

.viewport {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  padding: 80px 32px;
}

.prose {
  max-width: 780px;
  margin: 0;
  font-size: clamp(15px, 1.8vw, 20px);
  font-weight: 500;
  line-height: 1.7;
  letter-spacing: -0.005em;
  transition: transform 150ms ease-out;
  /* No will-change and no text-shadow here on purpose. paintWave rewrites
     --w (and therefore `color`) on dozens of the ~163 descendant words every
     frame, so promoting this element pinned a layer that then had to be
     fully re-rastered every frame anyway — the memory cost of a layer with
     none of the benefit. The text-shadow was a 0-blur 3% emboss that added a
     shadow pass per glyph to every one of those repaints. The tilt this
     transition serves is ±1deg, which needs neither. */
}

/* Every word is a span, inked by the read-head sweep in main.js:
   --w is 0 (not yet read) to 1 (settled), eased with smootherstep.
   Interpolating the colour through --w rather than toggling a class is
   what removes the hard per-word edge. Nothing moves — position and
   sharpness are left alone on purpose.

   Deliberately no transition: --w is written every frame and is already
   eased in JS, so a transition would only lag behind the head and smear. */
.word {
  --w: 0;
  color: color-mix(in oklab, var(--ink) calc(var(--w) * 100%), var(--ink-dim));
}

/* Fallback for engines without color-mix: keep the original binary fade
   rather than leaving every word stuck at the dim colour. */
@supports not (color: color-mix(in oklab, red 50%, blue)) {
  .word {
    color: var(--ink-dim);
    transition: color var(--word-fade) ease;
  }
  .word.is-active { color: var(--ink); }
}

/* Paragraph breaks: a full-width spacer inside the flow of words */
.para-break {
  display: block;
  height: 0.45em;
}

/* ---- inline word links (no pop-in; fade like normal words) ------ */
.word-link {
  display: inline-flex;
  align-items: center;
  gap: 0.22em;
  vertical-align: -0.12em;
  text-decoration: none;
  color: var(--ink-dim);
  opacity: 0.25;
  transition: opacity var(--word-fade) ease;
}

.word-link.is-active {
  opacity: 1;
  color: var(--ink);
}

/* ---- illini gradient ------------------------------------------- */
.word.illini.is-active {
  background: linear-gradient(90deg, #13294B, #FF552E);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  color: transparent;
}

[data-theme="dark"] .word.illini.is-active {
  background: linear-gradient(90deg, #4a7abf, #FF6B42);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

/* ---- inline animated tokens ------------------------------------
   Tokens start at width:0 and grow into the sentence when they
   activate — the surrounding words get pushed aside to make room,
   like reboot.studio's Rive icons. */

.token {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: -0.12em;
  position: relative;
  width: 0;
  margin: 0;
  overflow: hidden;
  opacity: 0;
  transform: scale(0.5);
  transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1),
    margin 600ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 450ms ease,
    transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}

.token.is-active {
  opacity: 1;
  transform: scale(1);
  overflow: visible;
}

.token svg {
  flex-shrink: 0;
}

/* -- roller: cycling word carousel -- */
.roller {
  display: inline-block;
  position: relative;
  height: 1.7em;
  vertical-align: bottom;
  overflow: hidden;
  transition: width 400ms cubic-bezier(0.16, 1, 0.3, 1);
}

.roller-word {
  position: absolute;
  left: 0;
  top: 0;
  height: 1.7em;
  display: flex;
  align-items: center;
  white-space: nowrap;
  font-weight: 600;
  color: var(--ink-dim);
  opacity: 0;
  transform: translateY(100%);
  transition: opacity 400ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 400ms cubic-bezier(0.16, 1, 0.3, 1);
}

.roller-word.is-visible {
  opacity: 1;
  transform: translateY(0);
}

.roller-word.is-out {
  opacity: 0;
  transform: translateY(-100%);
}

/* animated shimmer sweeping across green text once scrolled into view */
.roller.is-active .roller-word {
  background: linear-gradient(
    100deg,
    #4caf50 0%,
    #1b5e2f 25%,
    #6dd36f 50%,
    #1b5e2f 75%,
    #4caf50 100%
  );
  background-size: 250% 100%;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  color: transparent;
}

/* The gradient above stays on every word so one can't flash to solid ink
   part-way through rotating out. Only the animation is scoped, and only to
   the two words that can actually be seen: the one on screen and the one
   travelling off it. Animating background-position on a 250%-wide gradient
   clipped to glyphs regenerates the gradient and re-rasterizes the text
   mask every frame, and three of the five spans are fully transparent at
   any moment — that work was invisible by definition. */
.roller.is-active .roller-word.is-visible,
.roller.is-active .roller-word.is-out {
  animation: roller-shimmer 3.85s linear infinite;
}

@keyframes roller-shimmer {
  0%   { background-position: 0% 0; }
  100% { background-position: -250% 0; }
}

[data-theme="dark"] .roller.is-active .roller-word {
  background: linear-gradient(
    100deg,
    #66d16b 0%,
    #2e8b45 25%,
    #8ce88f 50%,
    #2e8b45 75%,
    #66d16b 100%
  );
  background-size: 250% 100%;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

/* -- wave: a hand that waves when activated -- */
.token-wave {
  height: 1.1em;
  font-size: 0.9em;
  transform-origin: 70% 80%;
}

.token-wave.is-active {
  width: 1.3em;
  margin: 0 0.1em;
  animation: wave 900ms ease-in-out 2;
}

@keyframes wave {
  0%, 100% { transform: rotate(0deg); }
  25%      { transform: rotate(18deg); }
  60%      { transform: rotate(-12deg); }
}

/* -- spark: a little star that spins in -- */
.token-spark {
  height: 1em;
  color: var(--accent);
}

.token-spark svg { width: 1em; height: 1em; }

.token-spark.is-active {
  width: 1.1em;
  margin: 0 0.1em;
  animation: spark-in 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes spark-in {
  0% { transform: scale(0) rotate(-90deg); }
  100% { transform: scale(1) rotate(0); }
}

/* -- mh3d: company name as a styled link -- */

.mh3d-logo {
  font-weight: 700;
  letter-spacing: -0.02em;
  transition: filter 200ms ease;
}

.token-mh3d.is-active .mh3d-logo {
  color: #4a8ad4;
  -webkit-text-fill-color: #4a8ad4;
  -webkit-text-stroke: 0.6px #FFCB05;
  paint-order: stroke fill;
}

[data-theme="dark"] .token-mh3d.is-active .mh3d-logo {
  color: #4a8ad4;
  -webkit-text-fill-color: #4a8ad4;
  -webkit-text-stroke: 0.6px #FFCB05;
}

.token-mh3d:hover .mh3d-logo { filter: brightness(1.6); }

/* -- geni: lavender text -- */
.geni-name {
  font-weight: 700;
  letter-spacing: -0.01em;
  transition: filter 200ms ease;
}

.token-geni.is-active .geni-name {
  color: #8B5CF6;
  -webkit-text-fill-color: #8B5CF6;
}

.token-geni:hover .geni-name { filter: brightness(1.2); }

/* -- cap: graduation cap with swinging tassel -- */
.token-cap {
  height: 1.1em;
  color: var(--ink);
}

.word-link.is-active .token-cap,
.token-cap.is-active {
  width: 1.3em;
  margin: 0 0.12em;
  opacity: 1;
  transform: scale(1);
}

.token-cap svg { width: 1.2em; height: 1.2em; }

.cap-tassel, .cap-bob {
  transform-origin: 20px 10px;
}

.word-link.is-active .cap-tassel,
.word-link.is-active .cap-bob {
  animation: tassel-swing 2.5s ease-in-out infinite;
}

@keyframes tassel-swing {
  0%   { transform: rotate(0deg); }
  25%  { transform: rotate(8deg); }
  50%  { transform: rotate(0deg); }
  75%  { transform: rotate(-6deg); }
  100% { transform: rotate(0deg); }
}

/* -- fahnestock: grey gradient -- */
.fahnestock-name {
  font-weight: 700;
  letter-spacing: -0.01em;
  transition: filter 200ms ease;
}

.token-fahnestock.is-active .fahnestock-name {
  background: linear-gradient(90deg, #4a4a4a, #8a8a8a, #5a5a5a);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  color: transparent;
}

.token-fahnestock:hover .fahnestock-name { filter: brightness(1.3); }

/* -- amazon: orange text with delivery truck to the left -- */
.amazon-name {
  font-weight: 700;
  transition: filter 200ms ease;
}

.token-amazon.is-active .amazon-name {
  color: #FF9900;
  -webkit-text-fill-color: #FF9900;
}

.token-amazon:hover .amazon-name { filter: brightness(1.15); }

/* truck icon */
.token-truck {
  height: 1.2em;
  color: var(--ink);
}

.token-truck svg { width: 1.8em; height: 1.2em; overflow: visible; }

.word-link.is-active .token-truck,
.token-truck.is-active {
  width: 1.6em;
  margin: 0 0 0 0.08em;
  opacity: 1;
  transform: scale(1);
}

.word-link.is-active .truck-wheel-spoke,
.word-link.is-active .truck-wheel-spoke2 {
  animation: wheel-spin 0.8s linear infinite;
  transform-origin: center;
}

.word-link.is-active .truck-wheel-spoke {
  transform-origin: 8px 17px;
}

.word-link.is-active .truck-wheel-spoke2 {
  transform-origin: 23px 17px;
}

@keyframes wheel-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

.truck-lines { opacity: 0; }

.word-link.is-active .truck-lines {
  animation: speed-lines 1.5s ease-in-out infinite;
}

@keyframes speed-lines {
  0%, 100% { opacity: 0; transform: translateX(0); }
  50%      { opacity: 0.7; transform: translateX(-2px); }
}

.truck-smile {
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
}

.truck-smile-arrow {
  opacity: 0;
}

.word-link.is-active .truck-smile {
  animation: truck-smile-draw 600ms ease 400ms forwards;
}

.word-link.is-active .truck-smile-arrow {
  animation: truck-arrow-pop 200ms ease 900ms forwards;
}

@keyframes truck-smile-draw {
  to { stroke-dashoffset: 0; }
}

@keyframes truck-arrow-pop {
  from { opacity: 0; transform: scale(0.5); }
  to   { opacity: 1; transform: scale(1); }
}


/* -- vault: grey-gold gradient wordmark -- */
.vault-name {
  font-weight: 700;
  letter-spacing: -0.01em;
  transition: filter 200ms ease;
}

.token-vault.is-active .vault-name {
  color: #E6B800;
  -webkit-text-fill-color: #E6B800;
}

[data-theme="dark"] .token-vault.is-active .vault-name {
  color: #E6B800;
  -webkit-text-fill-color: #E6B800;
}

.token-vault:hover .vault-name { filter: brightness(1.25); }

/* -- box: a 3D package icon that rotates -- */
.token-box {
  height: 1.1em;
  color: var(--ink);
  vertical-align: -0.2em;
}

.token-box.is-active {
  width: 1.3em;
  margin: 0 0.15em;
  animation: box-float 2.5s ease-in-out infinite;
}

.token-box svg { width: 1.2em; height: 1.2em; }

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

/* -- bridge: golden-gate-style suspension bridge -- */
.token-bridge {
  height: 1.1em;
  color: var(--ink);
  vertical-align: -0.2em;
}

.token-bridge.is-active {
  width: 2.2em;
  margin: 0 0.1em;
}

.token-bridge svg { width: 2.1em; height: 1.1em; }

.bridge-tower, .bridge-tower-brace {
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
}

.bridge-cable-main {
  stroke-dasharray: 60;
  stroke-dashoffset: 60;
}

.bridge-hanger {
  stroke-dasharray: 8;
  stroke-dashoffset: 8;
}

.bridge-truss {
  stroke-dasharray: 6;
  stroke-dashoffset: 6;
}

.bridge-deck-lower {
  stroke-dasharray: 50;
  stroke-dashoffset: 50;
}

.token-bridge.is-active .bridge-deck {
  stroke-dasharray: 50;
  stroke-dashoffset: 50;
  animation: bridge-draw 600ms cubic-bezier(0.33, 1, 0.68, 1) forwards;
}

.token-bridge.is-active .bridge-tower {
  animation: bridge-draw 500ms cubic-bezier(0.33, 1, 0.68, 1) 200ms forwards;
}

.token-bridge.is-active .bridge-tower-brace {
  animation: bridge-draw 300ms cubic-bezier(0.33, 1, 0.68, 1) 500ms forwards;
}

.token-bridge.is-active .bridge-cable-main {
  animation: bridge-draw 800ms cubic-bezier(0.33, 1, 0.68, 1) 400ms forwards;
}

.token-bridge.is-active .bridge-hanger {
  animation: bridge-draw 400ms cubic-bezier(0.33, 1, 0.68, 1) 800ms forwards;
}

.token-bridge.is-active .bridge-truss {
  animation: bridge-draw 300ms cubic-bezier(0.33, 1, 0.68, 1) 600ms forwards;
}

.token-bridge.is-active .bridge-deck-lower {
  animation: bridge-draw 600ms cubic-bezier(0.33, 1, 0.68, 1) 500ms forwards;
}

.bridge-endcap {
  stroke-dasharray: 5;
  stroke-dashoffset: 5;
}

.token-bridge.is-active .bridge-endcap {
  animation: bridge-draw 300ms cubic-bezier(0.33, 1, 0.68, 1) 900ms forwards;
}

@keyframes bridge-draw {
  to { stroke-dashoffset: 0; }
}

/* -- scanner: a frame with a sweeping beam and detection blip -- */
.token-scanner {
  height: 1.1em;
  color: var(--ink);
}

.word-link.is-active .token-scanner,
.token-scanner.is-active {
  width: 1.3em;
  margin: 0 0.08em;
  opacity: 1;
  transform: scale(1);
}

.token-scanner svg { width: 1.2em; height: 1.2em; }

.scanner-beam {
  transform-origin: center center;
  animation: none;
}

.word-link.is-active .scanner-beam {
  animation: beam-sweep 2s ease-in-out infinite;
}

.scanner-blip {
  opacity: 0.2;
  transition: opacity 150ms ease;
}

.word-link.is-active .scanner-blip {
  animation: blip-detect 2s ease-in-out infinite;
}

@keyframes beam-sweep {
  0%   { transform: translateY(-4px); }
  50%  { transform: translateY(4px); }
  100% { transform: translateY(-4px); }
}

@keyframes blip-detect {
  0%, 35%  { opacity: 0.2; fill: currentColor; }
  45%, 55% { opacity: 1; fill: #e53935; }
  65%, 100% { opacity: 0.2; fill: currentColor; }
}


/* -- camera: a camera that flashes -- */
.token-camera {
  height: 1.1em;
  color: var(--ink);
}

.word-link.is-active .token-camera,
.token-camera.is-active {
  width: 1.3em;
  margin: 0 0.08em;
  opacity: 1;
  transform: scale(1);
}

.token-camera svg { width: 1.2em; height: 1.2em; }

.word-link.is-active .token-camera svg,
.token-camera.is-active svg {
  animation: camera-flash 3s ease-in-out infinite;
}

/* No `filter: none` in here. It was visually a no-op, but naming filter in
   a keyframe makes it an animated property for the whole 3s infinite cycle,
   which promotes the element and drops subpixel antialiasing on it. */
@keyframes camera-flash {
  0%, 100% { opacity: 1; transform: scale(1); }
  15%      { opacity: 1; transform: scale(1.05); }
  18%      { opacity: 0.3; }
  22%      { opacity: 1; transform: scale(1); }
}

[data-theme="dark"] .word-link.is-active .token-camera svg,
[data-theme="dark"] .token-camera.is-active svg {
  animation: camera-flash-dark 3s ease-in-out infinite;
}

@keyframes camera-flash-dark {
  0%, 14%  { opacity: 1; transform: scale(1); color: var(--ink); }
  16%      { opacity: 1; transform: scale(1.05); color: #d4d4d4; }
  20%      { opacity: 0.85; color: #d4d4d4; }
  24%      { opacity: 1; transform: scale(1); color: var(--ink); }
  100%     { opacity: 1; transform: scale(1); color: var(--ink); }
}

/* -- bars: a tiny bar chart that bounces -- */
.token-bars {
  height: 0.9em;
  gap: 2px;
  align-items: flex-end;
}

.token-bars.is-active {
  width: 1.4em;
  margin: 0 0.12em;
}

.token-bars span {
  flex: 1;
  background: var(--accent);
  border-radius: 2px;
  height: 30%;
}

.token-bars.is-active span {
  animation: bar-grow 1.1s ease-in-out infinite alternate;
}

.token-bars.is-active span:nth-child(1) { animation-delay: 0ms; }
.token-bars.is-active span:nth-child(2) { animation-delay: 150ms; }
.token-bars.is-active span:nth-child(3) { animation-delay: 300ms; }

@keyframes bar-grow {
  from { height: 30%; }
  to   { height: 100%; }
}

/* -- underline link, like reboot's "Book a call" -- */
.cta {
  position: relative;
  color: inherit;
  text-decoration: none;
  white-space: nowrap;
}

.cta::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  height: 3px;
  width: 0;
  background: var(--ink);
  transition: width 600ms ease;
}

.cta.is-active::after { width: 100%; }

.cta:hover::after { width: 100%; }

/* underline is held at full width while the work box is open;
   released (and allowed to retract) only after the box is gone */
.cta.is-held::after { width: 100%; }



/* ---- theme toggle button ---------------------------------------- */

.theme-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  border-radius: 50%;
  cursor: pointer;
  color: var(--ink);
  position: relative;
  transition: background-color 300ms ease;
}

.theme-toggle:hover { background: rgba(0, 0, 0, 0.06); }

/* -- the phone's "more" button: hidden until the phone layout reveals it -- */
.nav-menu-btn {
  display: none;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  pointer-events: auto;
  color: var(--ink);
  padding: 0;
  transition: background-color 300ms ease;
}

[data-theme="dark"] .nav-menu-btn { border-color: rgba(255, 255, 255, 0.15); }

.nav-menu-btn.is-open { background: rgba(0, 0, 0, 0.05); }

[data-theme="dark"] .nav-menu-btn.is-open { background: rgba(255, 255, 255, 0.08); }

/* Three bars that fold into a cross. Absolute rather than a flex column so
   the two that survive can rotate about the middle one's line without the
   layout moving under them. */
.nav-menu-bars {
  position: relative;
  display: block;
  width: 15px;
  height: 11px;
}

.nav-menu-bars span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 1.6px;
  border-radius: 1px;
  background: var(--ink);
  transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 180ms ease;
}

.nav-menu-bars span:nth-child(1) { top: 0; }
.nav-menu-bars span:nth-child(2) { top: 4.7px; }
.nav-menu-bars span:nth-child(3) { top: 9.4px; }

.nav-menu-btn.is-open .nav-menu-bars span:nth-child(1) {
  transform: translateY(4.7px) rotate(45deg);
}

.nav-menu-btn.is-open .nav-menu-bars span:nth-child(2) { opacity: 0; }

.nav-menu-btn.is-open .nav-menu-bars span:nth-child(3) {
  transform: translateY(-4.7px) rotate(-45deg);
}

.theme-icon {
  width: 18px;
  height: 18px;
  position: absolute;
  transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 350ms ease;
}

.theme-icon.sun {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}

.theme-icon.moon {
  opacity: 0;
  transform: rotate(-90deg) scale(0.5);
}

[data-theme="dark"] .theme-icon.sun {
  opacity: 0;
  transform: rotate(90deg) scale(0.5);
}

[data-theme="dark"] .theme-icon.moon {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}

.sun-rays {
  transform-origin: 12px 12px;
  transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}

[data-theme="dark"] .sun-rays {
  transform: rotate(45deg) scale(0);
}

/* ---- dark mode -------------------------------------------------- */

[data-theme="dark"] {
  --bg: #1e1e1e;
  --ink: #e8e6e3;
  --ink-dim: rgba(232, 230, 227, 0.08);
  --ink-soft: rgba(232, 230, 227, 0.5);
}

[data-theme="dark"] body {
  background: var(--bg);
  color: var(--ink);
}

[data-theme="dark"] .theme-toggle:hover {
  background: rgba(255, 255, 255, 0.08);
}

[data-theme="dark"] .nav-icon:hover {
  background: rgba(255, 255, 255, 0.08);
}

[data-theme="dark"] .nav-chip {
  color: var(--ink);
  border-color: rgba(255, 255, 255, 0.15);
}

[data-theme="dark"] .nav-chip:hover {
  background: rgba(255, 255, 255, 0.06);
}

[data-theme="dark"] .now-playing:hover {
  border-color: rgba(255, 255, 255, 0.15);
}

/* ---- the globe (right column) -----------------------------------
   The nine project objects orbiting on a Fibonacci sphere. Tiles are
   positioned in 3D by sphere.js but never rotated (billboarded), so every
   drawing stays flat and upright — that's what gives the clean graphic read.

   Loudness, quietest state last:
     (default)     drifting, objects in colour
     is-quiet      reading is underway — everything greyscale, colour only
                   under the cursor
     is-focusing   an object has your attention: the rest of the globe recedes
     is-zoomed     that object is flying to the middle and its card is opening
     is-dimmed     the resume panel or a section has taken the column

   Note that sphere.js writes each tile's `opacity` inline every frame from its
   depth, so no rule here can dim a tile with the opacity property — the state
   rules go through filter's opacity() instead, which JS never touches. */
.sphere {
  position: fixed;
  top: 0;
  bottom: 0;
  /* start clear of the prose column (32px pad + 780px), capped so it
     doesn't drift too far away on ultra-wide screens */
  left: clamp(812px, 54vw, 900px);
  right: 0;
  display: grid;
  place-items: center;
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  transform: scale(0.94);
  /* The return trip. Long, heavily eased-out beat so the globe drifts back
     into place rather than popping in — it should feel like it was always
     there and you simply noticed it again.

     Deliberately only opacity and transform: both are compositor-friendly on
     a layer this size, where animating the whole globe's `filter` was not. */
  transition: opacity 2200ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 2400ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* first-load entrance: faster, drifts in from slightly smaller */
.sphere.is-entering {
  transition: opacity 1100ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 1300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.sphere.is-in {
  opacity: 1;
  transform: scale(1);
}

/* Once reading starts the globe settles back so it stops competing with the
   prose — quieter and very slightly smaller, but still present. Declared
   before .is-dimmed so that the panel state (equal specificity) wins on
   source order when both are active. */
.sphere.is-quiet {
  opacity: 0.2;
  transform: scale(0.97);
  transition: opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Reaching for an object overrides the reading quiet: you're looking at the
   globe now, so it comes back up to full strength. */
.sphere.is-quiet.is-focusing,
.sphere.is-quiet.is-zoomed {
  opacity: 1;
  transform: scale(1);
  transition: opacity 380ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 520ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* The globe steps aside for the resume panel / full-screen sections instead of
   competing with them: it fades out completely while contracting, so it reads
   as receding into depth rather than merely dimming. Quicker to leave than to
   return — it gets out of the panel's way promptly, then takes its time coming
   home (sphere.js holds the return until the panel has finished leaving).

   This used to add a 6px blur. Two problems: blurring a 661px layer with nine
   filtered children is the most expensive thing on the page, and the state it
   returns to (.is-quiet) doesn't transition `filter` — so the blur *snapped*
   off in a single frame, re-rasterising the whole globe right as the section
   was sliding away. That was the stutter. Opacity and scale read the same. */
.sphere.is-dimmed {
  opacity: 0;
  transform: scale(0.92);   /* pulls back into the page, doesn't slide off it */
  transition: opacity 420ms cubic-bezier(0.4, 0, 1, 1),
              transform 520ms cubic-bezier(0.4, 0, 1, 1);
}

.sphere-stage {
  position: relative;
  user-select: none;
  -webkit-user-select: none;
  /* wide enough for the 207px radius plus scaled-up tile overhang
     (furthest tile edge lands near 251px, so this leaves headroom) */
  width: 661px;
  height: 661px;
  /* No perspective / preserve-3d. sphere.js writes translate3d(x, y, 0) —
     z is always zero, and the depth read is faked in JS by scaling each
     tile (see place()). So the 3D rendering context changed nothing
     visually, while disabling flattening for ~156 SVG nodes and making the
     compositor depth-sort nine layers a frame against the z-index that the
     same loop was already writing. */
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
}
.sphere.is-grabbed .sphere-stage { cursor: grabbing; }
/* an open card owns the column; nothing behind it should take a drag */
.sphere.is-card .sphere-stage { pointer-events: none; }

/* ---- the objects on the globe ----
   Line art, not photographs: no tile chrome and no crop. The stroke takes the
   page's ink colour, so an object reads as a drawing sitting in the volume
   rather than a picture of one. */
.sphere-item {
  position: absolute;
  left: 50%;
  top: 50%;
  /* JS writes translate3d + scale, plus margin-left/-top to centre the tile
     on its point */
  will-change: transform, opacity;
  aspect-ratio: 1;
  overflow: visible;          /* strokes can sit right on the frame */
  color: var(--ink);
  cursor: pointer;
  transition: filter 420ms ease;
  /* Dragging the globe used to leave the browser's blue selection sitting
     across the drawings — there is no text here to select, so nothing is lost
     by taking the gesture out of selection entirely. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

.sphere-item svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;       /* the tile takes the hit, not the strokes */
  /* the object leans towards you as it takes focus. The tile's own transform
     belongs to the globe, so the lift lives on the drawing inside it. */
  transition: transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
}

.sphere-item.is-focused svg {
  transform: scale(1.12);
}

.sphere-item:hover,
.sphere-item:focus {
  outline: none;              /* the page blur is the hover feedback */
}

.sphere-item:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* ---- states ----
   Ordered by specificity on purpose: quiet (3) < focusing (4) < the focused
   object (5), so each state can only be overridden by a louder one. */
.sphere.is-quiet .sphere-item {
  filter: grayscale(1) contrast(1.08);
}

.sphere.is-quiet .sphere-item:hover {
  filter: grayscale(0) contrast(1.05);
}

/* everything that isn't the object you're pointing at recedes: greyscale,
   soft, and mostly out of the way. Blur is deliberately small — the globe
   should still read as a globe. */
.sphere.is-focusing .sphere-item:not(.is-focused),
.sphere.is-card .sphere-item:not(.is-flying) {
  filter: grayscale(1) blur(2.5px) opacity(0.32);
}

.sphere.is-focusing .sphere-item.is-focused,
.sphere.is-quiet.is-focusing .sphere-item.is-focused {
  filter: none;               /* the one thing on the page in full colour */
}

/* motion in a blurred layer is what makes this kind of effect feel noisy */
.sphere.is-focusing .sphere-item:not(.is-focused) * {
  animation-play-state: paused;
}

/* ---- click-through: the object flies to the middle ----
   The tile keeps its inline transform; all this adds is the curve to travel
   along, plus a fade for the tiles it leaves behind. */
.sphere.is-zoomed .sphere-item {
  transition: opacity 380ms ease, filter 380ms ease;
}

.sphere.is-zoomed .sphere-item.is-flying,
.sphere-item.is-flying {
  transition: transform 620ms cubic-bezier(0.4, 0, 0.2, 1),
              opacity 460ms ease 120ms;
}

/* ---- the hovered object's name ----
   Sits directly under whichever object has focus; sphere.js writes left/top
   from that tile's box each time. Safe to pin to the tile because the drift
   holds still while you're pointing at one — otherwise the label would trail a
   moving target. */
.sphere-label {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2100;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, 8px);
  transition: opacity 240ms ease,
              transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform, opacity;
}

.sphere-label.is-visible {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* mask window: the name rises up into it, clipped, so it reveals rather
   than fades */
.sphere-label-text {
  display: block;
  overflow: hidden;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  text-align: center;
  color: var(--ink);
}

.sphere-label-inner {
  display: block;
  transform: translateY(105%);
  transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1) 60ms;
}

.sphere-label.is-visible .sphere-label-inner {
  transform: translateY(0);
}

/* ---- the card an object opens into ----
   Centred on the globe it came out of, and allowed to be taller than the
   artwork ever was — the longest entries are a screen of text. */
.sphere .desk-detail {
  position: absolute;
  top: 50%;
  left: 50%;
  right: auto;
  bottom: auto;
  z-index: 2200;
  width: min(84%, 500px);
  max-height: min(72vh, 540px);
  transform: translate(-50%, -50%) translateY(14px);
}

.sphere .desk-detail.is-visible {
  transform: translate(-50%, -50%) translateY(0);
}

/* Hide when there isn't room beside the prose column. The sphere starts at
   812px (32px pad + 780px prose), so the stage needs 812 + 661 = 1473px of
   viewport to sit uncropped — round up rather than let it clip. */
@media (max-width: 1500px) {
  .sphere { display: none; }
}

/* the artwork's own coordinate space; the <template> never renders */
.desk-svg {
  width: 100%;
  height: 100%;
  color: var(--ink);
  transform-origin: center center;
}



/* gentle idle animations */
.coffee-steam {
  animation: steam-rise 2.6s ease-in-out infinite;
}

@keyframes steam-rise {
  0%, 100% { opacity: 0.15; transform: translateY(0); }
  50%      { opacity: 0.5; transform: translateY(-2px); }
}

.desk-sun {
  animation: sun-glow 4s ease-in-out infinite;
}

@keyframes sun-glow {
  0%, 100% { opacity: 0.7; }
  50%      { opacity: 1; }
}

.desk-cloud {
  animation: cloud-drift 9s ease-in-out infinite;
}

@keyframes cloud-drift {
  0%, 100% { transform: translateX(0); }
  50%      { transform: translateX(8px); }
}

/* the card an object opens into, once it has flown to the middle of the
   globe. Positioning is set by the .sphere rules above; this is its
   composition. */
.desk-detail {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  padding: 8px 12px 16px;
  opacity: 0;
  transform: translateY(14px);
  pointer-events: none;
  transition: opacity 350ms ease, transform 400ms cubic-bezier(0.16, 1, 0.3, 1);
}

.desk-detail.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.desk-back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  align-self: flex-start;
  border: none;
  background: none;
  padding: 6px 0;
  margin-bottom: 14px;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink-soft);
  cursor: pointer;
  transition: color 200ms ease, transform 200ms ease;
}

.desk-back:hover {
  color: var(--ink);
  transform: translateX(-3px);
}

.desk-detail-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 12px;
}

.desk-detail .desk-card-name { font-size: 20px; }
.desk-detail .desk-card-chips { margin-bottom: 18px; }
/* the longest cards (amazon, risc-v) outrun the column, so the body scrolls
   inside the card — main.js lets the wheel through while the pointer is in it */
.desk-detail .desk-card-desc {
  font-size: 13px;
  line-height: 1.7;
  flex: 1;
  min-height: 0;
  margin-bottom: 0;
  white-space: pre-line;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: 6px;
}
.desk-detail .desk-card-link { margin-top: 16px; align-self: flex-start; }

.desk-card-name {
  font-weight: 600;
  font-size: 13px;
}

.desk-card-date {
  font-size: 10px;
  color: var(--ink-soft);
}

.desk-card-desc {
  font-size: 11px;
  line-height: 1.5;
  color: var(--ink-soft);
  margin: 0 0 8px;
}

.desk-card-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-bottom: 8px;
}

.desk-card-chips span {
  font-size: 9px;
  font-weight: 500;
  padding: 3px 8px;
  border-radius: 10px;
  border: 1px solid rgba(0, 0, 0, 0.1);
  color: var(--ink);
}

.desk-card-link {
  font-size: 11px;
  font-weight: 600;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1.5px solid var(--ink);
  padding-bottom: 1px;
  transition: opacity 200ms ease;
}

.desk-card-link:hover { opacity: 0.6; }

.scanner-sweep {
  animation: scanner-spin 2.5s linear infinite;
  transform-origin: 260px 152px;
}

@keyframes scanner-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

/* phone: app grid scrolls up and loops */
.phone-apps g {
  animation: apps-scroll 6s cubic-bezier(0.45, 0, 0.25, 1) infinite;
}

@keyframes apps-scroll {
  0%, 18%   { transform: translateY(0); }
  30%, 48%  { transform: translateY(-13px); }
  60%, 78%  { transform: translateY(-26px); }
  90%, 100% { transform: translateY(-52px); }
}

/* workbook: writing lines redraw themselves */
.book-write-line {
  stroke-dasharray: 60;
  stroke-dashoffset: 0;
  animation: write-line 5s ease-in-out infinite;
}

.book-write-line2 { animation-delay: 600ms; }

@keyframes write-line {
  0%, 55%  { stroke-dashoffset: 0; }
  60%      { stroke-dashoffset: 60; opacity: 0; }
  61%      { stroke-dashoffset: 60; opacity: 0.4; }
  95%, 100% { stroke-dashoffset: 0; }
}

/* vr: lenses glow alternately */
.vr-glow {
  animation: lens-glow 2.8s ease-in-out infinite;
}

.vr-glow2 { animation-delay: 1.4s; }

@keyframes lens-glow {
  0%, 100% { opacity: 0; }
  50%      { opacity: 0.65; }
}

/* gened: the magnifier sweeps up the card and the matching line lands under
   it — the search, then the hit. The lines are timed to the sweep, not to
   each other, which is why the delays aren't evenly spaced. */
.gened-lens {
  animation: gened-sweep 5.2s ease-in-out infinite;
}

@keyframes gened-sweep {
  0%, 10%   { transform: translate(0, 0); }
  45%, 62%  { transform: translate(-26px, -22px); }
  95%, 100% { transform: translate(0, 0); }
}

.gened-line {
  animation: gened-hit 5.2s ease-in-out infinite;
}

.gened-line2 { animation-delay: 260ms; }
.gened-line3 { animation-delay: 420ms; }

@keyframes gened-hit {
  0%, 40%   { opacity: 0.2; }
  55%, 78%  { opacity: 1; }
  92%, 100% { opacity: 0.2; }
}

/* towers: windows twinkle at night */
.tower-light {
  animation: window-twinkle 3.2s ease-in-out infinite;
}

.tower-light2 { animation-delay: 700ms; }
.tower-light3 { animation-delay: 1.3s; }
.tower-light4 { animation-delay: 1.9s; }
.tower-light5 { animation-delay: 2.5s; }

@keyframes window-twinkle {
  0%, 100% { opacity: 0.15; }
  50%      { opacity: 0.95; }
}

/* cpu: signal lines traveling across the chip */
.cpu-signal {
  stroke-dasharray: 8 24;
  animation: signal-travel 1.8s linear infinite;
}

.cpu-signal2 { animation-delay: 600ms; }
.cpu-signal3 { animation-delay: 1200ms; }

@keyframes signal-travel {
  from { stroke-dashoffset: 32; }
  to   { stroke-dashoffset: 0; }
}

/* brain: the folds shimmer — each gyrus brightens in turn, and a pink charge
   runs the length of five of them, so the light reads as travelling through
   the cortex rather than the whole drawing pulsing at once.

   Both halves are built to survive .sphere.is-quiet, which greyscales the
   whole globe: hue is gone there, so the shimmer has to be carried by
   luminance and stroke weight instead. That's why the folds swing across
   almost the full opacity range (the tower-light trick) and why the charge is
   nearly twice the width of the fold it rides — a same-weight dash in an
   accent colour is invisible the moment the colour is taken away. */
.brain-fold {
  opacity: 0.45;
  animation: fold-shimmer 2.8s ease-in-out infinite;
}

.brain-fold2 { animation-delay: 320ms; }
.brain-fold3 { animation-delay: 640ms; }
.brain-fold4 { animation-delay: 960ms; }
.brain-fold5 { animation-delay: 1.28s; }
.brain-fold6 { animation-delay: 1.6s; }
.brain-fold7 { animation-delay: 1.92s; }

@keyframes fold-shimmer {
  0%, 100% { opacity: 0.2; }
  50%      { opacity: 1; }
}

/* dash gap far longer than the path, so exactly one charge is in flight */
.brain-spark {
  stroke-dasharray: 18 120;
  animation: spark-travel 2.2s ease-in-out infinite;
}

.brain-spark2 { animation-delay: 440ms; }
.brain-spark3 { animation-delay: 880ms; }
.brain-spark4 { animation-delay: 1.32s; }
.brain-spark5 { animation-delay: 1.76s; }

@keyframes spark-travel {
  from { stroke-dashoffset: 138; }
  to   { stroke-dashoffset: 0; }
}

/* keyboard: keys "type" themselves in a ripple */
.kb-key {
  animation: key-press 4s ease-in-out infinite;
}

.kb-key:nth-child(3n)  { animation-delay: 300ms; }
.kb-key:nth-child(3n+1) { animation-delay: 900ms; }
.kb-key:nth-child(5n)  { animation-delay: 1.6s; }
.kb-key:nth-child(7n)  { animation-delay: 2.3s; }
.kb-space { animation-delay: 3s; }

@keyframes key-press {
  0%, 92%, 100% { fill: transparent; }
  94%, 96%      { fill: currentColor; fill-opacity: 0.25; }
}

/* dark mode overrides */
[data-theme="dark"] .desk-card {
  border-color: rgba(255, 255, 255, 0.1);
}

[data-theme="dark"] .desk-card-chips span {
  border-color: rgba(255, 255, 255, 0.15);
}


/* peccy's face keeps a dark outline even in dark mode (light ink would
   wash out against the orange body) */
[data-theme="dark"] .peccy-eye { stroke: #232323; }
[data-theme="dark"] .peccy-pupil { fill: #232323; }
[data-theme="dark"] .peccy-smile { stroke: #232323; }

/* mochi's face too — dark features on the pink body */
[data-theme="dark"] .mochi-eye { fill: #232323; }
[data-theme="dark"] .mochi-mouth { stroke: #232323; }

/* ---- resume panel ----------------------------------------------- */

.resume-panel {
  position: fixed;
  top: 22px;
  right: 32px;
  width: min(50vw, 640px);
  height: calc(100vh - 44px);
  background: var(--bg);
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 12px;
  z-index: 100;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  opacity: 0;
  pointer-events: none;
  transform-origin: top right;
  transform: scale(0.96) translateY(-8px);
  transition: opacity 350ms ease, transform 400ms cubic-bezier(0.16, 1, 0.3, 1);
  /* Shadow is static and carried in by the panel's own opacity fade, rather
     than being transitioned from `none` on open. A transitioned box-shadow
     changes the element's paint output every frame, so its layer can't be
     cached — which cancels the transform optimisation for the scale running
     alongside it, on a full-viewport-height element with a 40px blur.
     Opacity fades the shadow along with everything else, so this is the same
     picture for 50ms less, entirely on the compositor. */
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.08), 0 2px 12px rgba(0, 0, 0, 0.04);
}

.resume-panel.is-open {
  opacity: 1;
  pointer-events: auto;
  transform: scale(1) translateY(0);
}

.resume-topbar {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  padding: 8px 12px 4px;
  flex-shrink: 0;
  opacity: 0;
  transition: opacity 250ms ease 200ms;
}

.resume-panel.is-open .resume-topbar {
  opacity: 1;
}

.resume-close {
  width: 28px;
  height: 28px;
  border: none;
  background: none;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: var(--ink-soft);
  transition: background 200ms ease, color 200ms ease, transform 300ms ease;
}

.resume-close:hover {
  background: rgba(0, 0, 0, 0.06);
  color: var(--ink);
  transform: rotate(90deg);
}

.resume-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 0 32px 16px;
  overflow: hidden;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 300ms ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
}

.resume-panel.is-open .resume-content {
  opacity: 1;
  transform: translateY(0);
  transition-delay: 150ms;
}

.resume-header {
  margin-bottom: 10px;
  flex-shrink: 0;
  text-align: center;
}

.resume-header h1 {
  font-size: 16px;
  font-weight: 700;
  margin: 0 0 3px;
  letter-spacing: -0.02em;
}

.resume-contact {
  font-size: 10px;
  color: rgba(35, 35, 35, 0.6);
  margin: 0;
}

.resume-sections {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px 18px;
  min-height: 0;
  overflow: hidden;
}

.resume-section {
  min-height: 0;
  overflow: hidden;
}

.resume-section--edu { grid-column: 1 / -1; }
.resume-section--exp { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px; }
.resume-section--exp h2 { grid-column: 1 / -1; }
.resume-section--projects { grid-column: 1 / -1; }
.resume-section--skills { grid-column: 1 / -1; }

.resume-section h2 {
  font-size: 9px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: rgba(35, 35, 35, 0.6);
  margin: 0 0 5px;
  padding-bottom: 3px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}

.resume-entry {
  margin-bottom: 4px;
}

.resume-entry:last-child { margin-bottom: 0; }

.resume-entry-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-size: 11px;
  gap: 8px;
}

.resume-entry-head strong { font-weight: 600; }
.resume-entry-head span { font-size: 9px; color: rgba(35, 35, 35, 0.6); white-space: nowrap; }

.resume-entry-sub {
  display: flex;
  justify-content: space-between;
  font-size: 10px;
  color: rgba(35, 35, 35, 0.6);
  margin-top: 1px;
}

.resume-entry p,
.resume-section > p {
  font-size: 9.5px;
  line-height: 1.5;
  color: var(--ink);
  margin: 4px 0 0;
}

.resume-entry ul {
  margin: 3px 0 0;
  padding-left: 12px;
  font-size: 9px;
  line-height: 1.4;
  color: var(--ink);
}

.resume-entry li {
  margin-bottom: 1px;
}


.resume-download {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 10px;
  font-weight: 500;
  color: var(--ink-soft);
  text-decoration: none;
  border: 1px solid rgba(0, 0, 0, 0.08);
  border-radius: 14px;
  padding: 5px 10px;
  transition: background 200ms ease, color 200ms ease;
}

.resume-download:hover {
  background: rgba(0, 0, 0, 0.04);
  color: var(--ink);
}

/* static, same reasoning as the light-theme shadow above */
[data-theme="dark"] .resume-panel {
  border-color: rgba(255, 255, 255, 0.12);
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 2px 12px rgba(0, 0, 0, 0.2);
}

[data-theme="dark"] .resume-close:hover {
  background: rgba(255, 255, 255, 0.08);
  color: var(--ink);
}

[data-theme="dark"] .resume-contact,
[data-theme="dark"] .resume-section h2,
[data-theme="dark"] .resume-entry-head span,
[data-theme="dark"] .resume-entry-sub {
  color: rgba(232, 230, 227, 0.6);
}

[data-theme="dark"] .resume-section h2 { border-bottom-color: rgba(255, 255, 255, 0.08); }

[data-theme="dark"] .resume-download {
  border-color: rgba(255, 255, 255, 0.12);
}

[data-theme="dark"] .resume-download:hover { background: rgba(255, 255, 255, 0.06); }

/* ---- the sections below the story (ramblings / gallery) ----------
   These aren't popups: each one rises from the bottom of the viewport as
   if the page scrolled down to it, and the only way out is the "back up"
   control. One long ease rather than a fade, because the motion itself is
   the thing that says "you moved down the page". */

.fs-panel {
  position: fixed;
  inset: 0;
  z-index: 120;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  pointer-events: none;
  transform: translateY(100%);
  transform-origin: 50% 0%;
  transition: transform 880ms cubic-bezier(0.22, 1, 0.24, 1);
}

.fs-panel.is-open {
  pointer-events: auto;
  transform: translateY(0);
}

/* Leaving is its own gesture, not the arrival played backwards. The curve
   starts moving immediately and spends its long tail decelerating, and the
   section scales a touch off its top edge on the way down so it reads as
   receding rather than being yanked off screen. */
.fs-panel.is-leaving {
  transform: translateY(100%) scale(0.965);
  opacity: 0;
  transition: transform 900ms cubic-bezier(0.32, 0.72, 0, 1),
              opacity 700ms cubic-bezier(0.4, 0, 1, 1) 180ms;
}

/* The story gives a little ground as the section arrives, and comes back
   just behind it on the way out — the small delay is what makes the two
   feel like one connected move instead of a swap. The transform has to be
   transitioned on the base rules (not just under .is-sectioned) or the
   return snaps; each keeps the opacity timing the intro loader set up. */
.track {
  transition: opacity 900ms ease 50ms,
              transform 900ms cubic-bezier(0.22, 1, 0.24, 1) 90ms;
}

.nav {
  transition: opacity 800ms ease 150ms,
              transform 900ms cubic-bezier(0.22, 1, 0.24, 1) 90ms;
}

.footer {
  transition: opacity 800ms ease 400ms,
              transform 900ms cubic-bezier(0.22, 1, 0.24, 1) 90ms;
}

html.is-sectioned .track,
html.is-sectioned .footer,
html.is-sectioned .nav {
  transform: translateY(-3vh);
  opacity: 0;
  transition: transform 880ms cubic-bezier(0.22, 1, 0.24, 1),
              opacity 420ms ease;
  pointer-events: none;
}

.fs-topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24px 32px 12px;
  flex-shrink: 0;
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 300ms ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
}

.fs-panel.is-open .fs-topbar {
  opacity: 1;
  transform: translateY(0);
  transition-delay: 140ms;
}

.fs-title {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.02em;
}

/* the way back out — the arrow drifts up on hover, in the direction it
   sends you */
.fs-up {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-soft);
  background: none;
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 18px;
  padding: 9px 15px;
  cursor: pointer;
  transition: background 220ms ease, color 220ms ease, border-color 220ms ease;
}

.fs-up:hover {
  background: rgba(0, 0, 0, 0.04);
  color: var(--ink);
}

.fs-up svg {
  transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}

.fs-up:hover svg {
  transform: translateY(-3px);
}

/* the stage the list and the reading view share — the article slides in
   over the list rather than replacing it, so the list keeps its scroll */
.fs-body {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.fs-scroll {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  display: flex;
  flex-direction: column;
  padding: 8px 32px 56px;
  scrollbar-width: thin;
  scrollbar-color: rgba(35, 35, 35, 0.2) transparent;
}

.fs-scroll::-webkit-scrollbar { width: 8px; }
.fs-scroll::-webkit-scrollbar-track { background: transparent; }
.fs-scroll::-webkit-scrollbar-thumb {
  background: rgba(35, 35, 35, 0.18);
  border-radius: 4px;
}

/* `margin: auto` inside the column flex parent rather than
   justify-content: center — auto margins centre the entries when they don't
   fill the viewport, but still let them scroll from the top once they do,
   where centring would clip the first one out of reach. */
.blog-list-inner {
  width: 100%;
  max-width: 800px;
  margin: auto;
}

/* -- the entries --
   Laid out like a journal rather than a feed: a dated rail down the left,
   the entry itself set against it, and hairlines instead of cards. Nothing
   is boxed — the date and the rules do the organizing. */

.blog-card {
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-top: 1px solid rgba(0, 0, 0, 0.09);
  padding: 30px 6px 30px 0;
  cursor: pointer;
  color: var(--ink);
  font: inherit;
  display: grid;
  grid-template-columns: 118px 1fr;
  gap: 0 26px;
  align-items: start;
  /* each entry is staggered by its index (--i), set inline by panels.js */
  opacity: 0;
  transform: translateY(14px);
  /* Entrance only. The rules stay put on hover — the number in the rail and
     the arrow beside the title are the hover, and they're on their own
     elements, clear of the stagger below. */
  transition: opacity 420ms ease, transform 520ms cubic-bezier(0.16, 1, 0.3, 1);
}

.blog-card:last-child { border-bottom: 1px solid rgba(0, 0, 0, 0.09); }

/* Careful with this one: transition-delay is a single value against the whole
   list above it, and this rule matches for as long as the section is open —
   not just as the entries arrive. Anything transitioned on .blog-card itself
   therefore inherits the stagger on *every* change, hover included. That's
   what made the border glow lag behind the cursor by up to 415ms on the
   fourth entry. Keep hover feedback on the children, where this can't reach. */
.fs-panel.is-open .blog-card {
  opacity: 1;
  transform: translateY(0);
  transition-delay: calc(220ms + var(--i, 0) * 65ms);
}

/* -- the dated rail -- */

.blog-rail {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding-top: 4px;
}

.blog-no {
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.12em;
  color: rgba(35, 35, 35, 0.28);
  /* lights with the rule above it, so the two have to share its timing */
  transition: color 140ms ease;
}

.blog-card:hover .blog-no { color: var(--ink); }

.blog-date {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
}

.blog-read {
  font-size: 10px;
  font-style: italic;
  color: rgba(35, 35, 35, 0.35);
}

/* -- the entry -- */

.blog-card-title {
  font-size: 25px;
  font-weight: 500;
  letter-spacing: -0.022em;
  line-height: 1.18;
  margin: 0 0 9px;
  /* the arrow rides in the flow so a long title wraps around it cleanly */
  display: block;
}

.blog-card-arrow {
  display: inline-block;
  margin-left: 9px;
  font-size: 16px;
  color: var(--ink-soft);
  opacity: 0;
  transform: translateX(-7px);
  transition: opacity 250ms ease, transform 400ms cubic-bezier(0.16, 1, 0.3, 1);
}

.blog-card:hover .blog-card-arrow {
  opacity: 1;
  transform: translateX(0);
}

/* set in italic so the summary reads as an aside in the reader's own
   handwriting rather than another block of copy */
.blog-card-excerpt {
  font-size: 13.5px;
  font-style: italic;
  line-height: 1.65;
  color: var(--ink-soft);
  margin: 0;
  max-width: 58ch;
}

/* middot-separated rather than pills — quieter, and it reads as a note in
   the margin instead of metadata */
.blog-tags {
  margin-top: 13px;
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: lowercase;
  color: rgba(35, 35, 35, 0.38);
}

.blog-tags span + span::before {
  content: '·';
  margin: 0 7px;
  color: rgba(35, 35, 35, 0.28);
}


/* -- blog reading view -- */

.blog-article {
  position: absolute;
  inset: 0;
  background: var(--bg);
  overflow-y: auto;
  overflow-x: hidden;
  padding: 8px 32px 72px;
  opacity: 0;
  pointer-events: none;
  transform: translateX(28px);
  transition: opacity 320ms ease, transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
  scrollbar-width: thin;
  scrollbar-color: rgba(35, 35, 35, 0.2) transparent;
}

.blog-article::-webkit-scrollbar { width: 8px; }
.blog-article::-webkit-scrollbar-track { background: transparent; }
.blog-article::-webkit-scrollbar-thumb {
  background: rgba(35, 35, 35, 0.18);
  border-radius: 4px;
}

.blog-article.is-open {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(0);
}

.blog-article-inner {
  max-width: 680px;
  margin: 0 auto;
}

.blog-back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  font-weight: 500;
  color: var(--ink-soft);
  background: none;
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 14px;
  padding: 6px 12px;
  cursor: pointer;
  margin-bottom: 26px;
  transition: background 200ms ease, color 200ms ease, gap 250ms ease;
}

.blog-back:hover {
  background: rgba(0, 0, 0, 0.04);
  color: var(--ink);
  gap: 10px;
}

/* the entry itself is centred and dated like a page in a notebook */
.blog-article h1 {
  font-size: 32px;
  font-weight: 500;
  letter-spacing: -0.028em;
  line-height: 1.14;
  margin: 0 0 14px;
  text-align: center;
}

.blog-article-meta {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: rgba(35, 35, 35, 0.4);
  text-align: center;
  margin: 0 0 34px;
  padding-bottom: 22px;
  position: relative;
}

/* a short rule under the date rather than a full-width divider */
.blog-article-meta::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 34px;
  height: 1px;
  transform: translateX(-50%);
  background: rgba(35, 35, 35, 0.22);
}

/* Drop cap on the opening paragraph — the one flourish that says "journal"
   more than any amount of styling elsewhere. Anchored to the paragraph
   after the date line rather than :first-of-type, since the date itself is
   the first <p> in the body. */
.blog-article-meta + p::first-letter {
  float: left;
  font-size: 47px;
  font-weight: 500;
  line-height: 0.84;
  margin: 5px 9px 0 0;
}


.blog-article h2 {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.015em;
  margin: 34px 0 10px;
}

.blog-article p {
  font-size: 14.5px;
  line-height: 1.75;
  margin: 0 0 18px;
  color: var(--ink);
}

.blog-article ul {
  font-size: 14.5px;
  line-height: 1.75;
  padding-left: 20px;
  margin: 0 0 18px;
}

.blog-article li { margin-bottom: 6px; }

.blog-article code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12.5px;
  background: rgba(35, 35, 35, 0.06);
  border-radius: 4px;
  padding: 2px 5px;
}

.blog-article blockquote {
  margin: 0 0 18px;
  padding-left: 16px;
  border-left: 2px solid rgba(35, 35, 35, 0.2);
  color: var(--ink-soft);
  font-size: 14px;
  line-height: 1.7;
}

/* -- dark mode -- */

[data-theme="dark"] .fs-close,
[data-theme="dark"] .blog-back,
[data-theme="dark"] .blog-tags span {
  border-color: rgba(255, 255, 255, 0.14);
}

[data-theme="dark"] .fs-close:hover,
[data-theme="dark"] .blog-back:hover {
  background: rgba(255, 255, 255, 0.08);
  color: var(--ink);
}

[data-theme="dark"] .blog-card,
[data-theme="dark"] .blog-card:last-child,
[data-theme="dark"] .blog-article-meta {
  border-color: rgba(255, 255, 255, 0.1);
}


[data-theme="dark"] .blog-article code {
  background: rgba(255, 255, 255, 0.08);
}

[data-theme="dark"] .blog-article blockquote {
  border-left-color: rgba(255, 255, 255, 0.2);
}

[data-theme="dark"] .fs-scroll,
[data-theme="dark"] .blog-article {
  scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}

[data-theme="dark"] .fs-scroll::-webkit-scrollbar-thumb,
[data-theme="dark"] .blog-article::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.18);
}

/* -- narrow viewports -- */

@media (max-width: 720px) {
  .fs-topbar { padding: 20px 20px 10px; }
  .fs-scroll, .blog-article { padding-left: 20px; padding-right: 20px; }
  .blog-card { grid-template-columns: 1fr; gap: 10px; }
  .blog-rail { flex-direction: row; align-items: baseline; gap: 10px; }
  .blog-card-title { font-size: 21px; }
  .blog-article h1 { font-size: 26px; }
}

/* ---- the buttons at the end of the story ------------------------
   The two sections are only reachable from here, so these have to feel
   like a destination rather than chrome. Presence is written per frame by
   panels.js from the reading progress — they rise as the last words land,
   which is why there's no opacity transition here to fight it. */

.end-actions {
  position: fixed;
  left: 50%;
  bottom: 62px;
  z-index: 11;
  display: flex;
  gap: 10px;
  opacity: 0;
  pointer-events: none;
  transform: translateX(-50%);
}

.end-action {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  letter-spacing: -0.005em;
  color: var(--ink);
  /* same hairline and hover grey as the resume chip — they're the same
     family of control, so they shouldn't respond differently */
  background: transparent;
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 24px;
  padding: 12px 20px;
  cursor: pointer;
  perspective: 200px;
  transition: background-color 300ms ease;
}

.end-action:hover {
  background: rgba(0, 0, 0, 0.04);
}

.end-action svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
  opacity: 0.5;
  transition: transform 780ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease;
  transform-style: preserve-3d;
}

.end-action:hover svg {
  opacity: 0.85;
  transform: rotateY(720deg);
}

/* Clicking sends a section up over the button, which takes the cursor with
   it and drops the hover, so the icon would spin back out underneath the
   arriving panel. Two states rather than one, because the spin takes 780ms
   and nobody waits that long before clicking — at the click it is almost
   always mid-turn:

   is-holding  pins the spin's destination so the turn in flight carries on
               to it instead of reversing.
   is-held     freezes the icon outright. Invisible *once the turn has
               landed* — identity and 720deg are the same picture — and a
               visible jump before then, which is why panels.js waits for
               the spin to finish before swapping one for the other. */
.end-action.is-holding svg {
  transform: rotateY(720deg);
}

.end-action.is-held svg {
  transition: none;
  transform: none;
}

[data-theme="dark"] .end-action {
  border-color: rgba(255, 255, 255, 0.15);
}

[data-theme="dark"] .end-action:hover {
  background: rgba(255, 255, 255, 0.06);
}

/* ---- curved photo carousel -------------------------------------
   A port of the OGL circular-gallery idea to plain DOM: the cards sit on
   a horizontal strip that's bent around a very large circle, so the ones
   away from center sink and tilt along the arc. panels.js writes each
   card's transform every frame from one smoothed scroll value and wraps
   the strip around itself, so it spins forever in either direction. */

.carousel {
  position: relative;
  height: 100%;
  width: 100%;
  overflow: hidden;
  cursor: grab;
  touch-action: none;
}

.carousel.is-grabbed {
  cursor: grabbing;
}

.carousel-item {
  position: absolute;
  left: 50%;
  top: 50%;
  /* size is written by JS (it's a function of the stage height); the
     centering half of the transform stays here */
  margin-left: 0;
  will-change: transform;
  transform: translate(-50%, -50%);
}

.carousel-figure {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 6px;
  background: rgba(35, 35, 35, 0.06);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.16);
}

.carousel-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
  user-select: none;
  /* --sat is written per frame: only the card in focus carries color */
  filter: grayscale(calc(1 - var(--sat, 0))) contrast(1.04);
}

[data-theme="dark"] .carousel-figure {
  background: rgba(255, 255, 255, 0.07);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}

/* the drag hint sits under the strip and gets out of the way once the
   reader has spun it themselves */
.carousel-hint {
  position: absolute;
  left: 50%;
  bottom: 26px;
  transform: translateX(-50%);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-soft);
  pointer-events: none;
  transition: opacity 500ms ease;
}

.carousel.is-touched .carousel-hint {
  opacity: 0;
}


/* ---- reduced motion --------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .scroll-hint,
  .token-wave.is-active,
  .token-bars.is-active span,
  .token-box.is-active,
  /* .token-bridge, not .token-crane — the token is authored as <crane> but
     main.js builds it with class token-bridge, so this rule was never
     matching and the bridge kept drawing itself under reduced motion. */
  .token-bridge.is-active svg,
  .roller.is-active .roller-word,
  .now-playing-bars span {
    animation: none;
  }

  /* keep the label legible but drop the choreography */
  .sphere-label,
  .sphere-label-inner {
    transition-duration: 1ms;
  }

  .sphere-label-inner { transform: translateY(0); }

  /* blur is a common migraine trigger — recede with contrast only. The
     greyscale hold stays: it's the state itself, not decoration. */
  .sphere.is-focusing .sphere-item:not(.is-focused),
  .sphere.is-card .sphere-item:not(.is-flying) {
    filter: grayscale(1) opacity(0.3);
  }

  .sphere-item.is-focused svg { transform: none; }

  /* the entrance keeps its fade — that one is safe, and without it the page
     appears mid-layout — but nothing slides in */
  html.is-booting .track,
  html.is-booting .nav,
  html.is-booting .footer {
    transform: none;
  }

  /* the globe holds still (JS drops its drift to 0); drag still works */
  .sphere { transition-duration: 1ms; }
  /* no scale — just go, and come straight back, since the fade is the only
     cue that's safe here */
  .sphere.is-dimmed {
    opacity: 0;
    transform: none;
  }
  .sphere.is-quiet { opacity: 0.3; transform: none; }
  /* the flight to the centre is the one motion the card depends on, so it
     stays — just shorter */
  .sphere-item.is-flying,
  .sphere.is-zoomed .sphere-item.is-flying {
    transition-duration: 200ms;
  }

  /* Sections: they still have to travel (the slide is what makes them a
     place rather than a popup), but everything inside arrives flat, and
     the per-item stagger goes away. */
  .fs-topbar,
  .blog-article {
    transform: none;
  }

  .blog-card,
  .fs-panel.is-open .blog-card {
    transform: none;
    transition-delay: 0ms;
  }

  .blog-card:hover { padding-left: 4px; }
  .fs-up:hover svg { transform: none; }

  /* the icons' two-turn spin is the most decorative thing on the page */
  .end-action:hover svg,
  .end-action.is-holding svg { transform: none; }

  /* the carousel only moves while the reader is dragging or scrolling it,
     so it stays — but the label doesn't need to fade with focus */
  .carousel-hint { transition: none; }
}

/* ================================================================
   perf-low — measured slow hardware
   ----------------------------------------------------------------
   DORMANT. This was set by the capability probe in the intro loader, which
   used its own two seconds of canvas animation as a benchmark and read the
   median frame time off it. The loader was retired (see archive/), and
   nothing sets perf-low any more, so none of these rules currently apply.

   They are kept because they are still correct and still worth having if the
   probe is ever rehomed onto another continuously-animating element — see
   archive/README.md.

   The rule for everything in this block: the choreography does not change.
   No duration, easing, delay or stagger is touched, and nothing starts or
   stops moving. What changes is only how expensively the same picture is
   drawn — blur radii, shadow spread, and animation of detail far too small
   to read. On the hardware this targets, the difference between these and
   the defaults is invisible; the difference in frame time is not.

   Kept separate from prefers-reduced-motion on purpose. That is a stated
   preference about motion and it legitimately removes movement; this is a
   measurement about throughput and must not.
   ================================================================ */

/* A real-time blur on eight tiles whose transforms are being rewritten
   every frame is the single most expensive moment on the page. Greyscale
   plus opacity reads as the same recede — it is already the substitute the
   reduced-motion path uses. */
html.perf-low .sphere.is-focusing .sphere-item:not(.is-focused),
html.perf-low .sphere.is-card .sphere-item:not(.is-flying) {
  filter: grayscale(1) opacity(0.3);
}

/* The tiles carry ~40 elements animating paint properties (fill,
   stroke-dashoffset, opacity) inside filtered subtrees — keyboard keys
   depressing, tower lights twinkling, signals travelling. At tile scale
   these are a few pixels each. Holding them still costs nothing you can
   see and takes a continuous repaint of nine filtered subtrees off the
   frame budget. */
html.perf-low .sphere-item * {
  animation-play-state: paused;
}

/* 60px of shadow blur around five cards that are transform-written every
   frame is a large paint area; 24px is the same lift at a third of it. */
html.perf-low .carousel-figure {
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.16);
}

html[data-theme="dark"].perf-low .carousel-figure {
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5);
}

/* width and margin are layout properties, so each token activation reflows
   the whole 163-word paragraph for 600ms — and it happens during scroll,
   when paintWave is already busiest. Snapping is a smaller loss than a
   stuttering read. */
html.perf-low .token {
  transition: opacity 450ms ease,
    transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}

html.perf-low .roller {
  transition: none;
}

/* ±1deg of cursor tilt on the largest text block on the page. main.js skips
   the work when this is set; this is belt and braces. */
html.perf-low .prose {
  transform: none !important;
  transition: none;
}

/* ================================================================
   Touch and narrow viewports
   ----------------------------------------------------------------
   Additive and guarded: with none of these queries matching, the desktop
   rendering above is untouched.

   Three axes rather than a ladder of widths, because they answer different
   questions. 1500px is where the globe already disappears, so it is also
   where its replacement has to appear. 720px is the existing panel
   breakpoint and does duty as "phone layout". Hover capability is asked
   separately from width, because a 1300px touchscreen and a 700px window
   with a mouse want opposite answers and no width can express that.
   ================================================================ */

/* ---- hover is not a thing every pointer has --------------------
   Roughly thirty :hover rules in this stylesheet were firing on touch as
   sticky states that only cleared when you tapped something else. The ones
   that matter are collected here; the rest are cosmetic. */
@media (hover: none) {
  /* The track name was hover-only, so on a phone there was no way at all to
     see what was playing. It becomes tap-to-toggle instead (the class is set
     in spotify.js), and the transition goes away with it — animating
     max-width and padding is the one hover on this page that also reflowed
     the fixed nav row. */
  .np-expand { transition: none; }

  .now-playing.is-expanded .np-expand {
    max-width: min(220px, 46vw);
    opacity: 1;
    padding-right: 12px;
  }

  /* the only cue that a row is tappable */
  .blog-card-arrow,
  .proj-arrow {
    opacity: 1;
    transform: none;
  }

  /* no cursor to lift toward, and these read as stuck-on when they latch */
  .nav-icon:hover,
  .nav-chip:hover,
  .theme-toggle:hover { background: transparent; }
}

/* Tap handling. touch-action: manipulation drops the ~300ms wait for a
   possible double-tap-to-zoom on every control, which is felt as general
   sluggishness rather than as a delay on any one tap. */
@media (pointer: coarse) {
  .nav-chip,
  .nav-icon,
  .theme-toggle,
  .end-action,
  .fs-up,
  .blog-back,
  .blog-card,
  .proj-row,
  .resume-close,
  .resume-download,
  .desk-back {
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }
}

/* Hidden by default, revealed by the queries below. Both of these have to be
   declared before those queries, not after: same specificity means source
   order decides, and a `display: none` written afterwards wins everywhere. */
.nav-chip--projects { display: none; }

/* No pointer-events here: these used to sit in the footer, which is
   pointer-events: none, so they had to opt back in. In #endActions the
   container owns that — and must, or the closed menu would still be
   tappable in the top-right corner. */
.end-social {
  display: none;
  align-items: center;
  gap: 2px;
}

.end-social .nav-icon { width: 30px; height: 30px; }
.end-social .nav-icon svg { width: 16px; height: 16px; }

/* ---- the work panel replaces the globe below 1500px ------------
   Between 720px and 1500px there was previously neither a globe nor any
   mobile treatment — this closes that gap with the same rule that opens it
   on phones. */
@media (max-width: 1500px) {
  .nav-chip--projects { display: inline-flex; }
}

.proj-list-inner {
  max-width: 800px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
}

.proj-row {
  display: grid;
  grid-template-columns: 74px 1fr;
  align-items: center;
  gap: 18px;
  width: 100%;
  padding: 20px 4px;
  border: none;
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
  background: none;
  text-align: left;
  cursor: pointer;
  color: var(--ink);
  font: inherit;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 420ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1),
    background-color 200ms ease;
  transition-delay: calc(220ms + var(--i, 0) * 55ms);
}

.fs-panel.is-open .proj-row {
  opacity: 1;
  transform: translateY(0);
}

[data-theme="dark"] .proj-row { border-bottom-color: rgba(255, 255, 255, 0.1); }

.proj-art {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 58px;
}

.proj-art svg {
  width: 100%;
  height: 100%;
  color: var(--ink);
}

.proj-body { display: flex; flex-direction: column; gap: 4px; }

.proj-name {
  font-size: 19px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.proj-arrow {
  display: inline-block;
  margin-left: 8px;
  opacity: 0;
  transform: translateX(-4px);
  transition: opacity 260ms ease, transform 260ms ease;
}

.proj-row:hover .proj-arrow { opacity: 1; transform: translateX(0); }

.proj-date {
  font-size: 11px;
  font-weight: 500;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.proj-detail-art {
  height: 120px;
  margin: 0 0 26px;
  display: flex;
  align-items: center;
  justify-content: flex-start;
}

.proj-detail-art svg { height: 100%; color: var(--ink); }

.proj-link {
  display: inline-block;
  margin-top: 28px;
  font-size: 14px;
  font-weight: 500;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  padding-bottom: 2px;
}

/* ---- phone layout --------------------------------------------- */
@media (max-width: 720px) {
  /* dvh, not vh: on iOS the URL bar makes vh taller than what you can see,
     so a 100vh column has its last line permanently under the chrome. */
  .track { height: 100svh; }

  /* Now that the story is taller than its box and slides through it, both
     ends of that box are cuts — lines ride up into the nav and the tail sits
     under the footer. These two are the edges of the window it moves behind:
     the page's own background, faded, so text arrives and leaves instead of
     being sliced off. Painted over the story (z-9) and under the nav and
     footer (z-10), which is what keeps those two legible.

     Gradients rather than a mask on .viewport on purpose: the story repaints
     every frame as words take ink, and masking that layer would mean
     resolving per-pixel alpha across the whole paragraph on every one of
     those repaints. These are two static strips the compositor can leave
     alone. Their heights are exactly the viewport's padding, so each fade
     ends where the text's own box begins and no live line is ever dimmed. */
  /* These stay gradients through a theme flip, which they could not do when
     --bg was an ordinary custom property: a gradient is a background image
     and an image does not interpolate, so the strips snapped to the new
     theme while the page behind them eased, and you got two bands of the
     outgoing colour framing the story for the length of the fade. Now that
     --bg is registered and animates, the gradient is simply re-resolved each
     frame from a colour that is already mid-fade — nothing to declare here,
     and no transition of their own to keep in step with anyone else's. */
  .track::before,
  .track::after {
    content: "";
    position: fixed;
    left: 0;
    right: 0;
    z-index: 9;
    pointer-events: none;
  }

  .track::before {
    top: 0;
    height: calc(104px + env(safe-area-inset-top));
    background: linear-gradient(to bottom, var(--bg) 72%, transparent);
  }

  .track::after {
    bottom: 0;
    height: 60px;
    background: linear-gradient(to top, var(--bg) 45%, transparent);
  }

  /* Clears the nav and nothing else — the ramblings and the photos are
     behind the "more" button now, so they cost the story no height. */
  /* 104px is the 62px nav plus a breath — the first line used to start
     right on the nav's hem, which read as cramped rather than as the top of
     a page; 60px at the foot clears the scroll cue once that cue has moved
     down onto the footer (below). The sides are back to a proper 20px —
     they were squeezed to 16 to win a few lines back, and that stopped
     being the way to pay for type size the moment the story was allowed to
     slide (see the read-head slide in main.js). Keep the ::before gradient
     above in step with this number: it is the fade over exactly this gap. */
  .viewport {
    padding: calc(104px + env(safe-area-inset-top)) 20px 60px;
    align-items: flex-start;
  }

  /* 100%, not none: .prose is a flex item, and a flex item's default
     min-width:auto stops it shrinking below its content, so an unbounded
     max-width let the lines run off the right edge instead of wrapping. */
  .prose {
    /* Still a function of the screen's height rather than a constant, so a
       small phone gets a proportionate measure — but it is no longer the
       height that decides what *fits*, because the story slides now. Every
       term is another 10% up from the last pass; on a 16 in Safari
       (~745svh) this lands around 17.9px.

       svh, not vh: vh is the *tall* viewport, the one you only get after
       iOS collapses its URL bar, so the first screenful would be sized for
       room that isn't there yet — the same reason .track is 100svh. */
    font-size: clamp(15.3px, 2.4svh, 19.7px);
    /* Back open to something close to desktop's rhythm. This was squeezed
       to 1.42 when every line had to fit one screen; sliding is what pays
       for the leading now. */
    line-height: 1.55;
    max-width: 100%;
    min-width: 0;
    /* The slide writes this element's transform every frame from a scroll
       value that is already smoothed; a 150ms ease on top of that is pure
       lag behind the finger. (Desktop keeps it — there it eases the tilt.) */
    transition: none;
  }

  /* Six controls in a space-between row do not fit 390px. The social links
     move to the footer, which has room and is where they read as a sign-off
     rather than competing with the two things you actually do here. */
  /* The "more" button is a sixth thing in this row and 390px was already
     spoken for — the wordmark and the work chip were touching. The gap and
     the side padding pay for it; the wordmark keeps its size, being the one
     thing here that isn't a control. */
  .nav {
    padding: calc(14px + env(safe-area-inset-top)) 16px 14px;
  }

  .nav-logo { font-size: 18px; }

  .nav-right { gap: 8px; }

  .nav-right > .nav-icon { display: none; }

  .nav-chip { font-size: 11px; padding: 7px 12px; height: 31px; }

  .footer {
    padding: 14px 18px calc(14px + env(safe-area-inset-bottom));
    gap: 12px;
  }

  /* Down onto the footer's shoulder. It used to float 56px up, which put it
     in the middle of the space the story needs for its last two lines — and
     it is a cue you read once, not a fixture. */
  .scroll-hint {
    bottom: calc(42px + env(safe-area-inset-bottom));
    font-size: 13px;
  }

  /* The now playing chip is desktop's flourish: it costs 44px of the one
     row that has none to spare, and its whole point is the track name that
     only appears on hover. */
  .now-playing { display: none; }

  .nav-menu-btn { display: inline-flex; }

  /* ---- the actions row becomes the "more" dropdown ---------------
     On desktop these two buttons are the reward for finishing the read, so
     they rise off the bottom of the screen as the last words land. On a
     phone that meant the only route to the ramblings and the photos was a
     full scroll of the story, every time — and standing them in a row under
     the nav instead just moved the problem, because a row that is always
     there is a row the story never gets back.

     So it's the same element, folded into a card under the "more" button:
     nothing of the screen is spent until it's asked for. No second copy of
     the buttons anywhere — this *is* #endActions, which is why the click
     handlers, the icon spin and the section hand-off all still apply.

     The !important is against panels.js, which writes opacity, transform
     and pointer-events as *inline* styles from the reading progress; inline
     styles outrank a plain rule. panels.js stops writing them below 720px
     (see pinTop there), but these also cover the frames before that runs
     and a desktop-to-phone resize. */
  .end-actions {
    top: calc(62px + env(safe-area-inset-top));
    bottom: auto;
    left: auto;
    right: 14px;
    width: max-content;
    min-width: 176px;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    padding: 8px;
    border-radius: 16px;
    border: 1px solid rgba(0, 0, 0, 0.1);
    background: var(--bg);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
    /* out of the corner it belongs to, rather than out of its own middle */
    transform-origin: 100% 0;
    opacity: 0 !important;
    transform: translateY(-6px) scale(0.96) !important;
    pointer-events: none !important;
    transition: opacity 200ms ease,
                transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
  }

  [data-theme="dark"] .end-actions {
    border-color: rgba(255, 255, 255, 0.14);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
  }

  .end-actions.is-menu-open {
    opacity: 1 !important;
    transform: none !important;
    pointer-events: auto !important;
  }

  /* Inside a card they're rows, not chips: the card already draws the
     outline, so a border on each item would be an outline inside an
     outline. */
  .end-action {
    justify-content: flex-start;
    font-size: 13.5px;
    gap: 10px;
    padding: 11px 12px;
    border: none;
    border-radius: 10px;
    -webkit-tap-highlight-color: transparent;
  }

  .end-action svg { width: 14px; height: 14px; }

  .end-action:active { background: rgba(0, 0, 0, 0.06); }

  [data-theme="dark"] .end-action:active { background: rgba(255, 255, 255, 0.08); }

  /* the social links, rehoused out of the nav and along the foot of the
     card, where they read as the sign-off they were in the footer */
  .end-social {
    display: flex;
    justify-content: center;
    gap: 6px;
    margin-top: 4px;
    padding-top: 6px;
    border-top: 1px solid rgba(0, 0, 0, 0.08);
  }

  [data-theme="dark"] .end-social { border-top-color: rgba(255, 255, 255, 0.1); }

  /* The menu is chrome, so it leaves with the rest of it when a section
     opens. panels.js closes it on the way in as well — this is what covers
     the frames before that lands, and the resume panel, which opens without
     going through openSection. */
  html.is-sectioned .end-actions {
    opacity: 0 !important;
    pointer-events: none !important;
    transform: translateY(-6px) scale(0.96) !important;
  }

  .proj-row { grid-template-columns: 56px 1fr; gap: 14px; padding: 17px 2px; }
  .proj-art { height: 46px; }
  .proj-name { font-size: 17px; }
  .proj-detail-art { height: 92px; }

  /* ---- resume: was a 195px-wide two-column card at 9px type ----
     min(50vw, 640px) resolves to about 195px on a 390px screen, and the
     content inside is a nested two-column grid with overflow:hidden at
     three levels, because on desktop it is designed to fit one screen with
     no scrolling. None of that survives a phone: it goes full-screen, one
     column, and scrolls. */
  .resume-panel {
    top: 0;
    right: 0;
    left: 0;
    bottom: 0;
    width: auto;
    height: auto;
    border-radius: 0;
    border: none;
    padding-top: env(safe-area-inset-top);
    padding-bottom: env(safe-area-inset-bottom);
    transform-origin: center;
  }

  /* The panel becomes the scroll container; everything inside it stops
     being height-constrained. On desktop .resume-sections is `flex: 1` with
     overflow:hidden inside a fixed-height column — the layout is tuned to
     fit one screen exactly. Just switching it to one column and
     overflow:visible isn't enough: it keeps the bounded flex height, so the
     content spills out and draws on top of the sections below it. It has to
     stop flexing and be allowed to grow. */
  .resume-content {
    overflow-y: auto;
    overflow-x: hidden;
    -webkit-overflow-scrolling: touch;
    padding: 0 20px 24px;
  }

  /* plain blocks rather than a grid — the grid-column spans and row sizing
     have nothing to do once there's a single column, and block flow can't
     collapse two entries into the same cell */
  .resume-sections,
  .resume-section,
  .resume-section--exp {
    display: block;
    flex: 0 0 auto;
    min-height: auto;
    overflow: visible;
  }

  .resume-section { margin-bottom: 22px; }
  .resume-section:last-child { margin-bottom: 0; }
  .resume-entry { margin-bottom: 14px; }

  /* 9-9.5px is below legible on a desktop monitor, let alone in a hand */
  .resume-header h1 { font-size: 21px; }
  .resume-contact { font-size: 12px; }
  .resume-section h2 { font-size: 11px; }
  .resume-entry-head { font-size: 14px; }
  .resume-entry-head span { font-size: 12px; }
  .resume-entry-sub { font-size: 12px; }
  .resume-entry p { font-size: 13px; line-height: 1.55; }
  .resume-entry ul { font-size: 13px; line-height: 1.55; }

  /* company/title on the left and date/location on the right collide well
     before 390px, and the date is nowrap so it wins and pushes the title
     out. Stack them instead. */
  .resume-entry-head,
  .resume-entry-sub {
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
  }

  .resume-entry-head span,
  .resume-entry-sub span { white-space: normal; }

  .blog-article-meta + p::first-letter { font-size: 34px; }
}

/* ---- the narrower phones ---------------------------------------
   The "more" button is a sixth control in the nav, and the five that were
   already there had used the row up — at 375px the wordmark and the work
   chip come within three pixels of each other, and below that they meet.
   Everything in the row gives up a few pixels rather than any one control
   being dropped. 380 rather than 360 as the line: a 390pt phone has the
   room and a 375pt one does not. */
@media (max-width: 380px) {
  .nav { padding: calc(14px + env(safe-area-inset-top)) 12px 14px; }
  .nav-right { gap: 6px; }
  .nav-logo { font-size: 16px; }
  .nav-chip { font-size: 10px; padding: 6px 10px; height: 28px; }
  .nav-menu-btn { width: 30px; height: 30px; }
  .theme-toggle { width: 30px; height: 30px; }
  .now-playing { height: 28px; }
  .spotify-logo { padding: 6px; }
  .end-actions { right: 12px; }
}

/* ---- portrait only ---------------------------------------------
   Everything about this page assumes height: the story is one paragraph
   sized to fill a screen, and on its side a phone offers about 390px of it
   for text that wants 600. Rather than a landscape layout that can only be
   a worse version of the page, it asks for the phone back.

   The two bounds are what make this a phone rule rather than a
   short-window rule. A phone on its side is 320-430px tall and at most
   932px wide (a 16 Pro Max); an iPad is 744 tall and a laptop more, so
   tablets and desktops stay out of it either way, and a desktop window
   dragged down to a letterbox is still far too wide to qualify. Nothing
   here can be a true orientation lock — iOS Safari gives a page no way to
   ask — so it is a cover, held above every other layer including the
   loader. */
.rotate-note { display: none; }

@media (orientation: landscape) and (max-height: 520px) and (max-width: 950px) {
  .rotate-note {
    position: fixed;
    inset: 0;
    z-index: 9999;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 24px;
    text-align: center;
    background: var(--bg);
    color: var(--ink);
  }

  .rotate-note svg {
    width: 34px;
    height: 34px;
    opacity: 0.75;
  }

  .rotate-note span {
    font-size: 15px;
    font-weight: 600;
    letter-spacing: -0.01em;
  }

  .rotate-note small {
    font-size: 12px;
    color: var(--ink-soft);
  }
}

/* ---- touch hardening -------------------------------------------
   The page replaces native scrolling with a virtual one, so the browser's
   own overscroll gestures have nothing useful to do and actively fight it:
   pull-to-refresh fires on a downward swipe at the top of the story, and
   rubber-banding moves a page that is not supposed to move. */
html, body {
  overscroll-behavior: none;
}

.track {
  touch-action: none;
}

/* ---- project detail: the journal flourishes don't belong here ----
   #projectDetail reuses .blog-article so it inherits the slide-in, the
   native scrolling and the back control. What it should not inherit is the
   typography that makes a rambling look like a journal entry: the centred
   title and date with a rule under them, and the drop cap anchored to the
   paragraph after .blog-article-meta — which here is the project
   description, so every project opened with one enormous letter. */
#projectDetail h1 {
  text-align: left;
  font-size: 28px;
}

#projectDetail .blog-article-meta {
  text-align: left;
  margin-bottom: 24px;
  padding-bottom: 0;
}

#projectDetail .blog-article-meta::after { content: none; }

#projectDetail .blog-article-meta + p::first-letter {
  float: none;
  font-size: inherit;
  font-weight: inherit;
  line-height: inherit;
  margin: 0;
}

#projectDetail .blog-tags {
  margin-top: 22px;
  font-size: 11px;
}

[data-theme="dark"] #projectDetail .blog-tags { color: rgba(255, 255, 255, 0.45); }

/* bullets separated by newlines in projects.js, as on the globe's card */
#projectDetail .proj-desc {
  white-space: pre-line;
}

/* ---- scroll chaining on the panel scrollers ---------------------
   html/body carry overscroll-behavior: none so the browser's own
   pull-to-refresh and rubber-band can't fight the virtual scroll. These are
   the three regions that genuinely do scroll natively, and without `contain`
   reaching the end of one chains the gesture up to the document and re-arms
   exactly what that rule is suppressing. .desk-card-desc already does this
   (see the project card above); these are the rest. */
.fs-scroll,
.blog-article,
.resume-content {
  overscroll-behavior: contain;
}
