/* =========================================================
   Ben Pattison — Portfolio
   styles.css
   Design ported from the old site: dark navy, Gasoek One
   display, Lato body, Montserrat accents, neon color cycle,
   grayscale-to-colour project tiles.
   ========================================================= */

/* ---------- View Transitions ---------- */
/*
 * Enables the browser's cross-document View Transitions API for any
 * same-origin navigation. Out of the box this gives a default cross-fade
 * between pages. For projects, a shared-element morph runs as well —
 * the .tile-media in the expanded frame and the .case-hero on the
 * destination page share the same view-transition-name, so the image
 * smoothly morphs into the hero. Browsers without support fall back
 * to a normal navigation with no animation.
 */
@view-transition {
  navigation: auto;
}

/* Outgoing: signal-loss flicker — brightens, blurs, scales down, fades out */
::view-transition-old(root) {
  animation: 360ms cubic-bezier(0.4, 0, 1, 1) both page-out;
}
/* Incoming: tunes in — starts blurred and bright, resolves into focus */
::view-transition-new(root) {
  animation: 520ms cubic-bezier(0, 0, 0.2, 1) 60ms both page-in;
}

@keyframes page-out {
  from {
    opacity: 1;
    filter: brightness(1) blur(0px);
    transform: scale(1);
  }
  to {
    opacity: 0;
    filter: brightness(1.5) blur(10px);
    transform: scale(0.985);
  }
}
@keyframes page-in {
  from {
    opacity: 0;
    filter: brightness(1.5) blur(10px);
    transform: scale(1.015);
  }
  to {
    opacity: 1;
    filter: brightness(1) blur(0px);
    transform: scale(1);
  }
}

/* Persistent UI — sidebar stays put across navigations rather than
   fading out and back in */
.sidebar { view-transition-name: site-sidebar; }
::view-transition-old(site-sidebar),
::view-transition-new(site-sidebar) {
  animation-duration: 0ms;
}

::view-transition-old(project-image),
::view-transition-new(project-image) {
  animation-duration: 480ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 1ms;
  }
}

/* ---------- Departure Mono — pixel-perfect mono terminal font.
   Loaded from jsdelivr (mirroring the rektdeckard/departure-mono GitHub
   repo). Single weight, ~22 KB woff2. Swap in self-hosted assets/fonts
   later if you'd rather not depend on a CDN. */
@font-face {
  font-family: 'Departure Mono';
  src: url('https://cdn.jsdelivr.net/gh/rektdeckard/departure-mono/public/assets/DepartureMono-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* ---------- Tokens ---------- */
:root {
  /* Colour — CRT phosphor green */
  --color-bg: #00130a;    /* page bg — near-black with green undertone */
  --color-bg-2: #052114;  /* grid + hero bg — lighter green-tinted dark */
  --color-fg: #d6ffd9;    /* soft phosphor white-green for text */
  --color-muted: #5e9a72;
  --color-rule: rgba(180, 255, 200, 0.10);
  /* Canonical "interactable" colour — anything the user can click,
     focus, or hover should land here. Slightly more yellow-green and
     brighter than the previous #4dffa8 to read more like classic CRT
     phosphor (P31/P1 family) and less like mint. */
  --color-link: #5cff8e;
  --color-link-hover: #c4ffd0;

  /* Watermelon — used for close-button hover fill, error states in
     terminal/shmup/asteroids, and any other "destructive / urgent"
     accent. Tweak this one value to retint every red on the site. */
  --color-watermelon: #fd5c63;

  /* Phosphor glow stacks — central tokens so all the CRT-coherent
     text bloom uses the same values, and tuning is one-place. Three
     intensities cover the common cases. */
  /* Always-on halo for big headlines — letterforms feel luminous as
     if projected. Two-layer stack (tight + medium) keeps the bloom
     contained close to the glyph so it doesn't bleed into adjacent
     body copy below the headline. */
  --phosphor-glow-rest:
    0 0 2px rgba(214, 255, 217, 0.8),
    0 0 12px rgba(92, 255, 142, 0.5);
  /* Bright bloom for hover/focus states on interactable text —
     adds presence to the moment of engagement. Three layers so
     the eye registers the bloom as a step-change, not just a tint. */
  --phosphor-glow-hover:
    0 0 6px rgba(92, 255, 142, 0.9),
    0 0 18px rgba(92, 255, 142, 0.55),
    0 0 36px rgba(92, 255, 142, 0.25);
  /* Stronger version for primary CTAs / chip hover states. */
  --phosphor-glow-strong:
    0 0 10px rgba(92, 255, 142, 1),
    0 0 24px rgba(92, 255, 142, 0.6),
    0 0 50px rgba(92, 255, 142, 0.3);

  /* Pixelated cursors — base64-encoded inline SVGs (Chrome is more
     reliable with base64 data URIs than URL-encoded ones for cursors).
     Default = small phosphor-green arrow; pointer = solid green block
     (matches the menu hover treatment). To swap with your own pixel
     art, replace the base64 string with a path to a PNG/SVG file. */
  --cursor-arrow: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyNCcgaGVpZ2h0PScyNCc+PHBvbHlnb24gcG9pbnRzPScyLDIgMiwxOCA2LDE0IDksMjEgMTIsMjAgOSwxNCAxNSwxNCcgZmlsbD0nI2Q2ZmZkOScgc3Ryb2tlPScjMDUyMTE0JyBzdHJva2Utd2lkdGg9JzEuNScgc2hhcGUtcmVuZGVyaW5nPSdjcmlzcEVkZ2VzJy8+PC9zdmc+");
  --cursor-pointer: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyNCcgaGVpZ2h0PScyNCc+PHJlY3QgeD0nMicgeT0nMicgd2lkdGg9JzgnIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzInIHk9JzInIHdpZHRoPScyJyBoZWlnaHQ9JzgnIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScxNCcgeT0nMicgd2lkdGg9JzgnIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzIwJyB5PScyJyB3aWR0aD0nMicgaGVpZ2h0PSc4JyBmaWxsPScjZDZmZmQ5Jy8+PHJlY3QgeD0nMicgeT0nMjAnIHdpZHRoPSc4JyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScyJyB5PScxNCcgd2lkdGg9JzInIGhlaWdodD0nOCcgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzE0JyB5PScyMCcgd2lkdGg9JzgnIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzIwJyB5PScxNCcgd2lkdGg9JzInIGhlaWdodD0nOCcgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzExJyB5PScxMScgd2lkdGg9JzInIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjwvc3ZnPg==");
  /* Expand cursor — two diagonal arrows in opposite corners (top-left
     and bottom-right) pointing outward. Used on grid tiles to signal
     "click to expand into the full view." */
  --cursor-expand: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyNCcgaGVpZ2h0PScyNCc+PHJlY3QgeD0nMicgeT0nMicgd2lkdGg9JzgnIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzInIHk9JzInIHdpZHRoPScyJyBoZWlnaHQ9JzgnIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PSc0JyB5PSc0JyB3aWR0aD0nMicgaGVpZ2h0PScyJyBmaWxsPScjZDZmZmQ5Jy8+PHJlY3QgeD0nNicgeT0nNicgd2lkdGg9JzInIGhlaWdodD0nMicgZmlsbD0nI2Q2ZmZkOScvPjxyZWN0IHg9JzgnIHk9JzgnIHdpZHRoPScyJyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScxNCcgeT0nMjAnIHdpZHRoPSc4JyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScyMCcgeT0nMTQnIHdpZHRoPScyJyBoZWlnaHQ9JzgnIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScxNCcgeT0nMTQnIHdpZHRoPScyJyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScxNicgeT0nMTYnIHdpZHRoPScyJyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48cmVjdCB4PScxOCcgeT0nMTgnIHdpZHRoPScyJyBoZWlnaHQ9JzInIGZpbGw9JyNkNmZmZDknLz48L3N2Zz4=");

  /* Phosphor accent palette (kept for any cycling animations) */
  --neon-red:  #5cff8e;
  --neon-cyan: #6fffb0;
  --neon-pink: #c4ffd0;
  --neon-mint: #5cff8e;

  /* Type — three-axis system.
       --font-display : Space Grotesk      → headlines, hero, h1–h6
       --font-body    : Inter              → long-form body, lede, project copy
       --font-accent  : Departure Mono     → UI labels, footer, system messages,
                                             captions, anything "terminal"
       --font-mono    : Departure Mono     → code/pre

     Space Grotesk + Inter are loaded via Google Fonts <link> in the HTML
     head (preconnect + stylesheet). Departure Mono is loaded via the
     @font-face block above. All three keep the engineered/CRT feel while
     giving real hierarchy to headlines and readability to body copy. */
  --font-display: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-body: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-accent: 'Departure Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --font-mono: 'Departure Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Space */
  --s-1: 0.5rem;
  --s-2: 1rem;
  --s-3: 1.5rem;
  --s-4: 2rem;
  --s-5: 3rem;
  --s-6: 5rem;
  --s-7: 8rem;

  /* Layout */
  --content-max: 1400px;
  --expanded-max-width: 1400px; /* width cap when a tile is expanded */
  --side-rail: 80px;
  --radius: 4px;

  /* Site frame */
  --site-frame-margin: 20px;
  --site-frame-color: var(--color-fg);
  --site-frame-radius: 16px;
}

/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
img, svg { max-width: 100%; display: block; }

html {
  height: 100%;
  font-size: 100%;
  -webkit-text-size-adjust: 100%;
  /* No scrolling on html — body is the scroll container so its clip-path
     stays anchored to the viewport edges as the user scrolls. */
  overflow: hidden;
  /* Match body bg so the 20px frame margin reads as the same dark navy
     when body's clip-path crops content out of those edges. */
  background: var(--color-bg);
}

body {
  font-family: var(--font-body);
  /* Inter at 400 reads cleanly against phosphor bg; 300 was tuned for
     Departure Mono and renders too thin in a proportional sans. */
  font-weight: 400;
  color: var(--color-fg);
  background-color: var(--color-bg);
  line-height: 1.75;
  letter-spacing: 0.04em;
  -webkit-font-smoothing: antialiased;
  /* Body fills the viewport exactly and scrolls its own contents. That keeps
     the clip-path anchored to viewport edges — content is clipped to the
     rounded frame at every scroll position, not just when at the top/bottom. */
  height: 100%;
  width: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-behavior: smooth;
  scroll-padding-top: var(--s-3);
  /* Site frame: keep all in-flow content inside the framed area */
  padding: var(--site-frame-margin);
  /* Clip every body descendant (including fixed elements like the
     sidebar and the expanded tile frames) to the rounded inset rect. */
  clip-path: inset(var(--site-frame-margin) round var(--site-frame-radius));
  /* Hide the scrollbar visually — it'd render at the right edge which
     is outside the clip, so the only visible result was a thin sliver
     anyway. Wheel/trackpad/keyboard scrolling still works. */
  scrollbar-width: none;
}
body::-webkit-scrollbar { display: none; }

/* The frame keyline — drawn via a fixed pseudo-element so it stays put
   while the page scrolls and never affects layout. Plain 1px white. */
body::before {
  content: "";
  position: fixed;
  inset: var(--site-frame-margin);
  border: 2px solid var(--color-rule);
  border-radius: var(--site-frame-radius);
  box-sizing: border-box;
  pointer-events: none;
  z-index: 100;
}

/* Animated CRT noise — fine procedural grain that drifts continuously
   across the entire viewport. The noise itself is a tiny inline SVG
   (feTurbulence at high frequency, tiled at 200×200), and motion is
   achieved by stepping background-position through a 12-frame loop.
   Steps(12) gives the stuttery TV-static feel rather than smooth grain.

   Stack:
     z-index: 99   sits below the frame keyline (z-100) so the border
                   still draws cleanly on top, but above all content.
     mix-blend-mode: screen brightens the dark phosphor bg where the
                   noise is light, leaving dark areas alone.
     opacity: 0.06 keeps the texture subtle — meant to feel like a
                   "live signal" hiss, not full TV static.

   Body's clip-path crops this to the rounded inset rect along with
   everything else, so the grain only shows inside the frame.

   Skipped under prefers-reduced-motion: reduce. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 99;
  /* feColorMatrix puts the phosphor green colour into the constant
     offsets (last column of each RGB row) and uses the noise intensity
     to drive the ALPHA output (last row: A_out = R_in). Dark noise
     pixels end up fully transparent; bright noise pixels end up fully
     opaque. This bakes the "only bright specks visible" effect into
     the image itself, so we no longer need mix-blend-mode: screen to
     drop out the dark bits — and the overlay's visible colour no
     longer depends on what's beneath it, which kept causing the bg
     to shift shade when tiles transitioned. */
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.25' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.302  0 0 0 0 1  0 0 0 0 0.659  1 0 0 0 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
  background-repeat: repeat;
  background-size: 200px 200px;
  /* Dialed down — at 0.10 the noise was prominent enough that any
     contrast change beneath it (a tile hover transitioning from dim
     to colourful) read as the bg subtly shifting shade. 0.05 keeps
     the texture barely-there so backdrop changes don't register. */
  opacity: 0.05;
  /* Promote to its own GPU layer so the noise renders independently
     of whatever's beneath, preventing the noise pixels from being
     re-composited when a tile gets layer-promoted on hover. */
  will-change: transform;
  /* steps(10) divides the 0.5s loop into 10 discrete frames at 20fps —
     each frame snaps to a new offset (rather than smoothly drifting),
     which is the stuttery hiss that reads as TV static. The pixel
     offsets are large enough that the eye can't track the underlying
     200px tile pattern from frame to frame, so the same noise SVG
     reads as fresh-randomised each time. */
  animation: bg-noise-shimmer 0.5s steps(10) infinite;
  will-change: background-position;
}
/* Scroll-jitter mitigation: pause the noise overlay while the page is
   actively scrolling. The full-viewport fixed element re-painting at
   20fps competes with the compositor's scroll work. Resumes ~180ms
   after scroll ends (see onScrollTick in main.js). */
body.is-scrolling::after {
  animation-play-state: paused;
}

@keyframes bg-noise-shimmer {
  0%   { background-position:    0px    0px; }
  10%  { background-position:  -73px   41px; }
  20%  { background-position:   58px  -89px; }
  30%  { background-position: -127px  113px; }
  40%  { background-position:   91px  -47px; }
  50%  { background-position:  -39px -103px; }
  60%  { background-position:  121px   67px; }
  70%  { background-position: -101px -131px; }
  80%  { background-position:   47px  149px; }
  90%  { background-position:  -83px  -29px; }
  100% { background-position:    0px    0px; }
}

@media (prefers-reduced-motion: reduce) {
  body::after { animation: none; }
}

::selection { background: var(--color-fg); color: var(--color-bg); }

a {
  color: var(--color-link);
  text-decoration: none;
  transition: color 0.2s ease;
}
a:hover { color: var(--color-link-hover); }

/* ---------- Custom pixel cursors ----------
   Phosphor-green pixel arrow as the default; solid green block as the
   pointer for anything interactive. Hotspots: arrow at (0,0) = top-left
   tip; block centred at (7,7). !important is needed because individual
   elements throughout the stylesheet declare cursor: pointer with the
   same specificity but later in source order — without it, those
   later declarations win and the custom cursor never shows. */
html, body {
  cursor: var(--cursor-arrow) 0 0, default !important;
}
/* Buttons, links, and rail nav items get the reticle pointer — "click
   this control." */
a, button, [role="button"], summary, label, select,
input[type="submit"], input[type="button"], input[type="reset"],
.tile-close, .tile-play, .tile-nav, .tile-dot,
.tile-frame-link,
.sidebar-logo, .rail-nav__item, .menu-backdrop, .btn {
  cursor: var(--cursor-pointer) 12 12, pointer !important;
}
/* Project tiles in the work grid get the expand cursor — "click to
   open the full view." Expanded tiles (the open state) revert to the
   default arrow so they don't suggest further expansion. */
.tile {
  cursor: var(--cursor-expand) 12 12, zoom-in !important;
}
.tile--expanded, .tile--placeholder {
  cursor: var(--cursor-arrow) 0 0, default !important;
}

/* ---------- Typography ---------- */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-display);
  /* Space Grotesk at 500 has the right presence at hero sizes; 300 was
     a Departure-Mono-only weight and reads anemic in a proportional
     display face. Bump to 600/700 if hero needs more shout. */
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: 0.04em;
  margin: 0;
}

h1 { font-size: 90px; line-height: 104px; }
h2 { font-size: 70px; line-height: 82px; }
h3 { font-size: 44px; line-height: 54px; }
h4 { font-size: 28px; line-height: 38px; }
h5 { font-size: 21px; line-height: 29px; }
h6 { font-size: 16px; line-height: 24px; }

p { margin: 0 0 1rem; }

.eyebrow {
  font-family: var(--font-accent);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 3px;
  color: var(--color-muted);
  font-size: 12px;
  margin: 0 0 var(--s-3);
}

.lede { color: var(--color-muted); max-width: 60ch; font-size: 18px; }

/* ---------- Animation: opacity-only entries ---------- */
/*
 * All entry animations animate ONLY opacity, never transform or layout
 * properties. Transforms on tiles or the frame can cause iframes
 * (e.g. the Wonderment YouTube embed) to render with clipped contents
 * during the transform animation. Opacity is GPU-cheap, doesn't touch
 * layout, and CSS Grid's dense-flow packer doesn't care.
 */

@keyframes tile-fade-in {
  from { opacity: 0; }
  to   { opacity: 0.25; }
}

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

/* ----- Grid runner (Chrome-no-internet-style endless scroller) ------
   Sits along the bottom margin of the work grid as a decorative
   loop. The bottom of the runner strip is the ground — no separate
   dashed ground line, just the implied floor where the dino +
   obstacles' feet sit.

   Architecture:
     .grid-runner     — fixed-height strip, overflow hidden
     .runner-scene    — 200% wide; contains obstacles mirrored
                        across both halves; animates translateX
                        0 → -50% on a linear loop. The mirroring
                        means when the first half exits left, the
                        second half is in identical position — no
                        visual snap at loop reset
     .runner-obstacle — absolute children of .runner-scene,
                        positioned via inline `--x` (% of scene)
     .runner-dino     — fixed x position near the left edge.
                        Jumps are NOT a CSS infinite — JS adds
                        .is-jumping when an obstacle's screen
                        position enters the dino's trigger zone,
                        firing the one-shot jump keyframe so the
                        dino actually clears the cacti

   Placeholder visuals are phosphor outlines. To plug in real art,
   set `background-image: url(...)` on each .runner-* class and
   drop the `border`. */
.grid-runner {
  /* In-flow strip sitting between the work-grid-wrap and the
     footer. Because it's a real layout element (not absolutely
     positioned), its bottom edge IS the footer's top edge by
     virtue of being the previous sibling — no offset math, no
     positioned-ancestor dependency.

     The 1px phosphor border-bottom is the ground line: the dino +
     obstacles sit at the runner's content-bottom (their own
     bottom: 0), which is directly above the border. So their
     feet visually rest ON the line — the border IS the ground.

     Background matches .work-grid-wrap's composited colour: the
     base bg-2 PLUS a faint phosphor tint overlay (replicated via
     ::after below, identical animation so the flicker stays in
     sync). Without the overlay, the runner reads as a flat
     bg-2 panel while the wrap above it has the subtle phosphor
     wash from its own ::after — they look like different
     surfaces. With the overlay, they composite identically and
     read as one continuous grid surface.

     Height is 120px = (42px dino height + 70px jump apex height
     + 8px breathing room) — gives the dino's full jump arc
     clearance above the resting line without clipping at the
     runner's top.

     overflow: hidden is needed for the horizontal clipping (the
     2x-viewport .runner-scene would otherwise spill off the right
     edge as it scrolls). Browsers force overflow-y to match
     when the other axis is hidden, so we can't keep vertical
     visible — instead we just give the runner enough vertical
     room to contain the jump apex. */
  position: relative;
  width: 100%;
  height: 120px;
  background: var(--color-bg-2);
  border-bottom: 1px solid var(--color-link);
  overflow: hidden;
  pointer-events: none;
}
/* Phosphor flicker overlay — mirrors .work-grid-wrap::after so the
   runner composites to the same colour as the grid surface above.
   Same animation, same opacity, same colour — synced because both
   start at page load with identical 12s cycles. */
.grid-runner::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background: rgba(180, 255, 200, 0.025);
  animation: crt-flicker 12s steps(1) infinite;
}
@media (prefers-reduced-motion: reduce) {
  .grid-runner::after { animation: none; opacity: 0; }
}

/* ----- Runner game UI (play button / score / game over) -------------
   These chrome elements layer on top of the scrolling scene. Visibility
   is driven by state classes on .grid-runner — JS toggles between
   (none) → .is-playing → .is-gameover. */

/* Play CTA (visible in demo mode only) */
.runner-play {
  position: absolute;
  top: var(--s-2);
  right: var(--s-3);
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px 7px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--color-link);
  background: rgba(3, 14, 23, 0.65);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--color-link);
  border-radius: 999px;
  cursor: pointer;
  pointer-events: auto; /* override .grid-runner pointer-events: none */
  transition:
    background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
    color            220ms cubic-bezier(0.16, 1, 0.3, 1),
    border-color     220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.runner-play__hint {
  padding-left: 8px;
  border-left: 1px solid currentColor;
  opacity: 0.55;
}
.runner-play:hover,
.runner-play:focus-visible {
  background-color: var(--color-link);
  color: var(--color-bg);
  outline: none;
}

/* Score readout (visible in play mode only). Top-right of the runner
   in place of the "i want to play" button. */
.runner-score {
  position: absolute;
  top: var(--s-2);
  right: var(--s-3);
  z-index: 5;
  display: none;
  align-items: baseline;
  gap: 8px;
  padding: 8px 14px 7px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--color-link);
  background: rgba(3, 14, 23, 0.65);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid var(--color-link);
  border-radius: 999px;
}
.runner-score__label {
  opacity: 0.55;
}
.runner-score__value {
  font-variant-numeric: tabular-nums;
  min-width: 4ch;
  text-align: right;
}

/* Game-over overlay (visible when .is-gameover is set). Centred
   over the runner with a dim backdrop. */
.runner-gameover {
  position: absolute;
  inset: 0;
  z-index: 6;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 18px;
  background: rgba(0, 19, 10, 0.7);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  font-family: var(--font-accent);
  text-align: center;
  pointer-events: auto;
}
.runner-gameover__title {
  margin: 0;
  font-size: 18px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--color-watermelon);
  text-shadow:
    0 0 6px rgba(253, 92, 99, 0.7),
    0 0 14px rgba(253, 92, 99, 0.35);
}
.runner-gameover__score {
  margin: 0;
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--color-link);
}
.runner-gameover__score-label { opacity: 0.55; }
.runner-gameover__score-value {
  font-variant-numeric: tabular-nums;
  font-size: 14px;
}
.runner-restart {
  padding: 8px 16px 7px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--color-link);
  background: transparent;
  border: 1px solid var(--color-link);
  border-radius: 999px;
  cursor: pointer;
  transition:
    background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
    color            220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.runner-restart:hover,
.runner-restart:focus-visible {
  background-color: var(--color-link);
  color: var(--color-bg);
  outline: none;
}

/* State-driven visibility on .grid-runner */
.grid-runner.is-playing .runner-play   { display: none; }
.grid-runner.is-playing .runner-score  { display: inline-flex; }
.grid-runner.is-gameover .runner-play  { display: none; }
.grid-runner.is-gameover .runner-score { display: inline-flex; }
.grid-runner.is-gameover .runner-gameover {
  display: flex;
}

/* Freeze scrolling + bird flapping when game over. Animation is paused
   in place — the obstacles + birds hold their last position so the
   collision is visually obvious. */
.grid-runner.is-gameover .runner-scene,
.grid-runner.is-gameover .runner-bird {
  animation-play-state: paused;
}

/* Faster scroll in play mode — driven by the play-speed custom
   property that main.js computes from the runner's actual width.
   This keeps the absolute px/s rate constant regardless of viewport,
   so the jump arc timing stays calibrated correctly everywhere. */
.grid-runner.is-playing .runner-scene {
  animation-duration: var(--runner-play-s, 12s);
}
.runner-scene {
  position: absolute;
  inset: 0;
  width: 200%;
  /* Duration is computed by main.js from the runner's actual width
     so the scroll runs at a constant px/s regardless of viewport
     size (see DEMO_SPEED / PLAY_SPEED constants). Fallback values
     match what would render at a ~1500px viewport. */
  animation: runner-scroll var(--runner-demo-s, 18s) linear infinite;
}
@keyframes runner-scroll {
  from { transform: translateX(0);    }
  to   { transform: translateX(-50%); }
}

/* Per-obstacle positioning — each reads its horizontal position
   from inline `--x` (% of the 200%-wide scene). Feet anchored at
   the bottom of the strip = the implied ground. */
.runner-obstacle {
  position: absolute;
  bottom: 0;
  left: var(--x, 50%);
  display: block;
  /* Visual now comes from the inline <svg class="runner-art">
     inside each obstacle span — see the cactus / bird SVG paths
     in the HTML. Borders + bg-image left over from the placeholder
     boxes are removed. */
  color: var(--color-link);
}
.runner-cactus--sm { width: 14px; height: 26px; }
.runner-cactus--lg { width: 22px; height: 40px; }
.runner-bird {
  width: 32px;
  height: 22px;
  /* Birds fly higher — sit above the ground line, at jump-arc
     height so the dino can clear them. 36px (was 32) lifts the
     bird just above the standing dino\'s hit-box top so a casual
     stroll doesn\'t graze the bird\'s belly. */
  bottom: 36px;
  animation: runner-bird-flap 0.6s steps(2) infinite;
}
@keyframes runner-bird-flap {
  0%, 100% { transform: translateY(0);    }
  50%      { transform: translateY(-2px); }
}

/* Dinosaur — outside the scrolling scene so its x position is
   fixed while obstacles travel past it. No animation at rest —
   stands still on the ground line. JS toggles two state classes
   based on what's approaching:
     .is-jumping  — ground obstacle (cactus) in trigger zone;
                    fires the one-shot jump keyframe
     .is-ducking  — air obstacle (bird/cloud) in trigger zone;
                    flattens the dino so it passes under
   The duck uses a transition (not an animation) so it eases in
   when a bird enters the zone and eases out when it leaves. The
   jump uses an animation, which takes precedence over the
   transition while running — meaning the dino can't "duck-jump"
   visually even if both classes are briefly set. */
.runner-dino {
  position: absolute;
  bottom: 0;
  left: 8%;
  width: 36px;
  height: 42px;
  display: block;
  /* Visual now comes from the inline <svg class="runner-art">
     inside the dino span — see the SVG paths in the HTML. */
  color: var(--color-link);
  transform-origin: bottom center;
  transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Inline SVG silhouettes inside the dino + obstacle spans.
   They fill their parent and inherit colour (currentColor) so the
   phosphor green palette stays unified. preserveAspectRatio
   xMidYMax on cacti + dino keeps feet planted on the bottom edge
   regardless of any future aspect-ratio shifts. */
.runner-art {
  display: block;
  width: 100%;
  height: 100%;
  /* Tiny glow so the silhouettes match the rest of the CRT look. */
  filter: drop-shadow(0 0 1px rgba(92, 255, 142, 0.55));
}
.runner-dino.is-jumping {
  animation: runner-jump 0.7s cubic-bezier(0.33, 0, 0.4, 1);
}
.runner-dino.is-ducking {
  /* Squash to ~half height (clears a bird whose belly sits at
     32px from the ground — ducked dino tops at ~21px, leaving
     ~11px of clearance). Width unchanged; in the real Chrome
     game ducked sprites also widen, but with a placeholder box
     scaling X would look strange. When real SVG art lands, swap
     this for a background-image change to a ducking pose
     instead of the scaleY trick. */
  transform: scaleY(0.5);
}
@keyframes runner-jump {
  /* Quick rise to apex around 40%, controlled descent by 100%.
     Apex 80px = clears a 40px-tall cactus with 38px of clearance
     beneath the dino's feet at peak. Apex top is at 80+42=122px
     above ground; the runner is 120px tall, so the apex grazes
     the runner's top edge by 2px (clipped harmlessly under
     overflow: hidden — visually unnoticeable since the dino's
     box top is empty space). */
  0%   { transform: translateY(0);    }
  40%  { transform: translateY(-80px); }
  100% { transform: translateY(0);    }
}

@media (prefers-reduced-motion: reduce) {
  .runner-scene,
  .runner-bird,
  .runner-dino,
  .runner-dino.is-jumping,
  .runner-dino.is-ducking {
    animation: none;
    transition: none;
  }
}

/* Ambient body-copy glitch — JS swaps a single character out for a
   random glyph wrapped in this span, briefly, then restores. The
   span gets the canonical interactable phosphor green so the flicker
   reads as "signal interference" without softening the surrounding
   body copy with halo bloom. (Glow stays for headlines + hover
   states — body reading surfaces stay crisp.) A 3-step stutter
   keeps the brief 140ms appearance feeling alive — CRT/VHS rather
   than a clean color fade. */
.glitch-char {
  color: var(--color-link);
  animation: glitch-char-flicker 140ms steps(3) forwards;
}
@keyframes glitch-char-flicker {
  0%   { opacity: 1; }
  33%  { opacity: 0.45; }
  66%  { opacity: 1; }
  100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .glitch-char { animation: none; }
}

/* ----- Phosphor headline bloom (always on) ---------------------------
   Big text reads as luminous projection rather than ink. Subtle —
   the eye senses the glow more than reads it consciously. Applied
   to all major headlines + eyebrows across tile-frames, case
   studies, and panels. Body copy stays clean so dense reading
   surfaces aren't softened. */
.tile-frame-eyebrow,
.tile-frame-title,
.case-eyebrow,
.case-title,
.cv-divider,
.panel--about h1,
.panel--colophon h1 {
  text-shadow: var(--phosphor-glow-rest);
}

/* ----- Phosphor hover bloom on interactable text --------------------
   When the user engages with anything clickable, the text doesn't
   just change colour — it RADIATES. Reinforces the canonical
   "interactable = phosphor" principle: the moment of engagement
   is where the screen blooms back at the user. text-shadow gets a
   transition so the bloom fades in (and out) rather than snapping.

   CRITICAL: scope must exclude .tile (the project-card anchors).
   text-shadow is an INHERITED property — if we apply it to <a> on
   hover and the <a> is a tile card, the glow inherits down into
   every descendant text element, including the body description
   paragraph. That's exactly the bug we want to avoid: text links
   should bloom on hover, but a hovered tile-card surface should
   not silently broadcast text-shadow to its content. :not(.tile)
   keeps the rule applying to real text-link anchors only. */
a:not(.tile),
.panel-close__label,
.tile-frame-link,
.case-nav a,
.footer-stat a,
.footer-colophon,
.contact-direct a {
  transition: color 220ms ease, text-shadow 220ms ease;
}
a:not(.tile):hover,
a:not(.tile):focus-visible,
.tile-frame-link:hover,
.tile-frame-link:focus-visible,
.case-nav a:hover,
.case-nav a:focus-visible,
.footer-stat a:hover,
.footer-stat a:focus-visible,
.footer-colophon:hover,
.footer-colophon:focus-visible,
.contact-direct a:hover,
.contact-direct a:focus-visible {
  text-shadow: var(--phosphor-glow-hover);
}

/* JS sets the .with-anim class on <html> via an inline script in <body>.
   Without that class (e.g. JS disabled), tiles render at their natural state. */
.with-anim .tile { opacity: 0; }

/* Belt-and-braces lockout: hide tiles AND the sidebar entirely while
   the loader is playing. opacity: 0 alone isn't sufficient for tiles
   (children using mix-blend-mode can render past parent opacity), and
   the sidebar has been flashing briefly because nothing was gating
   its first paint. visibility: hidden suppresses the whole subtree
   from rendering regardless of compositing. Released the moment
   .loader-done lands on <html>. */
.with-anim:not(.loader-done) .tile,
.with-anim:not(.loader-done) .sidebar { visibility: hidden; }

/* Tile entry runs TWO animations in parallel:
     - tile-fade-in  (opacity 0 → 0.25, forwards-filled to lock the rest state)
     - tile-pop-in   (transform scale 0.75 → 1.0, NO forwards so the hover
                      transform takes over cleanly afterward)
   Gated on html.loader-done (set alongside the existing .with-anim) —
   tiles are queued with .tile--in as the IntersectionObserver fires
   during loader playback, but the animations only kick in once the
   loader removes itself and adds .loader-done. End result: loader
   plays out fully, then the grid pops into place. */
.with-anim.loader-done .tile.tile--in {
  animation:
    tile-fade-in 700ms cubic-bezier(0.16, 1, 0.3, 1) forwards,
    tile-pop-in 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes tile-pop-in {
  from { transform: scale(0.75); }
  to   { transform: scale(1);    }
}

/* Frame entry plays automatically every time .tile-frame is inserted.
   No transform animation, so the static translateX(-50%) centring stays.
   Skipped when the browser supports the View Transition API — the
   morph animation handles the entrance there, and a parallel fade-in
   would feel like double entrance. */
@media (prefers-reduced-motion: no-preference) {
  .tile-frame {
    animation: frame-fade-in 480ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
  }
  @supports (view-transition-name: a) {
    .tile-frame { animation: none; }
  }
}

/* ---------- View Transition tuning ----------
   When supported, the browser morphs the tile thumbnail into the
   expanded frame stage (and back on collapse). Default duration is
   ~250ms which feels rushed against the site's other 500-700ms
   animations — bump to 560ms with the house curve. */
::view-transition-group(active-tile) {
  animation-duration: 560ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-old(active-tile),
::view-transition-new(active-tile) {
  animation-duration: 560ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

/* ---------- Color cycle (logo, accent fills) ---------- */
@keyframes color-cycle-fill {
  0%   { fill: var(--neon-red); }
  25%  { fill: var(--neon-cyan); }
  50%  { fill: var(--neon-pink); }
  75%  { fill: var(--neon-mint); }
  100% { fill: var(--neon-red); }
}
@keyframes color-cycle-bg {
  0%   { background: var(--neon-red); }
  25%  { background: var(--neon-cyan); }
  50%  { background: var(--neon-pink); }
  75%  { background: var(--neon-mint); }
  100% { background: var(--neon-red); }
}
@keyframes color-cycle-color {
  0%   { color: var(--neon-red); }
  25%  { color: var(--neon-cyan); }
  50%  { color: var(--neon-pink); }
  75%  { color: var(--neon-mint); }
  100% { color: var(--neon-red); }
}

/* ---------- Header / nav ---------- */
.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s-3) var(--s-4);
  max-width: var(--content-max);
  margin: 0 auto;
  position: relative;
  z-index: 10;
}

.logo {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  text-decoration: none;
  color: var(--color-fg);
}

.logo-mark {
  width: 38px;
  height: 38px;
  display: block;
  animation: color-cycle-fill 40s infinite;
}
.logo:hover .logo-mark { animation: color-cycle-fill 1.6s infinite; }

.logo-name {
  font-family: var(--font-accent);
  font-weight: 400;
  font-size: 13px;
  letter-spacing: 3px;
  text-transform: uppercase;
  color: var(--color-fg);
}

.site-nav {
  display: flex;
  gap: var(--s-4);
}

.site-nav a {
  font-family: var(--font-accent);
  font-weight: 400;
  font-size: 13px;
  letter-spacing: 3px;
  text-transform: uppercase;
  color: var(--color-muted);
  position: relative;
  padding-bottom: 4px;
}

.site-nav a:hover { color: var(--color-fg); }

.site-nav a[aria-current="page"] {
  color: var(--color-fg);
}
.site-nav a[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  background: var(--neon-cyan);
  animation: color-cycle-bg 40s infinite;
}

/* ---------- Main wrapper ---------- */
main {
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 0 var(--s-4) var(--s-7);
}

/* ---------- Hero ---------- */
.hero {
  padding: var(--s-6) 0 var(--s-6);
  border-bottom: 2px solid var(--color-rule);
}

.hero-title {
  font-family: var(--font-display);
  font-size: clamp(64px, 11vw, 160px);
  line-height: 0.95;
  margin: 0 0 var(--s-4);
}

.hero-title .accent {
  display: inline-block;
  animation: color-cycle-color 40s infinite;
}

.hero-sub {
  max-width: 60ch;
  font-size: 18px;
  color: var(--color-muted);
  font-weight: 300;
  margin: 0 0 var(--s-4);
}

.hero-actions {
  display: flex;
  gap: var(--s-2);
  flex-wrap: wrap;
  align-items: center;
}

/* ---------- Buttons ---------- */
.btn {
  display: inline-block;
  padding: 14px 28px;
  font-family: var(--font-accent);
  font-weight: 700;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 2px;
  text-decoration: none;
  border-radius: var(--radius);
  border: 0;
  cursor: pointer;
  transition: 0.5s;
  user-select: none;
}

/* Gradient CTA ported from old site (.button-87) */
.btn-gradient {
  color: #fff;
  box-shadow: 0 0 14px -7px var(--neon-red);
  background-image: linear-gradient(45deg, #FF512F 0%, #16cefa 51%, #FF512F 100%);
  background-size: 200% auto;
}
.btn-gradient:hover { background-position: right center; color: #fff; }
.btn-gradient:active { transform: scale(0.96); }

.btn-ghost {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: transparent;
  color: var(--color-fg);
  border: 2px solid var(--color-rule);
  transition:
    color 0.18s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
    border-color 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-ghost::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-fg);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
  z-index: -1;
  pointer-events: none;
}
.btn-ghost:hover,
.btn-ghost:focus-visible {
  color: var(--color-bg);
  border-color: var(--color-fg);
}
.btn-ghost:hover::before,
.btn-ghost:focus-visible::before {
  transform: scaleX(1);
}

/* ---------- Section helpers ---------- */
.section-title {
  font-family: var(--font-display);
  font-size: clamp(36px, 5vw, 70px);
  line-height: 1.05;
  margin: 0 0 var(--s-4);
}

.page-intro {
  padding: var(--s-6) 0 var(--s-5);
  border-bottom: 2px solid var(--color-rule);
  margin-bottom: var(--s-5);
}
.page-intro h1 {
  font-size: clamp(48px, 8vw, 110px);
  line-height: 1;
  margin: 0 0 var(--s-3);
  max-width: 18ch;
}

/* ---------- Project grid (CSS Grid + dense auto-flow) ---------- */
/*
 * Native CSS Grid with `grid-auto-flow: dense`. Smaller tiles that
 * come later in DOM order are pulled back to fill earlier gaps —
 * guarantees a gap-free packed layout. No bin-packer library needed.
 * Default: 6 columns × 320px rows. Tile sizes use grid spans:
 *   1×1 (default), w2 = span 2 cols, w3 = span 3 cols, h2 = span 2 rows.
 */
.work-grid {
  list-style: none;
  padding: 0;
  margin: 0;
  width: 100%;
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  grid-auto-rows: 320px;
  grid-auto-flow: dense;
  gap: 0;
}

/* Legacy Packery sizing stubs — no longer used but kept inert in case
   the markup hasn't been cleaned up yet. */
.grid-sizer,
.gutter-sizer { display: none; }

.tile {
  position: relative;
  display: block;
  overflow: hidden;
  /* Perf: skip render/layout/paint for off-screen tiles. Pair with
     contain-intrinsic-size so the scrollbar doesn't jitter as tiles
     enter/leave view — 280px matches grid-auto-rows. */
  content-visibility: auto;
  contain-intrinsic-size: 280px 280px;
  /* Isolate blend modes inside the tile — without this, child elements
     using mix-blend-mode (e.g. the intro tile's video) can render as if
     their backdrop is the page root, which lets them bleed through
     higher-z overlays like the loader. */
  isolation: isolate;
  background-color: var(--color-bg-2);
  background-size: cover;
  background-position: center center;
  opacity: 0.32;
  /* Phosphor-screen rest state. Was a 4-step filter chain (grayscale
     + sepia + hue-rotate + saturate) re-applied per repaint on every
     tile; replaced with a single grayscale() filter plus a tinted
     overlay (.tile::after) that fades on hover. GPU-cheaper. */
  filter: grayscale(100%);
  /* Phased hover using the site's house curve (the same one used by
     loader, hero, panels, tile-fade-in). Both phases start at t=0 —
     opacity + filter finish at 320ms, transform + shadow + radius
     keep going to 560ms. The duration difference creates the lift-
     follows-colour effect without needing an explicit delay, which
     avoided the plateau/stutter the previous curve caused. */
  transition:
    opacity       320ms cubic-bezier(0.16, 1, 0.3, 1),
    filter        320ms cubic-bezier(0.16, 1, 0.3, 1),
    transform     560ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow    560ms cubic-bezier(0.16, 1, 0.3, 1),
    border-radius 560ms cubic-bezier(0.16, 1, 0.3, 1),
    grid-column   0.4s  cubic-bezier(0.22, 1, 0.36, 1),
    grid-row      0.4s  cubic-bezier(0.22, 1, 0.36, 1);
  text-decoration: none;
  color: var(--color-fg);
  cursor: pointer;
  border-radius: 4px;
  /* Default: 1 col × 1 row */
  grid-column: span 1;
  grid-row: span 1;
}

/* Phosphor tint overlay — sits on top of the grayscale tile and
   pushes the image toward phosphor green. Replaces the
   filter-chain approach which was expensive to repaint per tile
   per frame. mix-blend-mode: multiply with a phosphor-green tint
   approximates the old sepia + hue-rotate + saturate look. Fades
   to opacity 0 on hover at the same speed as the grayscale, so
   the tile returns to full colour cleanly. */
.tile::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-color: rgba(77, 255, 168, 0.55);
  mix-blend-mode: multiply;
  transition: opacity 320ms cubic-bezier(0.16, 1, 0.3, 1);
  opacity: 1;
  z-index: 1;
}
.tile:hover::after,
.tile:focus-visible::after,
.tile--expanded::after { opacity: 0; }

/* Placeholder tiles (terminal/games), the intro tile, and the
   contact-cta tile have their own visuals that shouldn't be
   tinted — skip the overlay entirely. */
.tile--placeholder::after,
.tile--intro::after,
.tile--contact-cta::after { display: none; }

.tile:hover,
.tile:focus-visible {
  /* !important needed to outrank the tile-fade-in animation's
     forwards-fill value (CSS animation values outrank regular declarations) */
  opacity: 1 !important;
  filter: grayscale(0%);
  transform: translateY(-4px) scale(1.02);
  /* Layered shadow: tight phosphor edge ring on the tile, then the
     existing black drop for the lift sensation, then a wide
     phosphor bloom that extends past the drop so the tile reads
     as "lit from behind" rather than just floating over the
     grid. The bloom is the moment of engagement — same green that
     interactable text glows in, applied to the surface itself. */
  /* Blur radii halved (33→16, 60→30) — paint cost of box-shadow
     transitions scales roughly with blur radius. Visual softening is
     subtle; the bloom still reads as "lit from behind". */
  box-shadow:
    0 0 0 1px rgba(92, 255, 142, 0.65),
    0 10px 16px 4px rgba(0, 0, 0, 0.75),
    0 0 30px 6px rgba(92, 255, 142, 0.4);
  border-radius: 14px;
  /* Above the sidebar (z-index: 250) so the tilt isn't cropped */
  z-index: 300;
}


/* Size variants — break up the rhythm using grid spans. */
.tile--w2  { grid-column: span 2; }
.tile--w3  { grid-column: span 3; }
.tile--h2  { grid-row: span 2; }
.tile--w2.tile--h2 { grid-column: span 2; grid-row: span 2; }

/* Expanded state — outer .tile takes the whole row (so the CSS Grid
   other tiles above/below), but stays transparent. The inner .tile-frame
   is what visually represents the expanded view, capped at a comfortable
   reading width and centred. */
.tile--expanded {
  /* 3 cols × 3 rows (~50% wide × ~960px tall). Sits inside the grid
     rather than taking the full row, so the other tiles wrap around
     it via grid-auto-flow: dense. Matches the contact tile's wrap
     behaviour for every tile in the grid. */
  grid-column: span 3 !important;
  grid-row: span 3 !important;
  height: auto !important;
  background-image: none !important;
  background-color: transparent;
  opacity: 1 !important;
  filter: none !important;
  transform: none !important;
  box-shadow: none !important;
  cursor: default;
  z-index: 6;
  border: none;
}
/* Responsive — fewer columns means the dialog needs to grow
   proportionally to stay roughly half the grid width */
@media (max-width: 1280px) {
  .tile--expanded { grid-column: span 2 !important; } /* 50% of 4 cols */
}
@media (max-width: 1120px) {
  .tile--expanded { grid-column: span 2 !important; } /* ~66% of 3 cols */
}
@media (max-width: 860px) {
  .tile--expanded { grid-column: 1 / -1 !important; } /* full row on small */
}


/* Decorative placeholder tiles — quiet panels that the CSS Grid
   dense-flow placer slots into any leftover gaps after the work
   tiles are placed. Diagonal
   hatch + dashed inner border so they read as filler rather than
   competing with project thumbnails. !important on opacity is needed
   to outrank the .tile-fade-in animation's forwards-fill value, and
   `animation: none` skips the fade-in itself. */
.tile--placeholder {
  background-color: var(--color-bg-2);
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0,
    transparent 10px,
    rgba(180, 255, 200, 0.10) 10px,
    rgba(180, 255, 200, 0.10) 11px
  ) !important;
  filter: none !important;
  opacity: 0.7 !important;
  cursor: default;
  pointer-events: none;
  position: relative;
  animation: none !important;
}
.tile--placeholder::before {
  content: "";
  position: absolute;
  inset: 12px;
  border: 1px dashed rgba(180, 255, 200, 0.30);
  border-radius: 4px;
  pointer-events: none;
}
.tile--placeholder:hover {
  filter: none !important;
  transform: none !important;
}

/* ----- Placeholder variant: Terminal log feed ----------------------
   Replaces the diagonal hatch with a miniature CRT terminal that
   streams fake system output. Each line types in character-by-
   character (driven by main.js), older lines scroll off the top
   when the visible buffer fills. The cursor blinks on the line
   currently being typed. */
.tile--placeholder--terminal {
  background-image: none !important;
  opacity: 1 !important;
}
.tile--placeholder--terminal::before {
  display: none;
}
.placeholder-terminal {
  position: absolute;
  inset: 14px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  overflow: hidden;
  padding: 10px 12px;
  border: 1px solid rgba(92, 255, 142, 0.22);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.18);
  font-family: var(--font-accent);
  font-size: 10px;
  line-height: 1.55;
  letter-spacing: 0.04em;
  color: var(--color-link);
  text-align: left;
  white-space: pre;
}
/* Individual line in the buffer. Default is full phosphor; .dim is
   used for sub-output (response codes, durations, status), .err for
   the very occasional pseudo-warning. flex-shrink: 0 keeps each line
   at its natural line-height even when the buffer briefly exceeds
   what fits — overflow at the top is clipped by the terminal's
   overflow: hidden rather than the lines being squished. */
.placeholder-terminal__line {
  display: block;
  flex-shrink: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: clip;
  /* Smooth scroll: each new line grows from 0 to its natural height,
     which (because the buffer uses justify-content: flex-end) causes
     all existing lines to glide upward instead of snapping. The
     animation runs once per line on insertion. max-height upper bound
     is comfortably above any single line's actual height. */
  animation: placeholder-terminal-line-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes placeholder-terminal-line-in {
  from { max-height: 0;     opacity: 0; }
  to   { max-height: 1.8em; opacity: 1; }
}
.placeholder-terminal__line--dim { opacity: 0.55; }
.placeholder-terminal__line--dim {
  /* The cascade-in animation ends at opacity:1; override per-type
     opacity so .dim lines settle at their dimmer rest state. */
  animation-name: placeholder-terminal-line-in-dim;
}
@keyframes placeholder-terminal-line-in-dim {
  from { max-height: 0;     opacity: 0; }
  to   { max-height: 1.8em; opacity: 0.55; }
}
.placeholder-terminal__line--err {
  color: var(--color-watermelon);
  opacity: 0.8;
  animation-name: placeholder-terminal-line-in-err;
}
@keyframes placeholder-terminal-line-in-err {
  from { max-height: 0;     opacity: 0; }
  to   { max-height: 1.8em; opacity: 0.8; }
}
@media (prefers-reduced-motion: reduce) {
  .placeholder-terminal__line { animation: none; }
}
/* Blinking block cursor at the end of the currently-typing line. */
.placeholder-terminal__cursor {
  display: inline-block;
  width: 0.55em;
  height: 1em;
  margin-left: 1px;
  vertical-align: -1px;
  background: var(--color-link);
  animation: placeholder-terminal-cursor 0.6s steps(2) infinite;
}
@keyframes placeholder-terminal-cursor {
  0%, 50%      { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .placeholder-terminal__cursor { animation: none; opacity: 1; }
}
/* ----- Contact CTA: inbox-row notification ------------
   The closed-state contact tile reads as a single row pulled out of
   a real mail client. Layout: header (sender + timestamp) → unread
   dot + bold subject → preview body → Reply hint. The four text
   fields rotate through a shuffled variants list every ~4.5s, with
   a cross-fade swap. A watermelon left-edge strip marks the row as
   "selected / unread" — the visual cue that lifts this tile out of
   the green sea of work tiles. */
.contact-notif {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 8px;
  padding: 14px 14px 12px 18px; /* extra-left to clear the accent strip */
  color: var(--color-fg);
  text-align: left;
  pointer-events: none;
  font-family: var(--font-accent);
  /* Soft float-in on mount + gentle every-few-seconds breathing nudge */
  animation:
    contact-notif-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) both,
    contact-notif-nudge 7s ease-in-out 1.6s infinite;
}
/* Left-edge accent strip — 3px watermelon bar marking the row as
   "selected / unread", like the active row in a mail client. Sits
   on the .contact-notif (not the tile) so it lives inside the tile's
   border and matches the inbox-row metaphor. */
.contact-notif::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: var(--color-watermelon);
  box-shadow: 0 0 8px rgba(253, 92, 99, 0.45);
  opacity: 0.9;
}

/* Header row — sender on the left, timestamp on the right. Muted
   like inbox-row chrome; truncates on overflow. */
.contact-notif__header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--color-muted);
  overflow: hidden;
}
.contact-notif__sender {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.contact-notif__time {
  flex-shrink: 0;
  opacity: 0.85;
}

/* Subject row — the unread dot + bold subject. This is the headline. */
.contact-notif__subject-row {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.contact-notif__dot {
  flex-shrink: 0;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--color-watermelon);
  box-shadow: 0 0 8px rgba(253, 92, 99, 0.75);
  animation: contact-notif-dot-pulse 1.6s ease-in-out infinite;
}
.contact-notif__subject {
  font-family: var(--font-accent);
  font-size: 13px;
  letter-spacing: 0.04em;
  text-transform: lowercase;
  font-weight: 700; /* unread = bold */
  color: var(--color-link);
  text-shadow: 0 0 6px rgba(92, 255, 142, 0.45);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

/* Preview body — two-line clamp; muted, like a real mail-row preview. */
.contact-notif__preview {
  flex: 1 1 auto;
  font-size: 11px;
  line-height: 1.45;
  letter-spacing: 0.04em;
  color: var(--color-fg);
  opacity: 0.7;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Reply hint pinned to the bottom-right, like a row's hover action. */
.contact-notif__action {
  align-self: flex-end;
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--color-link);
  opacity: 0.75;
  text-shadow: 0 0 6px rgba(92, 255, 142, 0.35);
}

/* Cross-fade rotation: when JS sets .is-switching on the notif, all
   four text fields fade out briefly. JS swaps the strings after the
   fade-out completes, then removes the class so the new copy fades in.
   The dot + accent strip stay put — visual anchors for the unread
   state. */
.contact-notif__sender,
.contact-notif__time,
.contact-notif__subject,
.contact-notif__preview {
  transition: opacity 220ms ease;
}
.contact-notif.is-switching .contact-notif__sender,
.contact-notif.is-switching .contact-notif__time,
.contact-notif.is-switching .contact-notif__subject,
.contact-notif.is-switching .contact-notif__preview {
  opacity: 0;
}

/* Hover state — the row lights up the way a focused inbox row would.
   Subject + Reply action glow harder; the tile itself already
   lifts/scales via .tile:hover, this is the inner inbox-row treatment. */
.tile--contact-cta:hover .contact-notif__subject,
.tile--contact-cta:focus-visible .contact-notif__subject {
  text-shadow: var(--phosphor-glow-hover);
}
.tile--contact-cta:hover .contact-notif__action,
.tile--contact-cta:focus-visible .contact-notif__action {
  opacity: 1;
  text-shadow: var(--phosphor-glow-hover);
}

@keyframes contact-notif-enter {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0);   }
}
/* Gentle every-few-seconds breathing — tiny lift of the whole row
   so it reads as a fresh notification in peripheral vision. */
@keyframes contact-notif-nudge {
  0%, 14%, 100% { transform: translateY(0);    }
  6%            { transform: translateY(-2px); }
}
@keyframes contact-notif-dot-pulse {
  0%, 100% {
    transform: scale(1);
    box-shadow: 0 0 6px rgba(253, 92, 99, 0.6);
  }
  50% {
    transform: scale(1.35);
    box-shadow: 0 0 12px rgba(253, 92, 99, 0.95);
  }
}
@media (prefers-reduced-motion: reduce) {
  .contact-notif      { animation: none; }
  .contact-notif__dot { animation: none; }
}

/* ----- Placeholder variant: Side-scrolling shoot-'em-up ----------
   An auto-played shoot-em-up that fills the wider placeholder slot.
   A predictive AI controls a small player ship on the left, dodging
   enemies and leading shots; enemies spawn from the right in waves
   of mixed types, with occasional boss waves and rare powerups.
   Cabinet layout mirrors the Tetris placeholder: shared bordered
   frame with the game stage on the left and a UI panel
   (score / lives / high score) on the right, divided by a 1px
   phosphor line. */
.tile--placeholder--shmup {
  background-image: none !important;
  opacity: 1 !important;
}
.tile--placeholder--shmup::before {
  display: none;
}
.placeholder-shmup {
  position: absolute;
  inset: 14px;
  display: flex;
  align-items: stretch;
  overflow: hidden;
}
.shmup-frame {
  flex: 1 1 auto;
  display: flex;
  align-items: stretch;
  border: 1px solid rgba(92, 255, 142, 0.22);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.22);
  overflow: hidden;
  box-sizing: border-box;
}
.shmup-stage-area {
  flex: 1 1 auto;
  position: relative;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  min-width: 0;
  border-right: 1px solid rgba(92, 255, 142, 0.22);
  box-sizing: border-box;
  overflow: hidden;
}
.placeholder-shmup .shmup-stage {
  display: block;
  width: 100%;
  height: 100%;
}
.shmup-ui {
  flex: 0 0 80px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px 10px;
  box-sizing: border-box;
  background: transparent;
  font-family: var(--font-accent);
  color: var(--color-link);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1;
}
.shmup-ui__item {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.shmup-ui__label {
  font-size: 7px;
  letter-spacing: 0.22em;
  opacity: 0.55;
}
.shmup-ui__value {
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  text-shadow: 0 0 4px rgba(92, 255, 142, 0.4);
}

/* Entity visuals — driven by JS, each frame redrawn into the stage
   SVG. Player triangle in pure phosphor; enemies in slightly muted
   variants so the player ship reads as the brightest thing on
   screen. Bullets are small dots with strong glow. */
.shmup-player {
  fill: var(--color-link);
  filter: drop-shadow(0 0 2px rgba(92, 255, 142, 0.85));
}
.shmup-player--invuln {
  /* Flashes during respawn invulnerability. */
  animation: shmup-invuln-flash 120ms steps(2) infinite;
}
@keyframes shmup-invuln-flash {
  0%, 100% { opacity: 1;   }
  50%      { opacity: 0.35; }
}
.shmup-bullet {
  fill: var(--color-link);
  filter: drop-shadow(0 0 1.5px rgba(92, 255, 142, 0.9));
}
.shmup-bullet--enemy {
  fill: var(--color-watermelon);
  filter: drop-shadow(0 0 1.5px rgba(253, 92, 99, 0.85));
}
/* Laser — cyan-shifted phosphor, brighter halo, reads as cold/hot.
   Shape is drawn longer in JS (drawBullet branch for kind === "laser"). */
.shmup-bullet--laser {
  fill: hsl(170, 100%, 70%);
  filter: drop-shadow(0 0 2.5px rgba(120, 255, 220, 1))
          drop-shadow(0 0 5px rgba(120, 255, 220, 0.5));
}
/* Wave — violet phosphor, single small dot. The sine path does the
   work of telling the player what mode they're in. */
.shmup-bullet--wave {
  fill: hsl(280, 95%, 78%);
  filter: drop-shadow(0 0 2px rgba(200, 130, 255, 0.95))
          drop-shadow(0 0 4px rgba(200, 130, 255, 0.45));
}
/* Homing — hot pink arrowhead. The curving path + rotated triangle
   tells the player the missile is steering. */
.shmup-bullet--homing {
  fill: hsl(325, 95%, 72%);
  filter: drop-shadow(0 0 2px rgba(255, 130, 200, 0.95))
          drop-shadow(0 0 5px rgba(255, 130, 200, 0.5));
}
.shmup-enemy {
  fill: none;
  stroke: var(--color-link);
  stroke-width: 1;
  stroke-linejoin: round;
  filter: drop-shadow(0 0 1px rgba(92, 255, 142, 0.6));
}
/* Hit-flash — enemy briefly flares white-tinged on every damage tick
   that didn't kill it. Bumps brightness + halo. CSS-only because the
   JS just toggles the class for one frame. */
.shmup-enemy.is-flashing {
  stroke: #fff !important;
  stroke-width: 1.3 !important;
  filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.9))
          drop-shadow(0 0 8px rgba(92, 255, 142, 0.6)) !important;
}
.shmup-enemy--grunt   { stroke: hsl(140, 100%, 65%); }
.shmup-enemy--zigzag  { stroke: hsl(165, 90%, 60%); }
.shmup-enemy--shooter { stroke: hsl(120, 90%, 60%); }
.shmup-enemy--tank    { stroke: hsl(80, 85%, 55%); stroke-width: 1.4; }
.shmup-enemy--boss {
  stroke: var(--color-watermelon);
  stroke-width: 1.5;
  filter: drop-shadow(0 0 3px rgba(253, 92, 99, 0.8));
}
.shmup-powerup {
  fill: none;
  stroke: hsl(50, 100%, 65%);
  stroke-width: 1.2;
  filter: drop-shadow(0 0 2px rgba(255, 220, 80, 0.75));
}
.shmup-explosion {
  fill: var(--color-link);
  filter: drop-shadow(0 0 3px rgba(92, 255, 142, 1));
}
.shmup-explosion--big {
  filter: drop-shadow(0 0 5px rgba(92, 255, 142, 1));
}
.shmup-star {
  fill: rgba(180, 255, 200, 0.4);
}

/* Event text overlay — "WAVE 2", "BOSS!", "GAME OVER" etc. */
.shmup-event-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(1);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-link);
  text-shadow:
    0 0 6px rgba(92, 255, 142, 0.8),
    0 0 14px rgba(92, 255, 142, 0.35);
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}
.shmup-event-text--danger {
  color: var(--color-watermelon);
  font-size: 20px;
  text-shadow:
    0 0 8px rgba(253, 92, 99, 0.9),
    0 0 18px rgba(253, 92, 99, 0.45);
}
.shmup-event-text.is-showing {
  animation: shmup-event-pop 1400ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes shmup-event-pop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.6); }
  18%  { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
  30%  { transform: translate(-50%, -50%) scale(1);    }
  78%  { opacity: 1; transform: translate(-50%, -50%) scale(1);    }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
  .shmup-event-text.is-showing { animation: none; opacity: 1; }
  .shmup-player--invuln { animation: none; }
}

/* Tetris tile is now interactive — overrides .tile--placeholder
   defaults so it actually receives clicks + a pointer cursor.
   Closed state: AI plays itself (as before). Expanded: keyboard. */
.tile--placeholder--tetris {
  pointer-events: auto;
  cursor: pointer;
}
.tile--placeholder--tetris:hover {
  filter: drop-shadow(0 0 8px rgba(92, 255, 142, 0.4));
}
.tile--placeholder--tetris.tile--expanded {
  pointer-events: auto;
  cursor: default;
}
.tile--placeholder--tetris.tile--expanded .tetris-frame {
  border-color: rgba(92, 255, 142, 0.5);
  box-shadow: 0 0 0 1px rgba(92, 255, 142, 0.3),
              0 12px 32px rgba(0, 0, 0, 0.6),
              0 0 50px rgba(92, 255, 142, 0.25);
}
/* Close button — hidden unless expanded. Specificity override to
   beat the later .tile-close { display: inline-flex } rule. */
.tile-close.tetris-close { display: none; }
.tile--placeholder--tetris.tile--expanded .tile-close.tetris-close {
  display: inline-flex;
}

/* Controls hint — overlay strip at the bottom of the cabinet, only
   visible in play mode. Mirrors the asteroids hint. flex-wrap so it
   can flow onto a second line in narrower configurations. */
.tetris-controls-hint {
  position: absolute;
  bottom: 8px;
  left: 0;
  right: 0;
  display: none;
  justify-content: center;
  gap: 18px;
  flex-wrap: wrap;
  padding: 0 12px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-link);
  pointer-events: none;
  z-index: 3;
  opacity: 0.9;
}
.tile--placeholder--tetris.tile--expanded .tetris-controls-hint {
  display: flex;
}
.tetris-key-group {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.tetris-key-group kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  padding: 0 7px;
  border: 1px solid rgba(92, 255, 142, 0.55);
  border-radius: 3px;
  font-family: inherit;
  font-size: 11px;
  letter-spacing: 0;
  background: rgba(0, 0, 0, 0.4);
  color: var(--color-link);
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.4);
}
/* Push the cabinet up a bit when the hint is showing so the hint
   doesn't overlap the cabinet's bottom edge. */
.tile--placeholder--tetris.tile--expanded .tetris-frame {
  margin-bottom: 36px;
}

/* Shmup tile is now interactive — overrides .tile--placeholder
   defaults so it actually receives clicks + a pointer cursor.
   Closed state: AI plays itself (as before). Expanded: keyboard. */
.tile--placeholder--shmup {
  pointer-events: auto;
  cursor: pointer;
}
.tile--placeholder--shmup:hover {
  filter: drop-shadow(0 0 8px rgba(92, 255, 142, 0.4));
}
.tile--placeholder--shmup.tile--expanded {
  pointer-events: auto;
  cursor: default;
}
.tile--placeholder--shmup.tile--expanded .shmup-frame {
  border-color: rgba(92, 255, 142, 0.5);
  box-shadow: 0 0 0 1px rgba(92, 255, 142, 0.3),
              0 12px 32px rgba(0, 0, 0, 0.6),
              0 0 50px rgba(92, 255, 142, 0.25);
}
/* Close button — hidden unless expanded. Specificity override to
   beat the later .tile-close { display: inline-flex } rule. */
.tile-close.shmup-close { display: none; }
.tile--placeholder--shmup.tile--expanded .tile-close.shmup-close {
  display: inline-flex;
}

/* Controls hint — overlay strip at the bottom of the cabinet, only
   visible in play mode. Same chip styling as Tetris + Asteroids. */
.shmup-controls-hint {
  position: absolute;
  bottom: 8px;
  left: 0;
  right: 0;
  display: none;
  justify-content: center;
  gap: 22px;
  flex-wrap: wrap;
  padding: 0 14px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-link);
  pointer-events: none;
  z-index: 3;
  opacity: 0.9;
}
.tile--placeholder--shmup.tile--expanded .shmup-controls-hint {
  display: flex;
}
.shmup-key-group {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.shmup-key-group kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  padding: 0 7px;
  border: 1px solid rgba(92, 255, 142, 0.55);
  border-radius: 3px;
  font-family: inherit;
  font-size: 11px;
  letter-spacing: 0;
  background: rgba(0, 0, 0, 0.4);
  color: var(--color-link);
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.4);
}
/* Push the cabinet up a bit when the hint is showing so the hint
   doesn\'t overlap the cabinet\'s bottom edge. */
.tile--placeholder--shmup.tile--expanded .shmup-frame {
  margin-bottom: 36px;
}

/* ----- Placeholder variant: Asteroids auto-player ----------------
   Vector-neon arena with a momentum-physics ship dodging and
   shooting jagged-polygon asteroids. Same cabinet chrome as the
   shmup (stage area + UI panel), same colour vocabulary. */
.tile--placeholder--asteroids {
  background-image: none !important;
  opacity: 1 !important;
}
.tile--placeholder--asteroids::before { display: none; }

.placeholder-asteroids {
  position: absolute;
  inset: 14px;
  display: flex;
  align-items: stretch;
  overflow: hidden;
}
.asteroids-frame {
  flex: 1 1 auto;
  display: flex;
  align-items: stretch;
  border: 1px solid rgba(92, 255, 142, 0.22);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.22);
  overflow: hidden;
  box-sizing: border-box;
}
.asteroids-stage-area {
  flex: 1 1 auto;
  position: relative;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  min-width: 0;
  border-right: 1px solid rgba(92, 255, 142, 0.22);
  box-sizing: border-box;
  overflow: hidden;
}
.placeholder-asteroids .asteroids-stage {
  display: block;
  width: 100%;
  height: 100%;
}
.asteroids-ui {
  flex: 0 0 80px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px 10px;
  box-sizing: border-box;
  background: transparent;
  font-family: var(--font-accent);
  color: var(--color-link);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1;
}
.asteroids-ui__item {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.asteroids-ui__label {
  font-size: 7px;
  letter-spacing: 0.22em;
  opacity: 0.55;
}
.asteroids-ui__value {
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  text-shadow: 0 0 4px rgba(92, 255, 142, 0.4);
}

/* Entity visuals — vector-neon line-art, same vocabulary as the
   refreshed shmup. Ship glows brighter than asteroids so the
   player's craft reads as the protagonist. */
.asteroids-ship {
  fill: none;
  stroke: var(--color-link);
  stroke-width: 1;
  stroke-linejoin: round;
  filter: drop-shadow(0 0 2px rgba(92, 255, 142, 0.7));
}
.asteroids-ship--invuln {
  animation: asteroids-invuln-flash 120ms steps(2) infinite;
}
@keyframes asteroids-invuln-flash {
  0%, 100% { opacity: 1;   }
  50%      { opacity: 0.35; }
}
.asteroids-ship-thrust {
  stroke: hsl(50, 100%, 70%);
  filter: drop-shadow(0 0 2px rgba(255, 220, 80, 0.7));
}
.asteroids-rock {
  fill: none;
  stroke: hsl(120, 80%, 60%);
  stroke-width: 1;
  stroke-linejoin: round;
  filter: drop-shadow(0 0 1px rgba(92, 255, 142, 0.4));
}
.asteroids-bullet {
  fill: var(--color-link);
  filter: drop-shadow(0 0 1.5px rgba(92, 255, 142, 0.9));
}
.asteroids-explosion {
  fill: none;
  stroke: var(--color-link);
  stroke-width: 0.8;
  filter: drop-shadow(0 0 2px rgba(92, 255, 142, 0.8));
}
.asteroids-explosion--big {
  stroke-width: 1.1;
  filter: drop-shadow(0 0 3px rgba(92, 255, 142, 1));
}

/* Event text overlay — "WAVE 2", "GAME OVER" etc. */
.asteroids-event-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(1);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-link);
  text-shadow:
    0 0 6px rgba(92, 255, 142, 0.8),
    0 0 14px rgba(92, 255, 142, 0.35);
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}
.asteroids-event-text--danger {
  color: var(--color-watermelon);
  font-size: 20px;
  text-shadow:
    0 0 8px rgba(253, 92, 99, 0.9),
    0 0 18px rgba(253, 92, 99, 0.45);
}
.asteroids-event-text.is-showing {
  animation: asteroids-event-pop 1400ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes asteroids-event-pop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.6); }
  18%  { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
  30%  { transform: translate(-50%, -50%) scale(1);    }
  78%  { opacity: 1; transform: translate(-50%, -50%) scale(1);    }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
  .asteroids-event-text.is-showing { animation: none; opacity: 1; }
  .asteroids-ship--invuln { animation: none; }
}
/* Asteroids tile is the one interactive placeholder. Override the
   .tile--placeholder defaults so it actually receives clicks + a
   pointer cursor. */
.tile--placeholder--asteroids {
  pointer-events: auto;
  cursor: pointer;
}
.tile--placeholder--asteroids:hover {
  /* Slightly brighter ring on hover so it reads as clickable. */
  filter: drop-shadow(0 0 8px rgba(92, 255, 142, 0.4));
}
/* When expanded into play mode the tile gets the standard
   .tile--expanded 2x2 grid span; we just need to make sure the
   game stage scales correctly. The internal .placeholder-asteroids
   container already uses inset:14px so it fills the bigger
   container naturally. */
.tile--placeholder--asteroids.tile--expanded {
  pointer-events: auto;
  cursor: default;
}
.tile--placeholder--asteroids.tile--expanded .asteroids-frame {
  /* Stronger border in play mode so it reads as the active dialog. */
  border-color: rgba(92, 255, 142, 0.5);
  box-shadow: 0 0 0 1px rgba(92, 255, 142, 0.3),
              0 12px 32px rgba(0, 0, 0, 0.6),
              0 0 50px rgba(92, 255, 142, 0.25);
}
/* Close button is the standard .tile-close chip but here it lives
   statically in the markup rather than being created by buildFrame.
   Hide unless the tile is expanded. Specificity matters — the
   later-defined `.tile-close { display: inline-flex }` rule would
   otherwise override a plain `.asteroids-close { display: none }`.
   Compounding both classes raises the selector\'s specificity
   above the bare `.tile-close` rule. */
.tile-close.asteroids-close { display: none; }
.tile--placeholder--asteroids.tile--expanded .tile-close.asteroids-close {
  display: inline-flex;
}

/* Controls hint — overlay at the bottom of the stage area, only
   visible when the tile is expanded (human play mode). Shows the
   keyboard layout so the visitor knows what to press. */
.asteroids-controls-hint {
  position: absolute;
  bottom: 14px;
  left: 0;
  right: 0;
  display: none;            /* shown via .tile--expanded rule below */
  justify-content: center;
  gap: 22px;
  flex-wrap: wrap;
  padding: 0 14px;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-link);
  pointer-events: none;
  z-index: 3;
  opacity: 0.9;
}
.tile--placeholder--asteroids.tile--expanded .asteroids-controls-hint {
  display: flex;
}
.asteroids-key-group {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.asteroids-key-group kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  padding: 0 7px;
  border: 1px solid rgba(92, 255, 142, 0.55);
  border-radius: 3px;
  font-family: inherit;
  font-size: 11px;
  letter-spacing: 0;
  background: rgba(0, 0, 0, 0.4);
  color: var(--color-link);
  box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.4);
}

/* ----- Placeholder variant: Tetris auto-player --------------------
   A small phosphor grid where tetrominoes drop one at a time and
   stack at the bottom on a pre-choreographed sequence. After the
   full sequence completes, the grid clears with a brief flash and
   the sequence restarts. SVG rectangles are generated by main.js;
   CSS just sets the frame chrome and the cell visual. */
.tile--placeholder--tetris {
  background-image: none !important;
  opacity: 1 !important;
}
.tile--placeholder--tetris::before {
  display: none;
}
.placeholder-tetris {
  /* Outer positioning container. The framed cabinet itself is
     .tetris-frame inside this. */
  position: absolute;
  inset: 14px;
  display: flex;
  align-items: stretch;
  overflow: hidden;
}
/* Bordered cabinet wrapping both panels. Stage and UI share this
   single frame with a 1px phosphor divider between them. */
.tetris-frame {
  flex: 1 1 auto;
  display: flex;
  align-items: stretch;
  border: 1px solid rgba(92, 255, 142, 0.22);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.22);
  overflow: hidden;
  box-sizing: border-box;
}
/* Stage area — wraps the SVG grid plus the absolute-positioned
   clear-type text overlay. Takes the divider so the SVG itself
   doesn't need to know about its neighbour panel. */
.tetris-stage-area {
  flex: 1 1 auto;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  border-right: 1px solid rgba(92, 255, 142, 0.22);
  box-sizing: border-box;
}
.placeholder-tetris .tetris-stage {
  display: block;
  height: 100%;
  width: auto;
  max-width: 100%;
}
/* Clear-type text overlay — appears centred over the grid when a
   line clear happens. Different sizes / colours for single vs
   tetris so the player gets a satisfying readout per clear type. */
.tetris-clear-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(1);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-link);
  text-shadow:
    0 0 6px rgba(92, 255, 142, 0.8),
    0 0 14px rgba(92, 255, 142, 0.35);
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}
/* Tetris (4-line) clear — bigger, watermelon-tinted, more dramatic
   shadow. Reuses the destructive accent so the moment reads as
   high-impact and rare. */
.tetris-clear-text--tetris {
  color: var(--color-watermelon);
  font-size: 20px;
  letter-spacing: 0.14em;
  text-shadow:
    0 0 8px rgba(253, 92, 99, 0.9),
    0 0 18px rgba(253, 92, 99, 0.45);
}
.tetris-clear-text.is-showing {
  animation: tetris-clear-pop 1100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes tetris-clear-pop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.55); }
  18%  { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
  28%  { transform: translate(-50%, -50%) scale(1);    }
  70%  { opacity: 1; transform: translate(-50%, -50%) scale(1);    }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
  .tetris-clear-text.is-showing { animation: none; opacity: 1; }
}
/* Side UI panel — score / lines / level / next. No own border or
   background; the .tetris-frame provides the chrome. */
.tetris-ui {
  flex: 0 0 80px;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 10px;
  padding: 12px 10px;
  box-sizing: border-box;
  background: transparent;
  font-family: var(--font-accent);
  color: var(--color-link);
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1;
}
.tetris-ui__item {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.tetris-ui__label {
  font-size: 7px;
  letter-spacing: 0.22em;
  opacity: 0.55;
}
.tetris-ui__value {
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  text-shadow: 0 0 4px rgba(92, 255, 142, 0.4);
}
.tetris-ui__item--next .tetris-next {
  display: block;
  width: 100%;
  max-width: 56px;
  height: 32px;
  margin-top: 2px;
}
/* Cell visual — solid fill, no internal stroke. Different shape types
   get different phosphor shades (set via .tetris-cell--{TYPE}
   classes) so when pieces lock into the stack they remain visually
   distinguishable. All cells share a soft phosphor drop-shadow for
   the CRT glow. Cells touch with no gap and no rounded corners, so
   each piece reads as one solid shape rather than four separate
   squares. */
.tetris-cell {
  fill: var(--color-link); /* default for any cell without a type class */
  filter: drop-shadow(0 0 1px rgba(92, 255, 142, 0.45));
}
/* Per-shape shades — all within the phosphor-green family but
   varied in hue + lightness so each tetromino reads as its own
   colour. Tones modelled loosely on classic Tetris colour
   assignments but pulled into a green palette to match the CRT
   theme. */
.tetris-cell--I { fill: hsl(140, 100%, 72%); }  /* I — bright phosphor */
.tetris-cell--O { fill: hsl(80,  78%, 58%); }   /* O — yellow-green */
.tetris-cell--T { fill: hsl(120, 85%, 55%); }   /* T — vivid green */
.tetris-cell--L { fill: hsl(160, 90%, 65%); }   /* L — mint */
.tetris-cell--J { fill: hsl(150, 65%, 48%); }   /* J — deep green */
.tetris-cell--S { fill: hsl(100, 90%, 62%); }   /* S — lime */
.tetris-cell--Z { fill: hsl(175, 80%, 55%); }   /* Z — teal */

/* Subtle internal texture via stacked drop-shadows on alternating
   shapes — gives a faint pixel-art "lit" feel without overpowering
   the solid fill. Some shapes (T, L, Z) get a slightly heavier glow
   so they read as having more "presence" than the others. */
.tetris-cell--T,
.tetris-cell--L,
.tetris-cell--Z {
  filter:
    drop-shadow(0 0 0.8px rgba(92, 255, 142, 0.6))
    drop-shadow(0 0 2px rgba(92, 255, 142, 0.25));
}

/* The currently-falling piece reads slightly brighter so the user's
   eye can track which piece is active vs the locked stack. */
.tetris-cell--active {
  filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6))
          drop-shadow(0 0 4px rgba(92, 255, 142, 0.7));
}
/* Cells in rows about to be cleared — flash white for the duration
   of the clear animation before the row is removed and the stack
   shifts down. Override fill via !important so type-class fills
   don't compete. */
.tetris-cell--clearing {
  fill: rgba(255, 255, 255, 0.95) !important;
  filter: drop-shadow(0 0 4px rgba(92, 255, 142, 1));
}
/* Ghost piece — translucent outline at the projected landing
   position. Filled with a low-opacity phosphor so it reads as
   "this is where the piece will go" without competing visually
   with the active piece. */
.tetris-cell--ghost {
  fill: rgba(92, 255, 142, 0.18) !important;
  stroke: rgba(92, 255, 142, 0.5);
  stroke-width: 0.4;
  filter: none;
}
/* Clear flash — applied to the SVG when the grid is about to clear
   between cycles. Phosphor bloom across all cells before they
   vanish. */
.placeholder-tetris .tetris-stage.is-clearing {
  animation: tetris-clear 600ms ease-out forwards;
}
@keyframes tetris-clear {
  0%   { filter: brightness(1)   drop-shadow(0 0 0 rgba(92, 255, 142, 0)); }
  40%  { filter: brightness(1.8) drop-shadow(0 0 8px rgba(92, 255, 142, 0.9)); }
  100% { filter: brightness(0.4) drop-shadow(0 0 0 rgba(92, 255, 142, 0)); opacity: 0.6; }
}
@media (prefers-reduced-motion: reduce) {
  .placeholder-tetris .tetris-stage.is-clearing { animation: none; }
}

/* Contact CTA tile — a quiet "get in touch" card that sits amongst
   the work tiles. The closed thumb shows a live terminal feed (no
   label — the terminal IS the visual). On click it expands to a
   standard tile-frame with eyebrow / title / description / "Get in
   touch" mailto link. No system-message dialog chrome — the closed
   tile's terminal is its character, and the expanded view is honest
   about being a plain contact card. */
.tile--contact-cta {
  background-color: var(--color-bg-2) !important;
  background-image: none !important;
  filter: none !important;
  opacity: 1 !important;
  /* Subtle border so this tile reads as slightly different from the
     work tiles — pairs with the terminal feed as the "different
     character" treatment without needing extra chrome. */
  border: 1.5px solid var(--color-muted);
}
.tile--contact-cta:hover {
  filter: none !important;
}

/* Closed-state caption — was the cycling-message body area below
   a SYSTEM MESSAGE title bar; now it fills the whole tile and hosts
   the terminal feed directly. */
.tile--contact-cta .tile-caption {
  position: absolute;
  inset: 0;
  display: block;
  padding: 0;
  background: transparent;
  opacity: 1;
  transform: none;
}

/* Expanded — 2 cols × 2 rows, matching the intro tile. Uses the
   default .tile-frame styling, lightly themed to read as an "opened
   message" — envelope glyph beside the eyebrow, Reply ↩ CTA — so
   the closed-state "you've got mail" notification pays off when the
   tile opens. The structure is unchanged from a normal expanded
   tile; just the paint job follows through on the metaphor. */
.tile--contact-cta.tile--expanded {
  grid-column: span 2 !important;
  grid-row: span 2 !important;
  height: auto !important;
}
/* Email-header treatment on the eyebrow. The text content comes from
   data-discipline ("from · ben pattison · 09:32"); we just prepend
   an envelope glyph so it reads as a mail meta-line. */
.tile--contact-cta.tile--expanded .tile-frame-eyebrow::before {
  content: "";
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-right: 8px;
  vertical-align: -2px;
  background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='2' y='5' width='20' height='14' rx='1.5'/><path d='M2 6.5l10 7 10-7'/></svg>") center/contain no-repeat;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='2' y='5' width='20' height='14' rx='1.5'/><path d='M2 6.5l10 7 10-7'/></svg>") center/contain no-repeat;
  flex-shrink: 0;
}
/* Slight email-header divider — a thin rule under the eyebrow that
   visually separates "header" from "body" like a real email
   client. Subtle, just enough to suggest the layout. */
.tile--contact-cta.tile--expanded .tile-frame-eyebrow {
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(92, 255, 142, 0.18);
  margin-bottom: 10px;
}
/* The "Reply ↩" CTA button on contact-cta expanded gets a slightly
   more pronounced treatment so it reads as the primary action,
   mirroring how a real mail client makes Reply the headline
   button. */
.tile--contact-cta.tile--expanded .tile-frame-link {
  font-weight: 600;
  letter-spacing: 0.18em;
}

.tile--expanded:hover {
  transform: none !important;
  box-shadow: none !important;
  /* Hold the resting z-index on hover. Without this, .tile:hover
     would bump the expanded tile to z-index 300 — jumping it ABOVE
     the body::after noise overlay (z 99). The noise then no longer
     paints over the tile, and the bg loses its faint phosphor tint
     mid-interaction. Reads as the bg darkening as the cursor moves
     across the open tile. Locking z-index keeps the noise overlay
     above the tile consistently. */
  z-index: 6 !important;
}

/* Intro tile — pinned to first position. Closed: 1×1 thumbnail with
   a looping profile video. Open: 2×2 dialog with the same video and
   the bio/CTA. Always full-colour (skips the default tile grayscale). */
.tile--intro {
  filter: none !important;
  opacity: 1 !important;
}
.tile--intro:hover { filter: none !important; }
.tile--intro .tile-thumb-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Screen blend drops out the dark pixels of the video so only the
     lighter parts of the figure show against the green tile bg —
     gives the thumb a phosphor-glow CRT feel. */
  mix-blend-mode: screen;
  pointer-events: none;
  z-index: 1;
}
/* Same screen-blend treatment on the expanded view's video */
.tile--intro .tile-frame-stage video {
  mix-blend-mode: screen;
}
/* When expanded, the .tile-frame plays its own video — hide the
   thumbnail loop so we don't have two videos running in parallel. */
.tile--intro.tile--expanded .tile-thumb-video { display: none; }

/* Override the default full-row expanded sizing — intro stays inset
   at 2 cols × 2 rows so it sits cleanly at the top-left of the grid. */
.tile--intro.tile--expanded {
  grid-column: span 2 !important;
  grid-row: span 2 !important;
  height: auto !important;
}

/* About-me layout overrides — text left-aligned, video left-aligned,
   no green bg behind the video so it sits flat on the dialog panel. */
.tile--intro.tile--expanded .tile-frame-stage {
  justify-content: flex-start;   /* video sits to the left, not centred */
}
.tile--intro.tile--expanded .tile-frame-stage video.tile-media,
.tile--intro.tile--expanded .tile-frame-stage img.tile-media {
  background: transparent;       /* drop the green panel behind the media */
}
.tile--intro.tile--expanded .tile-frame-footer {
  align-items: flex-start;       /* text content stacks to the left */
  text-align: left;
}
.tile--intro.tile--expanded .tile-frame-textwrap {
  align-items: flex-start;
  text-align: left;
}
.tile--intro.tile--expanded .tile-frame-link {
  align-self: flex-end;          /* "More about me" button pushed to the right */
}

/* Frame layout — CSS grid lets us flip between landscape (default,
   stacked) and portrait (side-by-side) without restructuring HTML.
   Capped at --expanded-max-width and centred. Inset from the outer
   .tile container so there's visual breathing room on every side, with
   a grey keyline + drop shadow to lift it off the page. */
.tile-frame {
  position: absolute;
  top: var(--s-3);
  bottom: var(--s-3);
  left: 50%;
  transform: translateX(-50%);
  width: calc(100% - 2 * var(--s-3));
  max-width: var(--expanded-max-width, 1400px);
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  grid-template-columns: minmax(0, 1fr);
  background: var(--color-bg-2);
  /* No static keyline — the rainbow stroke pseudo-element below is the
     visible edge treatment around the open frame. */
  border-radius: 12px;
  box-shadow:
    0 24px 64px 12px rgba(0, 0, 0, 0.55), /* main drop with spread */
    0 4px 12px 0 rgba(0, 0, 0, 0.35);     /* subtle ambient close */
  overflow: hidden;
}

/* Static keyline border around the open frame at rest — calm 2px
   rule. Replaced by an animated conic-gradient ring when the tile
   is in its .tile--expanded state (see below). */
.tile-frame::before {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px solid var(--color-rule);
  border-radius: inherit;
  box-sizing: border-box;
  pointer-events: none;
  z-index: 5; /* above stage and footer; doesn't intercept clicks */
}

/* Register --tile-border-angle as a typed CSS custom property so
   it can be smoothly interpolated by @keyframes. Without @property
   the browser treats custom properties as raw strings and animates
   them in discrete jumps — the conic gradient would visually
   stutter instead of rotating. */
@property --tile-border-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

/* Animated phosphor ring on .tile--expanded — a bright phosphor
   "comet" travels around the frame's perimeter on a 5s loop.
   Implementation:
     1. Conic-gradient with a soft phosphor sweep occupying ~120°
        of the wheel, transparent everywhere else.
     2. Two stacked masks (one on content-box, one on the default
        border-box) composited with `exclude` produce a ring that
        shows the gradient only in the outer ~3px band — the
        inside of the box stays clear of the gradient.
     3. Animate --tile-border-angle 0deg → 360deg → infinite loop.
     4. drop-shadow filter adds a phosphor halo outside the ring
        edge so the comet reads as glowing, not just coloured. */
.tile--expanded .tile-frame::before {
  border: 0;
  padding: 1.5px;
  background: conic-gradient(
    from var(--tile-border-angle),
    rgba(92, 255, 142, 0)    0deg,
    rgba(92, 255, 142, 0)    240deg,
    rgba(92, 255, 142, 0.25) 275deg,
    rgba(92, 255, 142, 1)    310deg,
    rgba(92, 255, 142, 0.25) 345deg,
    rgba(92, 255, 142, 0)    360deg
  );
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
          mask-composite: exclude;
  filter: drop-shadow(0 0 3px rgba(92, 255, 142, 0.5))
          drop-shadow(0 0 7px rgba(92, 255, 142, 0.2));
  animation: tile-border-spin 5s linear infinite;
}

@keyframes tile-border-spin {
  from { --tile-border-angle: 0deg;   }
  to   { --tile-border-angle: 360deg; }
}
/* Border-spin reset hook — JS toggles this class on .tile-frame
   when the slideshow advances, killing the animation for one paint
   so the browser starts a fresh instance from 0deg. Result: the
   gradient comet syncs with each slide change, acting as a
   progress-ring for the current slide rather than drifting out
   of phase with the autoplay timer. */
.tile-frame.is-border-resetting::before {
  animation: none !important;
}

@media (prefers-reduced-motion: reduce) {
  .tile--expanded .tile-frame::before {
    animation: none;
    /* Without motion the comet would stick at one angle; replace
       with a calm phosphor edge so the open tile still reads as
       active without visible movement. */
    background: none;
    border: 2px solid var(--color-link);
    padding: 0;
    -webkit-mask: none;
            mask: none;
    filter: none;
  }
}

/* Portrait variant: image on left, footer on right — single row, two columns */
.tile-frame--portrait {
  grid-template-rows: minmax(0, 1fr);
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
}
.tile-frame--portrait .tile-frame-stage  {
  grid-column: 1;
  grid-row: 1;
  align-items: flex-start; /* image hugs top so it aligns with text top in the right column */
}
.tile-frame--portrait .tile-frame-footer {
  grid-column: 2;
  grid-row: 1;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
  justify-content: space-between;
  border-top: none;
  border-left: 2px solid var(--color-rule);
  padding: var(--s-4);
  gap: var(--s-3);
}
.tile-frame--portrait .tile-frame-controls {
  justify-content: flex-start;
  flex-wrap: wrap;
}

.tile-frame-eyebrow {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 3px;
  text-transform: uppercase;
  color: var(--color-muted);
  margin: 0 0 0.4rem;
}
.tile-frame-title {
  font-family: var(--font-display);
  font-size: clamp(20px, 2vw, 30px);
  line-height: 1.1;
  margin: 0 0 0.6rem;
  letter-spacing: 0.04em;
}

.tile-frame-stage {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center; /* image centered horizontally in its container */
  padding: var(--s-3);
  min-height: 0;
  min-width: 0;
  overflow: hidden;
  touch-action: pan-y;  /* let vertical scroll through, horizontal handled by JS swipe */
}

.tile-media {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  display: block;
  object-fit: contain;
  background: var(--color-bg);
  border: 0;
  border-radius: 4px;
}
iframe.tile-media,
video.tile-media {
  width: 100%;
  height: 100%;
}

/* Default footer = landscape image, stacked layout. Text is centred. */
.tile-frame-footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-2);
  padding: var(--s-3);
  border-top: 2px solid var(--color-rule);
}
.tile-frame-textwrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 80ch;
  min-width: 0;
}
/* Portrait/square images keep left-aligned text (regardless of layout mode) */
.tile-frame--portrait .tile-frame-textwrap {
  align-items: flex-start;
}
.tile-frame-desc {
  font-family: var(--font-body);
  font-weight: 300;
  font-size: 14px;
  line-height: 1.7;
  color: var(--color-fg);
  margin: 0;
  max-width: 70ch;
  /* Honour newline characters in data-description so a single attribute
     can contain multiple paragraphs */
  white-space: pre-line;
}

/* "built with claude" tag — phosphor pill chip pinned to the
   bottom-right corner of the expanded tile-frame. Replaces the
   trailing "Built with Claude." phrase from the description copy
   (stripped in JS) and offers a one-click route to the colophon
   panel for the curious. Terminal-coherent typography (accent
   font, uppercase, wide letter-spacing) so it reads as system
   chrome rather than prose. Sized with enough internal padding
   to feel like a deliberate stamp, not a tossed-in label. */
.built-with-claude-tag {
  position: absolute;
  bottom: var(--s-2);
  right: var(--s-2);
  z-index: 6;
  padding: 8px 14px 7px;
  font-family: var(--font-accent);
  font-size: 10px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--color-link);
  background: transparent;
  border: 1px solid var(--color-link);
  border-radius: 999px;
  cursor: pointer;
  transition:
    background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
    color            220ms cubic-bezier(0.16, 1, 0.3, 1),
    border-color     220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.built-with-claude-tag:hover,
.built-with-claude-tag:focus-visible {
  background-color: var(--color-link);
  color: var(--color-bg);
  border-color: var(--color-link);
  outline: none;
  /* No text-shadow on the hover state — bg fill already provides
     contrast; adding glow on top of a filled chip would muddy the
     letters. Hover-bloom is reserved for transparent text links. */
}
/* Project meta strip — "Role · Lead", "Year · 2025", etc. */
.tile-frame-details {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: var(--s-2) var(--s-3);
  margin: var(--s-3) 0 0;
  padding: var(--s-2) 0 0;
  border-top: 2px solid var(--color-rule);
  max-width: 70ch;
}
.tile-frame-details dt {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-muted);
  margin: 0 0 4px;
}
.tile-frame-details dd {
  font-family: var(--font-body);
  font-size: 13px;
  line-height: 1.45;
  color: var(--color-fg);
  margin: 0;
}
.tile-frame-link {
  display: inline-flex;
  align-items: center;
  align-self: center; /* landscape default: centred under the centred text */
  position: relative;
  isolation: isolate;
  overflow: hidden;     /* clip the sweep + glow into the pill shape */
  margin-top: 14px;
  padding: 9px 16px;
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  /* Bright phosphor — matches the rail labels' rest colour, so CTAs
     across the site read as one live family. */
  color: var(--color-link);
  background: transparent;
  border: 2px solid var(--color-link);
  border-radius: 999px;
  text-decoration: none;
}
/* Sweep highlight — phosphor block grows from the left edge. Used in
   the second half of the hover sequence, delayed so the power-on
   glow lands first. */
.tile-frame-link::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-link);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
  z-index: -1;
  pointer-events: none;
}
.tile-frame-link::after {
  content: " ↗";
  margin-left: 6px;
}
/* Two-phase CRT hover: phosphor power-on first (text glows bright +
   pill picks up an outer halo), then the green sweep arrives from
   the left and the text settles dark against the lit field. Built
   as a single keyframe animation on the element so the three colour
   states (rest → glow → inverted) can be hit in sequence — CSS
   transitions can only pass through two points per property.

   Shared across the whole pill button family — .tile-frame-link
   (project + case-study CTAs in expanded tiles), .btn-cv (Download
   CV in About), and .btn-contact (email + LinkedIn in About). All
   use the same phosphor token set so the keyframes work on each. */
.tile-frame-link:hover,
.tile-frame-link:focus-visible,
.btn-cv:hover,
.btn-cv:focus-visible,
.btn-contact:hover,
.btn-contact:focus-visible {
  animation: pill-power-on-wipe 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.tile-frame-link:hover::before,
.tile-frame-link:focus-visible::before,
.btn-cv:hover::before,
.btn-cv:focus-visible::before,
.btn-contact:hover::before,
.btn-contact:focus-visible::before {
  /* Sweep arrives 220ms after hover starts — i.e. once the power-on
     phase has resolved into its lit state. */
  transform: scaleX(1);
  transition-delay: 220ms;
}
@keyframes pill-power-on-wipe {
  0% {
    color: var(--color-link);
    text-shadow: none;
    border-color: var(--color-link);
    box-shadow: none;
  }
  40% {
    /* Power-on settled: text bright + glowing, border brightens,
       outer halo at full bloom. This is the moment before the
       wipe arrives. */
    color: var(--color-link-hover);
    text-shadow: var(--phosphor-glow-strong);
    border-color: var(--color-link-hover);
    box-shadow:
      0 0 12px rgba(92, 255, 142, 0.45),
      0 0 28px rgba(92, 255, 142, 0.2);
  }
  100% {
    /* Wipe arrived: text inverted to dark against the phosphor sweep
       fill, text-shadow consumed by the green field. Outer halo
       stays so the pill still reads as "hot". */
    color: var(--color-bg);
    text-shadow: none;
    border-color: var(--color-link);
    box-shadow:
      0 0 14px rgba(92, 255, 142, 0.4),
      0 0 32px rgba(92, 255, 142, 0.22);
  }
}
/* Reduced-motion: skip the sequence entirely and jump to the lit
   wiped state on hover. Same readability, no animation. Covers the
   whole pill button family. */
@media (prefers-reduced-motion: reduce) {
  .tile-frame-link:hover,
  .tile-frame-link:focus-visible,
  .btn-cv:hover,
  .btn-cv:focus-visible,
  .btn-contact:hover,
  .btn-contact:focus-visible {
    animation: none;
    color: var(--color-bg);
    border-color: var(--color-link);
    box-shadow:
      0 0 14px rgba(92, 255, 142, 0.4),
      0 0 32px rgba(92, 255, 142, 0.22);
  }
  .tile-frame-link:hover::before,
  .tile-frame-link:focus-visible::before,
  .btn-cv:hover::before,
  .btn-cv:focus-visible::before,
  .btn-contact:hover::before,
  .btn-contact:focus-visible::before {
    transition: none;
  }
}

/* --- Escalating-hover glitch ----------------------------------------
   The longer a user hovers a pill, the more the button glitches.
   JS (rotateContactNotif's neighbour in main.js) adds escalating
   classes at 1.5s / 3s / 5s of hover, and removes them on mouse-out
   so each hover starts fresh.

   Stage 1 — soft positional jitter (1px shifts, infrequent).
   Stage 2 — stage 1 + brief RGB chromatic mis-convergence on text.
   Stage 3 — stage 2 + heavy shake + occasional border-colour break.
              JS also fires scrambleReveal on a 1.6s interval at
              stage 3 so the button text glyph-decodes intermittently. */

/* Compose: keep the power-on/wipe animation running, layer the
   jitter on top. The composed transform combines because both
   animations target the same property in additive sequence — last
   one wins per frame, and the jitter runs after the wipe completes. */
.tile-frame-link.is-glitching-1,
.btn-cv.is-glitching-1,
.btn-contact.is-glitching-1 {
  animation:
    pill-power-on-wipe 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards,
    pill-jitter-soft 0.9s linear infinite 560ms;
}
.tile-frame-link.is-glitching-2,
.btn-cv.is-glitching-2,
.btn-contact.is-glitching-2 {
  animation:
    pill-power-on-wipe 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards,
    pill-jitter-soft 0.6s linear infinite 560ms,
    pill-rgb-pulse 1.6s ease-in-out infinite 800ms;
}
.tile-frame-link.is-glitching-3,
.btn-cv.is-glitching-3,
.btn-contact.is-glitching-3 {
  animation:
    pill-power-on-wipe 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards,
    pill-jitter-heavy 0.4s linear infinite 560ms,
    pill-rgb-pulse 0.9s ease-in-out infinite 800ms,
    pill-border-flash 1.6s ease-in-out infinite 1.2s;
}

/* Each "kick" is two keyframes 1% apart — at 60fps that's ~6ms of
   smooth interp, which reads as a single-frame snap. Longer holds
   at (0,0) between kicks give the signal its rhythm. */
@keyframes pill-jitter-soft {
  0%, 19%, 21%, 53%, 55%, 81%, 83%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-3px, 1px); }
  54% { transform: translate(2px, -2px); }
  82% { transform: translate(-1px, 2px); }
}
@keyframes pill-jitter-heavy {
  0%, 100% { transform: translate(0, 0); }
  6%   { transform: translate(-4px, 1px); }
  7%   { transform: translate(0, 0); }
  19%  { transform: translate(3px, -2px); }
  20%  { transform: translate(0, 0); }
  32%  { transform: translate(-2px, -3px); }
  33%  { transform: translate(0, 0); }
  46%  { transform: translate(4px, 1px); }
  47%  { transform: translate(0, 0); }
  60%  { transform: translate(-3px, 0); }
  61%  { transform: translate(0, 0); }
  74%  { transform: translate(2px, -1px); }
  75%  { transform: translate(0, 0); }
  87%  { transform: translate(-1px, 3px); }
  88%  { transform: translate(0, 0); }
}
/* RGB mis-convergence — red / cyan text-shadow ghost for a frame
   then settles back. Two-stop transitions (94%→95% and 96%→97%)
   keep each ghost a snap rather than a fade. */
@keyframes pill-rgb-pulse {
  0%, 93%, 95%, 97%, 100% { text-shadow: none; }
  94% {
    text-shadow:
      -3px 0 rgba(253, 92, 99, 0.95),
      3px 0 rgba(110, 245, 255, 0.95);
  }
  96% {
    text-shadow:
      3px 0 rgba(253, 92, 99, 0.95),
      -3px 0 rgba(110, 245, 255, 0.95);
  }
}
/* Border breaks colour briefly — flicks to watermelon-red as if the
   signal mis-locked, then snaps back to phosphor. */
@keyframes pill-border-flash {
  0%, 93%, 95%, 100% { border-color: var(--color-link); }
  94%                { border-color: var(--color-watermelon); }
}
/* Reduced-motion: no glitch stages, period. */
@media (prefers-reduced-motion: reduce) {
  .tile-frame-link.is-glitching-1,
  .tile-frame-link.is-glitching-2,
  .tile-frame-link.is-glitching-3 {
    animation: none;
  }
}
/* Portrait split-layout: text is left-aligned, so button follows suit */
.tile-frame--portrait .tile-frame-link {
  align-self: flex-start;
}

/* CTA action row — holds the external-link button and the case-study
   button side-by-side when there's room, wraps to a stack when there
   isn't. align-items: stretch so both buttons take the same height
   regardless of which one happens to be taller. Replaces the previous
   "two children stacked in the textwrap column-flex" arrangement. */
.tile-frame-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: stretch;
  gap: var(--s-2);
  margin-top: 14px;
  width: 100%;
}
/* Inside the action row, individual buttons drop their own top-margin
   and self-alignment — the row controls spacing now. Stretch keeps
   their heights matched even when one button's content (e.g. the
   case-study "//" affix vs the external "↗") implies a slightly
   different intrinsic height. */
.tile-frame-actions > .tile-frame-link {
  margin-top: 0;
  align-self: stretch;
}
/* Portrait/left-aligned mode — actions follow the textwrap's
   alignment so the buttons start at the left rather than centring. */
.tile-frame--portrait .tile-frame-actions {
  justify-content: flex-start;
}
/* Unified slideshow control rail — prev / dots / play / next, all
   sharing the same round-button chrome. Centred under the image
   with a fixed dots-row width so the rail size doesn't shift as
   slide counts vary. */
.tile-frame-controls {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
}

/* Slide indicator dot row — fixed width regardless of count */
.tile-dots {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
  width: 140px;
}
.tile-dot {
  width: 8px;
  height: 8px;
  padding: 0;
  border: 1.5px solid var(--color-muted);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.tile-dot:hover { border-color: var(--color-link); transform: scale(1.2); }
.tile-dot.is-active {
  background: var(--color-link);
  border-color: var(--color-link);
}
.tile-dot.is-active:hover { transform: scale(1.2); }

/* Play / pause autoplay button */
.tile-play {
  position: relative;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 2px solid var(--color-muted);
  background: transparent;
  color: var(--color-fg);
  font-size: 11px;
  border-radius: 50%;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: border-color 0.2s ease, color 0.2s ease;
  overflow: hidden; /* keep the rising fill clipped to the circle */
}
.tile-play:hover { border-color: var(--color-link); color: var(--color-link); }
.tile-play.is-playing { color: var(--color-link); border-color: var(--color-link); }

/* Progress fill — green liquid that rises from the bottom of the
   button over the autoplay interval (5s, matches AUTOPLAY_INTERVAL
   in main.js), then snaps back to empty when the next slide is
   shown. Restarts via restartPlayProgress() in main.js. */
.tile-play-fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0;
  background: var(--color-link);
  pointer-events: none;
  z-index: 0;
}
.tile-play.is-playing .tile-play-fill {
  animation: tile-play-fill 5s linear forwards;
}
@keyframes tile-play-fill {
  from { height: 0; }
  to   { height: 100%; }
}
.tile-play-icon {
  position: relative;
  z-index: 1;
  line-height: 1;
  /* Use difference blend so the icon stays legible whether sitting
     over the dark button bg (rest) or over the green fill (playing). */
  mix-blend-mode: difference;
  color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
  .tile-play.is-playing .tile-play-fill { animation: none; }
}

/* Slideshow nav (only present when there are multiple images) */
/* Slideshow prev/next buttons — sit in the unified control rail in
   the footer, matching the play button's chrome (round 32px, visible
   border, transparent fill). Hover lights the border + glyph. */
.tile-nav {
  position: relative;
  width: 32px;
  height: 32px;
  border: 2px solid var(--color-muted);
  background: transparent;
  color: var(--color-fg);
  font-size: 16px;
  line-height: 1;
  border-radius: 50%;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: border-color 0.2s ease, color 0.2s ease;
  z-index: 2;
}
.tile-nav:hover { border-color: var(--color-link); color: var(--color-link); }

/* Close button — sits at the top-right of the .tile-frame (the dialog
   itself, not the inner image stage) so its position is consistent
   regardless of inner layout: stacked landscape, side-by-side portrait,
   contact-cta — all get the close button at the same dialog corner.

   Pill chip with × glyph + "close" + "esc" hint, matching the
   .panel-close treatment for vocabulary consistency. The chip sits
   over arbitrary tile imagery so it keeps a glass-morphic bg
   (rgba bg-2 + backdrop blur) for legibility — but everything else
   (phosphor border, accent font, bloom-from-glyph hover) is shared
   with the panel-close. */
.tile-close {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  z-index: 10;
  isolation: isolate;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 7px 11px 7px 10px;
  border-radius: 999px;
  border: 2px solid var(--color-link);
  background: rgba(3, 14, 23, 0.65);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  color: var(--color-link);
  font-family: var(--font-accent);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
  transition:
    border-color 280ms cubic-bezier(0.83, 0, 0.17, 1) 80ms,
    color        300ms cubic-bezier(0.83, 0, 0.17, 1) 60ms;
}
/* Watermelon bloom — anchored at the × glyph position. Same pattern
   as .panel-close::before — scales from 0 to flood the chip outward
   on hover. left:16 puts the origin under the × (chip padding 10 +
   half-glyph ~6). */
.tile-close::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 16px;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  background: var(--color-watermelon);
  transform: scale(0);
  transform-origin: center;
  transition: transform 440ms cubic-bezier(0.83, 0, 0.17, 1);
  z-index: 0;
  pointer-events: none;
}
.tile-close__glyph,
.tile-close__label,
.tile-close__hint {
  position: relative;
  z-index: 1;
}
.tile-close__glyph {
  font-size: 16px;
  line-height: 1;
  display: inline-block;
  transform: translateY(-1px) scale(1);
  transition: transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.tile-close__label {
  display: inline-block;
}
.tile-close__hint {
  display: inline-block;
  padding-left: 8px;
  border-left: 1px solid currentColor;
  opacity: 0.55;
  transition: opacity 240ms ease 80ms;
}
.tile-close:hover,
.tile-close:focus-visible {
  border-color: var(--color-watermelon);
  color: var(--color-bg);
  outline: none;
}
.tile-close:hover::before,
.tile-close:focus-visible::before {
  transform: scale(30);
}
.tile-close:hover .tile-close__glyph,
.tile-close:focus-visible .tile-close__glyph {
  transform: translateY(-1px) scale(1.2);
}
.tile-close:hover .tile-close__hint,
.tile-close:focus-visible .tile-close__hint {
  opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
  .tile-close,
  .tile-close::before,
  .tile-close__glyph,
  .tile-close__hint {
    transition: none;
  }
  .tile-close:hover::before,
  .tile-close:focus-visible::before {
    transform: scale(30);
  }
}

/* Disable the original tile-caption while expanded — the frame
   provides its own title+description */
.tile--expanded .tile-caption { display: none; }

/* Below 1028px the side-by-side portrait layout doesn't have room to
   breathe, so portrait tiles fall back to the stacked layout (image on top,
   description below) — same as landscape. */
@media (max-width: 1028px) {
  .tile-frame--portrait {
    grid-template-rows: minmax(0, 1fr) auto;
    grid-template-columns: minmax(0, 1fr);
  }
  .tile-frame--portrait .tile-frame-stage  { grid-column: 1; grid-row: 1; }
  .tile-frame--portrait .tile-frame-footer {
    grid-column: 1;
    grid-row: 2;
    flex-direction: column;
    align-items: flex-start;
    text-align: left;
    border-top: 2px solid var(--color-rule);
    border-left: none;
    padding: var(--s-3);
    gap: var(--s-2);
  }
}

/* Mobile tweaks for the frame — compact paddings, smaller controls,
   smaller margin around the frame so it isn't choked on a phone */
@media (max-width: 700px) {
  .tile-frame,
  .tile-frame--portrait {
    grid-template-rows: minmax(0, 1fr) auto;
    grid-template-columns: minmax(0, 1fr);
  }
  .tile-frame {
    top: var(--s-2);
    bottom: var(--s-2);
    width: calc(100% - 2 * var(--s-2));
    border-radius: 8px;
    box-shadow:
      0 16px 40px 6px rgba(0, 0, 0, 0.5),
      0 2px 6px 0 rgba(0, 0, 0, 0.3);
  }
  .tile-frame-stage { padding: var(--s-2); }
  .tile-frame-footer {
    flex-direction: column;
    gap: 0.75rem;
    padding: var(--s-2) var(--s-3);
    /* alignment inherited: landscape stays centred, portrait stays left */
  }
  .tile-frame-controls { width: 100%; flex-wrap: wrap; }
  .tile-prev { left: var(--s-1); }
  .tile-next { right: var(--s-1); }
  .tile-nav { width: 38px; height: 38px; }
  .tile-close { top: var(--s-1); right: var(--s-1); padding: 6px 9px 6px 9px; font-size: 9px; gap: 7px; }
  .tile-close__glyph { font-size: 14px; }
  .tile-close__hint { padding-left: 7px; }
  .tile--expanded { height: auto !important; }
}

.tile-caption {
  position: absolute;
  inset: auto 0 0 0;
  padding: var(--s-2) var(--s-2);
  background: linear-gradient(to top, rgba(3,14,23,0.92), rgba(3,14,23,0));
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: var(--color-fg);
  /* Always visible at a low opacity so the grid is scannable at rest;
     full strength on hover. */
  opacity: 0.7;
  transform: translateY(0);
  /* Match the eased tile hover timing so the caption brightens on
     the same beat as the rest of the card. */
  transition: opacity 340ms cubic-bezier(0.16, 1, 0.3, 1);
  z-index: 3; /* sit above the rainbow gradient stroke pseudo-element */
}
.tile:hover .tile-caption,
.tile:focus-visible .tile-caption { opacity: 1; }

/* Work intro on home — sits inside <main>, stays at the centred max-width
   so the headline and lede read at a comfortable measure */
.work-intro {
  padding: var(--s-6) 0 var(--s-3);
  scroll-margin-top: var(--s-3);
}
.work-intro .eyebrow      { margin-bottom: var(--s-2); }
.work-intro .section-title { margin-bottom: var(--s-3); }
.work-intro .lede          { margin-bottom: 0; max-width: 70ch; }

/* Work grid wrap — sits OUTSIDE <main>, full browser width.
   Tiles get the whole viewport on desktop and full-bleed on mobile. */
/* Static hero box at the top of the homepage. Empty placeholder for now —
   will hold whatever hero content gets added later. Full grid width,
   matches the visual treatment of the tiles below. */
.hero-box {
  position: relative;
  width: 100%;
  /* Locked at 60% of the browser's height on initial load via JS
     (--hero-height set on documentElement); falls back to 60vh.
     Starts collapsed at height 0, smoothly grows to its target
     when .is-hero-revealed lands on <html>. */
  height: 0;
  overflow: hidden;
  /* Crosshair cursor signals "this is a game surface" to visitors
     who don't realise they can shoot. The companion rule below
     forces the same cursor on every descendant so the canvas, type
     spans, and decorative overlays don't fall back to their UA
     defaults. */
  cursor: crosshair;
}
.hero-box * {
  cursor: crosshair;
}
.hero-box {
  /* Continue the original rule body. */
  /* Slightly longer duration + softer decel curve than the rest of
     the site's house easing — the hero arriving is a meaningful
     beat, worth giving it room to land. */
  transition: height 1200ms cubic-bezier(0.22, 1, 0.36, 1);
  /* Placeholder fill held off the bg property — moved to ::after
     so it can fade in independently as the box reveals. The bg
     itself stays empty so the height grow is a clean reveal. */
  background-color: transparent;
  background-image: none;
  border: 0;
  box-sizing: border-box;
  /* Subtle inset phosphor glow — edges read as a tuned-signal
     surface, gives the rectangle a slight CRT vignette rather
     than looking like a flat content panel. */
  box-shadow:
    inset 0 0 60px -16px rgba(92, 255, 142, 0.18),
    inset 0 0 2px rgba(92, 255, 142, 0.25);
}

/* Brief camera-shake when the asteroids ship destroys a letter.
   JS toggles .is-hero-shaking for ~180ms; the keyframe wobbles
   the whole box. The transform here doesn't fight any other rule
   because nothing else on .hero-box sets transform. */
.is-hero-shaking {
  animation: hero-box-shake 180ms ease-out;
}
@keyframes hero-box-shake {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-4px, 1px); }
  40%  { transform: translate(3px, -2px); }
  60%  { transform: translate(-3px, 2px); }
  80%  { transform: translate(2px, -1px); }
  100% { transform: translate(0, 0); }
}

/* Hero ::before used to be a watermelon-hatched placeholder; now it's
   the soft phosphor backdrop wash that the hero type sits on. Lives
   under the scan-lines and the .hero-content so it can fade in
   independently of the height-grow. Subtle radial bias toward the
   centre — gently lifts the type out of the rectangle. */
.hero-box::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: radial-gradient(
    ellipse 70% 100% at 50% 50%,
    rgba(92, 255, 142, 0.10) 0%,
    rgba(92, 255, 142, 0.04) 40%,
    transparent 80%
  );
  opacity: 0;
  pointer-events: none;
  transition: opacity 900ms ease 500ms;
}
.is-hero-revealed .hero-box::before {
  opacity: 1;
}

/* ----- CRT texture overlays --------------------------------------
   Asteroids canvas backdrop + animated noise grain + four corner
   registration brackets. All sit behind .hero-content. Each bails
   its motion under prefers-reduced-motion (the texture itself
   stays — only the movement stops). */

/* Asteroids canvas — drifting wireframe polygons + parallax stars.
   Inset:0 covers the whole hero. opacity transitions in with the
   hero reveal so the field "boots up" alongside the type. No
   z-index so it paints in source order — behind the static and
   corner layers (both of which DO have z-index). */
.hero-asteroids {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 1100ms ease 500ms;
}
.is-hero-revealed .hero-asteroids { opacity: 1; }

.hero-static {
  position: absolute;
  inset: -8% -8%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 900ms ease 700ms;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.95' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 0.36  0 0 0 0 1  0 0 0 0 0.56  0 0 0 1 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
  background-size: 240px 240px;
  background-repeat: repeat;
  mix-blend-mode: screen;
  animation: hero-static-jitter 420ms steps(6) infinite;
  z-index: 1;
}
.is-hero-revealed .hero-static { opacity: 0.14; }
@keyframes hero-static-jitter {
  /* Random-ish step shifts so the grain feels alive without
     re-using the same offset two frames in a row. */
  0%   { background-position:    0px    0px; }
  16%  { background-position:  -37px   62px; }
  33%  { background-position:   91px  -28px; }
  50%  { background-position:  -64px  -91px; }
  66%  { background-position:  118px   33px; }
  83%  { background-position:  -22px  104px; }
  100% { background-position:   54px  -55px; }
}

/* Corner brackets — L-shaped phosphor marks at each corner. Each
   span is a fixed square; two of its borders are drawn (the two
   that meet at the corner it occupies). Glow comes from a
   drop-shadow so the marks read as emitted phosphor rather than
   stickers. Fade in with the hero reveal. */
.hero-corner {
  position: absolute;
  width: 44px;
  height: 44px;
  pointer-events: none;
  border: 2px solid var(--color-link);
  opacity: 0;
  transition: opacity 700ms ease 900ms, transform 700ms ease 900ms;
  filter:
    drop-shadow(0 0 4px rgba(92, 255, 142, 0.7))
    drop-shadow(0 0 10px rgba(92, 255, 142, 0.35));
  z-index: 2;
}
.is-hero-revealed .hero-corner {
  opacity: 0.9;
  transform: translate(0, 0);
}
/* Each corner only renders TWO of its borders — the two that
   meet at the bracket's "elbow". Negative inset + opposite-side
   borders give the classic viewfinder-mark shape. */
.hero-corner--tl {
  top: 16px; left: 16px;
  border-right: 0; border-bottom: 0;
  transform: translate(-6px, -6px);
}
.hero-corner--tr {
  top: 16px; right: 16px;
  border-left: 0; border-bottom: 0;
  transform: translate(6px, -6px);
}
.hero-corner--bl {
  bottom: 16px; left: 16px;
  border-right: 0; border-top: 0;
  transform: translate(-6px, 6px);
}
.hero-corner--br {
  bottom: 16px; right: 16px;
  border-left: 0; border-top: 0;
  transform: translate(6px, 6px);
}

/* Score HUD — sits inside the top-right corner bracket area. Mono
   typeface + tight letter-spacing reads as a tuner readout / arcade
   ticker. Padded in slightly so the digits don't kiss the bracket.
   Fades in alongside the corner brackets. */
.hero-score {
  position: absolute;
  top: 24px;
  right: 70px;     /* sits just inside the .hero-corner--tr bracket */
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  font-family: "Departure Mono", ui-monospace, "SFMono-Regular", Menlo,
               Consolas, monospace;
  font-size: 11px;
  letter-spacing: 0.14em;
  color: var(--color-link);
  text-shadow: 0 0 6px rgba(92, 255, 142, 0.45);
  opacity: 0;
  pointer-events: none;
  z-index: 2;
  transition: opacity 700ms ease 1100ms, transform 280ms ease;
  transform: translateY(-2px);
}
.is-hero-revealed .hero-score {
  opacity: 0.85;
  transform: translateY(0);
}
.hero-score__label {
  opacity: 0.65;
}
.hero-score__value {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* Tiny pop when the score changes — JS toggles --bumped briefly. */
.hero-score--bumped .hero-score__value {
  animation: hero-score-bump 320ms ease-out;
}
@keyframes hero-score-bump {
  0%   { transform: scale(1); filter: brightness(1); }
  40%  { transform: scale(1.15); filter: brightness(1.5); }
  100% { transform: scale(1); filter: brightness(1); }
}

/* Combo chip — sits right of the score value. Hidden until the JS
   sets textContent. Brief flash on each combo increment via the
   --combo-pulse class (added + removed by the asteroids loop). */
.hero-score__combo {
  display: inline-block;
  margin-left: 4px;
  color: var(--color-watermelon);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-shadow: 0 0 8px rgba(253, 92, 99, 0.55);
  opacity: 0;
  transition: opacity 220ms ease;
  font-variant-numeric: tabular-nums;
}
.hero-score__combo.is-active {
  opacity: 0.95;
}
.hero-score--combo-pulse .hero-score__combo {
  animation: hero-combo-pulse 280ms ease-out;
}
@keyframes hero-combo-pulse {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.25); filter: brightness(1.6); }
  100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .hero-score--combo-pulse .hero-score__combo { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
  .hero-score--bumped .hero-score__value { animation: none; }
}

/* Hero content — centred typographic intro. Absolutely positioned
   so it lives over the scan-line layer without affecting the
   .hero-box's height-grow timing. Fades in just after the scan
   reveal lands, so the name appears as the screen finishes
   "tuning in". The actual glitch-decode is driven by
   heroIntroLoop() in main.js (calls scrambleReveal). z-index lifts
   it above the static + scanline texture so legibility wins. */
.hero-content {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 32px 24px;
  text-align: center;
  opacity: 0;
  transition: opacity 700ms ease 1100ms;
  pointer-events: none;  /* purely decorative; doesn't intercept clicks */
}
.is-hero-revealed .hero-content { opacity: 1; }

.hero-name {
  font-family: "Space Grotesk", "Inter", system-ui, sans-serif;
  font-weight: 700;
  font-size: clamp(48px, 11vw, 160px);
  line-height: 1;
  letter-spacing: -0.02em;
  margin: 0;
  color: var(--color-link);
  /* Glow is now delivered per-letter (see .hero-name-letter filter
     below) so it can breathe and flicker independently. The parent
     text-shadow used to do this; it's been removed to avoid
     compounding with the per-letter filter. */
}

/* Per-letter spans — created by heroIntroLoop's splitNameIntoLetters
   shortly after the initial scramble settles. Each letter starts at
   HP 3 (data-hp="3"); the canvas asteroids loop decrements HP on bullet
   hits, swaps in damage states via the data-hp attribute, fires the
   .hero-name-letter--flash class for hit feedback, and adds
   .hero-name-letter--destroyed when HP reaches zero.

   Ambient phosphor character is delivered by two layers:
     1. A per-letter drop-shadow halo (cleaner than the parent
        text-shadow it replaces — animates well, GPU-friendly).
     2. A slow "breathe" keyframe that subtly modulates brightness
        across all live letters in unison — sells the CRT as a
        running display rather than a printed one.
   The asteroids loop separately taps in random per-letter flicker
   via the .hero-name-letter--flicker class.

   The non-shootable space span keeps the layout's word gap intact. */
.hero-name-letter,
.hero-name-space {
  display: inline-block;
  transform-origin: 50% 65%;
  transition:
    transform 280ms cubic-bezier(.34, 1.56, .64, 1),
    opacity 380ms ease,
    filter 280ms ease,
    color 280ms ease;
}
.hero-name-letter {
  /* Per-letter phosphor halo. Stacked drop-shadows give a soft inner
     halo + wider faint glow without text-shadow's blur artefacts. */
  filter:
    drop-shadow(0 0 6px rgba(92, 255, 142, 0.6))
    drop-shadow(0 0 18px rgba(92, 255, 142, 0.25));
}
/* Breathing pulse only on pristine HP-3 letters. Damaged letters
   keep their static damage filter so the visual damage cue isn't
   washed out by animation. */
.hero-name-letter[data-hp="3"] {
  animation: hero-letter-breathe 3.6s ease-in-out infinite;
}
@keyframes hero-letter-breathe {
  /* Very subtle — ~6% brightness modulation. Strong enough to read
     as alive once you look for it; quiet enough not to draw the eye
     away from anything else happening in the hero. */
  0%, 100% { filter:
    drop-shadow(0 0 6px rgba(92, 255, 142, 0.55))
    drop-shadow(0 0 18px rgba(92, 255, 142, 0.22)); }
  50% { filter:
    drop-shadow(0 0 8px rgba(92, 255, 142, 0.72))
    drop-shadow(0 0 22px rgba(92, 255, 142, 0.32)); }
}
/* Per-letter flicker — applied by JS at random intervals. Three-stage
   cycle: a hot phosphor surge (overcharge to near-white), then a deep
   dim (the cell nearly drops out), then snap back to normal. The
   surge/dip contrast is exaggerated so the flicker reads from
   peripheral vision, not just when you stare at the letter. */
/* Doubled class selector for specificity parity with the
   .hero-name-letter[data-hp="3"] breathing rule (both 0,2,0); source
   order then guarantees the flicker animation wins over breathing
   on pristine HP-3 letters. Without this, breathing claims the
   animation property and the flicker never actually plays. */
.hero-name-letter.hero-name-letter--flicker {
  animation: hero-letter-flicker 300ms ease-out;
}
@keyframes hero-letter-flicker {
  0% {
    opacity: 1;
    filter:
      drop-shadow(0 0 6px rgba(92, 255, 142, 0.6))
      drop-shadow(0 0 18px rgba(92, 255, 142, 0.25));
  }
  18% {
    /* Hot surge — brightness pushed near-white, two huge halos
       overlapping for the "bloomed CRT cell" look. */
    opacity: 1;
    filter:
      brightness(2.5)
      drop-shadow(0 0 26px rgba(230, 255, 235, 1))
      drop-shadow(0 0 56px rgba(140, 255, 180, 0.85));
  }
  32% {
    /* Quick falloff back through normal brightness but with the
       halo still wide — the burst trail. */
    opacity: 1;
    filter:
      brightness(1.4)
      drop-shadow(0 0 18px rgba(180, 255, 200, 0.75))
      drop-shadow(0 0 40px rgba(120, 255, 170, 0.4));
  }
  58% {
    /* Deep dim — the cell nearly drops out. Halo collapses to
       almost nothing; opacity drops well past half. */
    opacity: 0.18;
    filter:
      brightness(0.45);
  }
  75% {
    /* Second tiny stutter on recovery so the return isn't a smooth
       lerp — gives the flicker a "shaking back to life" texture. */
    opacity: 0.7;
    filter:
      brightness(0.85)
      drop-shadow(0 0 4px rgba(92, 255, 142, 0.4));
  }
  100% {
    opacity: 1;
    filter:
      drop-shadow(0 0 6px rgba(92, 255, 142, 0.6))
      drop-shadow(0 0 18px rgba(92, 255, 142, 0.25));
  }
}
/* HP 2 — first hit. Slight tilt, faint colour drift. */
.hero-name-letter[data-hp="2"] {
  transform: rotate(-4deg);
  filter: brightness(0.92);
}
/* HP 1 — second hit. More obvious damage: counter-rotation, drop, and
   a CRT-style "ghost" via offset drop-shadows. */
.hero-name-letter[data-hp="1"] {
  transform: rotate(6deg) translateY(2px);
  opacity: 0.78;
  filter:
    drop-shadow(2px 0 0 rgba(120, 255, 170, 0.5))
    drop-shadow(-1px 1px 0 rgba(120, 255, 170, 0.35));
}
/* HP 0 — destroyed. Brief watermelon-red flash to signal the kill
   (a phosphor explodes through its emergency colour before going
   dark), then tips backward, scales, falls off the baseline, and
   fades. Implemented as a single keyframe with NO 0% stop so the
   interpolation picks up from whatever HP-1 state the letter was
   in immediately before the hit. animation-fill-mode: forwards
   holds the final state until the respawn animation replaces it
   ~8s later. */
.hero-name-letter.hero-name-letter--destroyed {
  animation: hero-letter-die 720ms cubic-bezier(.4, 0, .8, 1) forwards;
  pointer-events: none;
}
@keyframes hero-letter-die {
  10% {
    /* Flash — scale-pop + watermelon glow */
    transform: rotate(0deg) scale(1.12);
    color: var(--color-watermelon);
    opacity: 1;
    filter:
      brightness(1.7)
      drop-shadow(0 0 14px rgba(253, 92, 99, 0.9))
      drop-shadow(0 0 28px rgba(253, 92, 99, 0.45));
  }
  30% {
    /* Hold the red briefly so the eye registers it. */
    color: var(--color-watermelon);
    opacity: 1;
    filter:
      brightness(1.3)
      drop-shadow(0 0 8px rgba(253, 92, 99, 0.7));
  }
  100% {
    transform: rotate(-22deg) scale(0.5) translateY(48px);
    opacity: 0;
    color: var(--color-watermelon);
    filter: drop-shadow(0 0 4px rgba(253, 92, 99, 0.3));
  }
}
/* Flash — bright phosphor burst on every hit. Class is added by the
   asteroids loop on each bullet impact and removed ~220ms later so
   the next hit can re-fire it. Doubled class selector so it beats
   the breathing rule (same specificity as [data-hp="3"], wins by
   source order). */
.hero-name-letter.hero-name-letter--flash {
  animation: hero-letter-flash 220ms ease-out;
}
@keyframes hero-letter-flash {
  0%   { filter: brightness(1); }
  30%  {
    filter:
      brightness(2.2)
      drop-shadow(0 0 14px rgba(180, 255, 200, 0.95));
  }
  100% { filter: brightness(1); }
}

/* Respawn — destroyed letters reassemble after a beat (handled in JS
   via respawnLetter). The keyframe boots the letter back from
   nothing: small + bright, slight overshoot, settles. Overrides any
   transition that would otherwise lerp from the destroyed transform
   back to default. */
.hero-name-letter.hero-name-letter--respawn {
  animation: hero-letter-respawn 700ms cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes hero-letter-respawn {
  0% {
    transform: scale(0) rotate(0deg);
    opacity: 0;
    filter: brightness(2.6) drop-shadow(0 0 12px rgba(180, 255, 200, 1));
  }
  55% {
    transform: scale(1.18) rotate(0deg);
    opacity: 1;
    filter: brightness(1.6) drop-shadow(0 0 6px rgba(180, 255, 200, 0.7));
  }
  100% {
    transform: scale(1) rotate(0deg);
    opacity: 1;
    filter: brightness(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Damage states still display (skipping them would be misleading
     about gameplay state), but the bounce-on-arrival easing flattens
     to linear so it doesn't violate the user's motion preference. */
  .hero-name-letter,
  .hero-name-space { transition-timing-function: ease; }
  .hero-name-letter.hero-name-letter--flash { animation: none; }
  .hero-name-letter.hero-name-letter--respawn { animation: none; }
  .hero-name-letter.hero-name-letter--flicker { animation: none; }
  /* Breathing pulse off — letters render at the static base filter. */
  .hero-name-letter[data-hp="3"] { animation: none; }
  /* Destruction animation keeps the colour flash (visual gameplay
     signal) but skips the bouncy timing function. */
  .hero-name-letter.hero-name-letter--destroyed { animation-timing-function: ease; }
  .is-hero-shaking { animation: none; }
}

.hero-tagline {
  font-family: "Departure Mono", ui-monospace, "SFMono-Regular", Menlo,
               Consolas, monospace;
  font-size: clamp(14px, 1.6vw, 22px);
  letter-spacing: 0.04em;
  color: var(--color-link);
  margin: 0;
  opacity: 0.92;
  /* Lock vertical space so cursor doesn't bounce between phrases of
     different ascender heights. */
  min-height: 1.2em;
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
}

/* Blinking cursor — a solid block character. Steps animation gives
   the hard on/off the terminal cursor convention wants (smooth
   fades would read as a typing-effect cursor, which is a different
   register). */
.hero-cursor {
  display: inline-block;
  color: var(--color-link);
  font-family: inherit;
  font-size: 0.88em;
  line-height: 1;
  transform: translateY(0.06em);
  animation: hero-cursor-blink 1100ms steps(2, end) infinite;
}
@keyframes hero-cursor-blink {
  0%, 50%   { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-cursor { animation: none; opacity: 0.7; }
  .hero-content { transition: opacity 240ms ease 200ms; }
}

/* Phosphor scan-line layer — TWO lines via stacked background-images
   on a single ::after. The bright reveal line plays once when the
   hero opens; the fainter drift line loops forever once revealed,
   keeping the surface feeling "live" rather than static.

   Implemented as a single positioned element with the line drawn
   via `top` and box-shadow glow rather than a background gradient,
   so the glow halo extends outside the 1px-tall element. */
.hero-box .hero-scan-reveal,
.hero-box .hero-scan-drift {
  position: absolute;
  display: block;
  left: 0;
  right: 0;
  height: 2px;
  pointer-events: none;
  opacity: 0;
  /* Linear gradient gives the line a soft fade at both horizontal
     ends so it doesn't appear to butt up against the frame edges. */
  background: linear-gradient(90deg,
    transparent 0%,
    var(--color-link) 22%,
    var(--color-link) 78%,
    transparent 100%);
}
.hero-box .hero-scan-reveal {
  top: -2px;
  box-shadow:
    0 0 14px rgba(92, 255, 142, 0.7),
    0 0 30px rgba(92, 255, 142, 0.35);
}
.hero-box .hero-scan-drift {
  top: -1px;
  height: 1px;
  /* Drift line is dimmer + thinner — it's ambient, not the headline. */
  background: linear-gradient(90deg,
    transparent 0%,
    rgba(92, 255, 142, 0.4) 30%,
    rgba(92, 255, 142, 0.4) 70%,
    transparent 100%);
}
.is-hero-revealed .hero-box .hero-scan-reveal {
  animation: hero-scan-sweep 1100ms cubic-bezier(0.4, 0, 0.6, 1) 150ms forwards;
}
.is-hero-revealed .hero-box .hero-scan-drift {
  /* Starts after the dramatic reveal sweep has finished, so the
     two don't overlap visually. Then loops every 9s. */
  animation: hero-scan-drift 9s linear 1400ms infinite;
}

@keyframes hero-scan-sweep {
  0%   { top: -3%; opacity: 0; }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { top: 103%; opacity: 0; }
}
@keyframes hero-scan-drift {
  0%   { top: -2%;  opacity: 0; }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { top: 102%; opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-box::before { transition: none; }
  .is-hero-revealed .hero-box .hero-scan-reveal,
  .is-hero-revealed .hero-box .hero-scan-drift { animation: none; }
  /* Static jitter off; the noise itself stays visible as a frozen
     treatment, still reads as CRT but no longer animates. */
  .hero-static { animation: none; }
  /* Corner brackets settle without the slide-in transform. */
  .hero-corner { transition: opacity 240ms ease 200ms; transform: none; }
}

/* When .is-hero-revealed is added to <html> (by main.js after the
   loader finishes and the grid has had time to pop in), the
   hero-box grows from 0 to its target height — pushing the work
   grid below it down the page. */
.is-hero-revealed .hero-box {
  height: var(--hero-height, 60vh);
}
/* Repeat-visit shortcut: when html.is-hero-instant is set (by the
   inline script in <body> on repeat visits), the hero appears at
   full height immediately with no slide-in animation. Disables the
   height transition + the ::before fade so there's no perceived
   delay before the page is "settled". */
.is-hero-instant .hero-box,
.is-hero-instant .hero-box::before {
  transition: none;
}

.work-grid-wrap {
  position: relative;
  width: 100%;
  padding: 0 0 var(--s-7);
  background: var(--color-bg-2);
  /* No overflow clip — tiles tilt + scale on hover and can extend
     past the wrap bounds. No `isolation: isolate` either — the
     hovered tile's z-index needs to escape this wrap's stacking
     context so it can render above the fixed sidebar. */
  overflow: visible;
}
/* CRT phosphor flicker — mostly steady, with rare big dips.
   Slow cycle, subtle range, occasional haunted-CRT dropouts.
   No mix-blend-mode: this layer used to be `screen`-blended, but that
   made its visible colour depend on whatever was beneath it. As tiles
   faded in / expanded, the backdrop changed and the flicker computed
   to a different shade — visible as a "shift" in the bg behind the
   grid. Composited as a plain low-alpha layer instead, the flicker
   pulses opacity over a fixed colour and stays consistent regardless
   of tile state. */
.work-grid-wrap::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background: rgba(180, 255, 200, 0.025);
  animation: crt-flicker 12s steps(1) infinite;
}
@keyframes crt-flicker {
  /* Mostly steady — opacity stays in the 0.92–1.0 range so the layer
     reads as constant. Three rare, short dips per 12s cycle suggest a
     tube under load without competing with the work for attention. */
  0%   { opacity: 1;    }
  10%  { opacity: 0.97; }
  20%  { opacity: 0.99; }
  25%  { opacity: 0.78; } /* rare dip */
  27%  { opacity: 0.99; }
  35%  { opacity: 0.96; }
  45%  { opacity: 0.98; }
  55%  { opacity: 0.94; }
  60%  { opacity: 0.75; } /* rare dip */
  62%  { opacity: 0.98; }
  70%  { opacity: 0.97; }
  80%  { opacity: 0.99; }
  85%  { opacity: 0.8;  } /* rare dip */
  87%  { opacity: 0.99; }
  95%  { opacity: 0.96; }
  100% { opacity: 1;    }
}
/* Tiles need to sit above the bg layers */
/* Position context only — no z-index. The bg pseudo-elements above
   render in natural paint order (before/after the children) and
   don't trap the hovered tile's z-index inside a stacking context. */
.work-grid-wrap > * { position: relative; }
@media (prefers-reduced-motion: reduce) {
  .work-grid-wrap::after { animation: none; opacity: 0; }
}

/* When main is followed by the full-width grid wrap, drop main's bottom
   padding so the gap between intro and grid stays tight. */
main:has(+ .work-grid-wrap) {
  padding-bottom: 0;
}

/* ---------- About ---------- */
.about-grid {
  display: grid;
  grid-template-columns: 1fr 1.6fr;
  gap: var(--s-6);
  align-items: start;
}
.about-photo {
  width: 100%;
  margin: 0;
  background: transparent;
  border-radius: var(--radius);
  overflow: hidden;
}
.about-photo img,
.about-photo video {
  width: 100%;
  height: auto;
  display: block;
}
/* Screen-blend the video so its dark background drops out and only
   the lighter pixels (the figure) show against the page */
.about-photo .about-video {
  mix-blend-mode: screen;
}

/* ----------------------------------------------------------------------
   Side-out panels (.panel.panel--about, .panel.panel--contact)

   Slide out from behind the mega-menu to fill the frame area to the
   right of the sidebar/menu when their menu link is clicked.

   - Position: fixed inside the rounded site frame, same top/bottom as
     the menu and sidebar so they all line up.
   - Left edge starts at the menu's left edge — this puts the panel
     UNDER the menu when open. Its left ~560px stays hidden behind the
     menu; only the area past the menu reads as "panel".
   - z-index 150 — above the frame keyline (100) but below the menu
     (200) and sidebar (250).
   - Hidden state: translateX(-100%) of the panel's own width, parking
     the right edge at the menu's left edge (i.e. fully behind it).
     Open state: translateX(0) — right edge slides out from behind the
     menu and across to the frame edge.
   - .panel-scroll is the actual scroll container; .panel-inner adds
     padding-left equal to the menu width + breathing room so content
     starts visually past the menu, not buried under it.
   ---------------------------------------------------------------------- */
.panel {
  /* display:none in the rest state guarantees the panel can't
     intercept any clicks or take any space — earlier the
     translateX-hidden panel was overlapping the sidebar/burger area
     even with pointer-events: none, blocking the menu from opening.
     JS swaps display:none ↔ display:block and toggles .is-open in
     the next frame so the transform transition still runs. */
  display: none;
  position: fixed;
  top: var(--site-frame-margin);
  bottom: var(--site-frame-margin);
  left: calc(var(--site-frame-margin) + var(--sidebar-width));
  right: var(--site-frame-margin);
  z-index: 150;
  background: var(--color-bg-2);
  /* Round only the right corners — left edge sits flush against
     (and behind) the menu. */
  border-radius: 0 var(--site-frame-radius) var(--site-frame-radius) 0;
  border: 2px solid var(--color-rule);
  border-left: 0;
  /* Shadow is transparent at rest and fades to its full intensity
     in .is-open — so when the panel slides out, the shadow fades
     alongside it instead of popping out of existence the moment the
     JS unmounts the panel. Shadow uses a slightly shorter duration
     so it finishes before the slide does — no trailing dark band
     hanging at the rail edge at the end of close. */
  box-shadow: 16px 0 40px -8px rgba(0, 0, 0, 0);
  transform: translateX(-100%);
  /* Strong S-curve ease-in-out + longer duration for a more theatrical
     entry. Panel hangs back, then snaps through the middle, then
     settles deliberately at the right edge. Closing plays the curve
     in reverse for symmetric drama. */
  transition:
    transform   760ms cubic-bezier(0.83, 0, 0.17, 1),
    box-shadow  560ms cubic-bezier(0.83, 0, 0.17, 1);
  overflow: hidden;
}

.panel.is-mounted { display: block; }
/* While mid-close (mounted but no longer .is-open) the panel is
   still display: block — block clicks so the user can interact with
   the menu/sidebar/work grid behind it. */
.panel.is-mounted:not(.is-open) { pointer-events: none; }
.panel.is-open {
  transform: translateX(0);
  box-shadow: 16px 0 40px -8px rgba(0, 0, 0, 0.45);
}

/* Right-anchored panel variant — slides in from the RIGHT edge of
   the frame instead of the left. Used for the colophon. Mirrors the
   .panel rules but flips horizontal anchor, transform direction,
   border-radius, and shadow direction. */
.panel--right {
  left: auto;
  right: var(--site-frame-margin);
  border-radius: var(--site-frame-radius) 0 0 var(--site-frame-radius);
  border-left: 2px solid var(--color-rule);
  border-right: 0;
  transform: translateX(100%);
  box-shadow: -16px 0 40px -8px rgba(0, 0, 0, 0);
}
.panel--right.is-open {
  transform: translateX(0);
  box-shadow: -16px 0 40px -8px rgba(0, 0, 0, 0.45);
}

/* Bottom-anchored panel variant — slides UP from below the frame
   instead of in from the side. Used for case study panels: the
   bottom-sheet metaphor (familiar from iOS) signals "drilling into
   detail on a specific item", as distinct from .panel--right's
   sibling-navigation feel. All four sides are exposed inside the
   frame (the panel doesn't sit flush against any edge), so we round
   all four corners and restore a full border. Slightly shorter
   duration than the side panels — a full panel-height of vertical
   travel feels weightier at 760ms, so we pull the timing in a touch
   (680ms) to keep the pace honest. Shadow rises from above so the
   sheet reads as lifted off the page. */
.panel--bottom {
  border: 2px solid var(--color-rule);
  border-radius: var(--site-frame-radius);
  transform: translateY(100%);
  box-shadow: 0 -16px 40px -8px rgba(0, 0, 0, 0);
  transition:
    transform   680ms cubic-bezier(0.83, 0, 0.17, 1),
    box-shadow  520ms cubic-bezier(0.83, 0, 0.17, 1);
}
.panel--bottom.is-open {
  transform: translateY(0);
  box-shadow: 0 -16px 40px -8px rgba(0, 0, 0, 0.45);
}

/* On desktop the panels don't need to fill the whole frame.
   Below 1100px both panels fall back to a centred modal (see media
   query further down). */
@media (min-width: 1100px) {
  /* About: a tighter column sized like the contact panel so the
     content sits at a comfortable measure without needing a content
     cap. Earlier formula (2/3-of-leftover) made the panel ~1200px
     wide on a 1920px viewport — CV sections sprawled. Clamp now
     keeps the panel at a deliberate 820–1040px range with the grid
     peeking through on the right. */
  .panel--about {
    right: auto;
    width: clamp(820px, 54vw, 1040px);
  }

  /* Colophon: narrower than About since the content is just text
     notes, no photo column or skills grid. Anchored to the right
     edge of the frame; the rail + grid stay visible on the left. */
  .panel--colophon {
    left: auto;
    width: clamp(420px, 36vw, 680px);
  }

  /* Case study: full content area beside the rail. Uses every pixel
     between the rail and the right frame edge — case studies want
     real room for hero imagery, 2-up grids, and proper reading
     columns. Pairs with .panel--bottom for the slide direction
     (case studies rise from below; colophon and contact slide in
     from the side). Geometry is restated here explicitly even though
     it matches the base .panel rule — keeps the intent legible. */
  .panel--case {
    left: calc(var(--site-frame-margin) + var(--sidebar-width));
    right: var(--site-frame-margin);
    width: auto;
  }
  /* Cap the inner content column so it doesn't sprawl on very wide
     monitors. Panel itself stays full-width (so the slide-in still
     reads as a dramatic full take-over and the dim backdrop covers
     the whole grid), but the actual content sits at a comfortable
     max measure, centred. At 1700px+ viewports there will be visible
     panel bg on either side of the content — that breathing room is
     deliberate. */
  .panel--case .panel-inner {
    max-width: 1240px;
    margin: 0 auto;
  }
}

/* Mobile + tablet: the sidebar becomes a top bar at 700px and the
   "slide out from behind the left-edge menu" model stops making sense.
   Below 900px we switch the panels to a centred modal — overlay, dim
   backdrop, fade + scale in. The same .is-mounted / .is-open machinery
   drives them; only the geometry changes here. */
@media (max-width: 1099px) {
  .panel,
  .panel--about,
  .panel--contact {
    /* Reset positioning — modal is centred in the viewport with a
       comfortable margin on all sides, capped at 640px wide. */
    top: 50%;
    left: 50%;
    right: auto;
    bottom: auto;
    /* Explicit height — the desktop layout used top+bottom to span
       the frame; in modal mode we set a concrete size so .panel-scroll
       (position: absolute) has something to fill, and the modal
       doesn't collapse to its content height (which is 0 because the
       scroller is taken out of flow). */
    width: min(640px, calc(100vw - var(--s-4) * 2));
    height: min(720px, calc(100vh - var(--s-4) * 2));
    /* Hidden state — fade + scale-up. Same dramatic S-curve as the
       desktop slide so the panel "lands" with the same theatricality
       regardless of viewport. Bigger starting scale (0.90 vs 0.96)
       so the pop reads stronger on smaller screens. */
    transform: translate(-50%, -50%) scale(0.90);
    opacity: 0;
    transition:
      transform 580ms cubic-bezier(0.83, 0, 0.17, 1),
      opacity   420ms cubic-bezier(0.83, 0, 0.17, 1);
    border-radius: var(--site-frame-radius);
    border-left: 2px solid var(--color-rule);
    box-shadow: 0 24px 64px -8px rgba(0, 0, 0, 0.7);
    /* Above the sidebar (250) and menu (200) so the modal floats
       above the rest of the chrome. */
    z-index: 320;
  }

  .panel.is-open {
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
  }

  /* Re-use the .menu-backdrop element to dim behind the modal. JS
     adds body.panel-open while any panel is open. */
  body.panel-open .menu-backdrop {
    opacity: 1;
    pointer-events: auto;
    /* Above sidebar + menu so it dims everything behind the modal. */
    z-index: 310;
  }

  /* Tighter padding in modal mode — the panel is smaller so symmetric
     desktop padding would eat too much of the content area. */
  .panel-inner {
    padding: var(--s-4);
  }

  .panel-close {
    top: var(--s-2);
    right: var(--s-2);
  }
}

/* ---------- Panel content stagger ----------
   When a panel opens, content sections fade up in cascade so the
   panel arrives and then its contents "settle in." Delays are timed
   against the panel's 760ms slide — first item begins ~280ms in
   (mid-slide), each subsequent block delayed ~70-90ms. Backwards
   fill keeps each item invisible until its delay starts.

   Important: we deliberately DON'T animate .about-hero itself. Doing
   so creates a transient stacking context (from the transform +
   opacity in the keyframe), and that catches the .about-video's
   mix-blend-mode: screen inside it — the video briefly blends
   against the animating wrapper instead of the panel bg, then snaps
   to its real appearance once the animation ends. Animating the
   prose paragraphs individually keeps the photo + its blend mode
   completely undisturbed. */
@keyframes panel-content-fade-up {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0);   }
}

.panel.is-open .about-readme,
.panel.is-open .about-prose > *,
.panel.is-open .cv-divider,
.panel.is-open .cv-skills-v2,
.panel.is-open .cv-role-v2,
.panel.is-open .cv-edu,
.panel.is-open .about-ctas,
.panel.is-open .contact-hero > * {
  animation: panel-content-fade-up 540ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

/* About panel — readme prompt first, prose cascade, then CV
   sections in document order. Avoid animating .about-hero itself
   so the video's mix-blend-mode stays undisturbed. */
.panel.is-open .about-readme                 { animation-delay: 240ms; }
.panel.is-open .about-prose > *:nth-child(1) { animation-delay: 320ms; }
.panel.is-open .about-prose > *:nth-child(2) { animation-delay: 400ms; }
.panel.is-open .about-prose > *:nth-child(3) { animation-delay: 480ms; }
.panel.is-open .about-prose > *:nth-child(4) { animation-delay: 560ms; }
.panel.is-open .cv-divider:nth-of-type(1)    { animation-delay: 640ms; }
.panel.is-open .cv-skills-v2                 { animation-delay: 710ms; }
.panel.is-open .cv-divider:nth-of-type(2)    { animation-delay: 780ms; }
.panel.is-open .cv-role-v2:nth-of-type(1)    { animation-delay: 850ms; }
.panel.is-open .cv-role-v2:nth-of-type(2)    { animation-delay: 920ms; }
.panel.is-open .cv-role-v2:nth-of-type(3)    { animation-delay: 990ms; }
.panel.is-open .cv-role-v2:nth-of-type(4)    { animation-delay: 1060ms; }
.panel.is-open .cv-divider:nth-of-type(3)    { animation-delay: 1130ms; }
.panel.is-open .cv-edu                       { animation-delay: 1200ms; }
.panel.is-open .about-ctas                   { animation-delay: 1270ms; }

/* Contact panel — h1, intro, direct contacts, then form fields. */
.panel.is-open .contact-hero > *:nth-child(1) { animation-delay: 280ms; }
.panel.is-open .contact-hero > *:nth-child(2) { animation-delay: 360ms; }
.panel.is-open .contact-hero > *:nth-child(3) { animation-delay: 440ms; }
.panel.is-open .contact-hero > *:nth-child(4) { animation-delay: 520ms; }
.panel.is-open .contact-hero > *:nth-child(5) { animation-delay: 600ms; }

@media (prefers-reduced-motion: reduce) {
  .panel { transition: none; }
  .panel.is-open .about-readme,
  .panel.is-open .about-prose > *,
  .panel.is-open .cv-divider,
  .panel.is-open .cv-skills-v2,
  .panel.is-open .cv-role-v2,
  .panel.is-open .cv-edu,
  .panel.is-open .about-ctas,
  .panel.is-open .contact-hero > * {
    animation: none;
  }
}

.panel-scroll {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--color-rule) transparent;
}
.panel-scroll::-webkit-scrollbar { width: 8px; }
.panel-scroll::-webkit-scrollbar-track { background: transparent; }
.panel-scroll::-webkit-scrollbar-thumb {
  background: var(--color-rule);
  border-radius: 4px;
}

.panel-inner {
  /* No mega-menu covers the panel anymore — the rail sits flush against
     the panel's left edge, so symmetric horizontal padding gives the
     content a clean column. Generous bottom padding so the last block
     clears the scrollbar gutter on long content. */
  padding: var(--s-5) var(--s-6) var(--s-7);
  max-width: 100%;
  box-sizing: border-box;
}

/* Panel close button — fixed top-right of the panel, lives ABOVE
   .panel-scroll so it stays put as content scrolls. Labelled pill
   chip: a × glyph, the word "close", and an "esc" keyboard hint, in
   the terminal accent font. Reads at-a-glance from across the full
   case-study panel where a bare × in a faint circle was getting
   lost. Inherits the canonical interactable colour at rest, flips to
   watermelon on hover/focus.

   Hover animation: a watermelon disc anchored at the × glyph scales
   up and floods the pill outward — the "destructive" colour comes
   FROM the action's own symbol rather than just appearing under it.
   Echoes the loader's coffee-cursor bloom that wipes the screen at
   the end of the boot sequence. Meanwhile the × punches up in scale
   (button-press feedback) and the text colour inverts to bg, timed
   to land mid-bloom so the letters don't change colour before the
   watermelon has visibly reached them. On mouseleave the whole
   thing reverses — the bloom contracts back into the × position. */
.panel-close {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
  isolation: isolate;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px 8px 12px;
  border-radius: 999px;
  border: 2px solid var(--color-link);
  background: transparent;
  color: var(--color-link);
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
  z-index: 2;
  /* Border + colour delayed slightly so they shift AFTER the bloom
     has begun to cover the chip — keeps the visual order
     "bloom-first, invert-second" instead of all changing at once. */
  transition:
    border-color 280ms cubic-bezier(0.83, 0, 0.17, 1) 80ms,
    color        300ms cubic-bezier(0.83, 0, 0.17, 1) 60ms;
}
/* Watermelon bloom — small disc anchored at the × glyph's position
   on the left side of the chip. At rest scale(0); on hover scales to
   ~14× to flood the entire pill from a single point. Disc shape +
   chip's overflow:hidden + pill border-radius means the bloom is
   clipped to the chip outline as it expands. Same S-curve as the
   panel slides for motion-language consistency. */
.panel-close::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 18px;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  background: var(--color-watermelon);
  transform: scale(0);
  transform-origin: center;
  transition: transform 440ms cubic-bezier(0.83, 0, 0.17, 1);
  z-index: 0;
  pointer-events: none;
}
/* Children sit above the bloom layer. */
.panel-close__glyph,
.panel-close__label,
.panel-close__hint {
  position: relative;
  z-index: 1;
}
/* The × glyph — slightly larger than the label text and optically
   nudged up so it sits aligned with the cap-height of the label
   rather than the baseline. On hover it punches up in scale: a small
   "press" gesture that feels like the bloom is being released from
   under it. */
.panel-close__glyph {
  font-size: 18px;
  line-height: 1;
  display: inline-block;
  transform: translateY(-1px) scale(1);
  transition: transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Action label — main reading target, inherits chip colour. */
.panel-close__label {
  display: inline-block;
}
/* Keyboard hint — supporting metadata, separated from the label by a
   thin vertical rule. Dim at rest so it reads as a secondary cue,
   not as part of the action verb. */
.panel-close__hint {
  display: inline-block;
  padding-left: 10px;
  border-left: 1px solid currentColor;
  opacity: 0.55;
  transition: opacity 240ms ease 80ms;
}
.panel-close:hover,
.panel-close:focus-visible {
  border-color: var(--color-watermelon);
  color: var(--color-bg);
  outline: none;
}
.panel-close:hover::before,
.panel-close:focus-visible::before {
  transform: scale(30);
}
.panel-close:hover .panel-close__glyph,
.panel-close:focus-visible .panel-close__glyph {
  transform: translateY(-1px) scale(1.2);
}
/* Hint stays slightly subordinate even on hover so the eye finds
   "close" first, then registers "esc" as the alternative. */
.panel-close:hover .panel-close__hint,
.panel-close:focus-visible .panel-close__hint {
  opacity: 0.75;
}
/* Reduced motion — keep the colour invert but skip the bloom and
   glyph punch. Snap the disc straight to its end state on hover so
   the hover still reads as a state change. */
@media (prefers-reduced-motion: reduce) {
  .panel-close,
  .panel-close::before,
  .panel-close__glyph,
  .panel-close__hint {
    transition: none;
  }
  .panel-close:hover::before,
  .panel-close:focus-visible::before {
    transform: scale(30);
  }
}

/* About panel — split hero with photo + prose. Originally lived in
   a dedicated /about.html, now opens via the .tile--intro tile-frame
   triggered by the mega-menu's About link. */
.about-main {
  padding: var(--s-6) var(--s-5);
  max-width: 880px;
  margin: var(--s-4) auto;
  border: 2px solid var(--color-rule);
  border-radius: var(--site-frame-radius);
}
.about-hero {
  display: grid;
  grid-template-columns: minmax(220px, 280px) 1fr;
  gap: var(--s-5);
  align-items: start;
}
.about-hero .about-prose h1 {
  font-family: var(--font-display);
  font-size: clamp(48px, 6vw, 96px);
  font-weight: 300;
  letter-spacing: 0.04em;
  line-height: 1;
  margin: 0 0 var(--s-3);
  color: var(--color-fg);
}
.about-cta { margin-top: var(--s-3); }
/* Matched to .tile-frame-link styling so all CTAs across the site —
   the "View the site ↗" buttons inside expanded project tiles and
   the panel CTAs (Download CV, Send message) — read as one button
   family. */
.btn-cv {
  display: inline-flex;
  align-items: center;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: 9px 16px;
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-link);
  background: transparent;
  border: 2px solid var(--color-link);
  border-radius: 999px;
  text-decoration: none;
  transition:
    color 0.18s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
    border-color 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-cv::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-link);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
  z-index: -1;
  pointer-events: none;
}
.btn-cv::after { content: " ↓"; margin-left: 8px; }
/* Hover handled by the shared .tile-frame-link/.btn-cv/.btn-contact
   rules above (power-on-wipe + escalating glitch). */
@media (max-width: 860px) {
  .about-hero {
    grid-template-columns: 1fr;
    gap: var(--s-4);
  }
  .about-photo { max-width: 280px; }
}

/* CV block on the about page */
.cv {
  margin-top: var(--s-7);
  border-top: 2px solid var(--color-rule);
  padding-top: var(--s-5);
}
.cv-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin: 0 0 var(--s-4);
}
.cv-section-title {
  font-family: var(--font-display);
  font-size: clamp(36px, 4vw, 56px);
  font-weight: 300;
  letter-spacing: 0.04em;
  margin: 0;
  color: var(--color-fg);
}
.cv-block { margin-bottom: var(--s-5); }
.cv-block-title {
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: var(--color-muted);
  margin: 0 0 var(--s-3);
  padding-bottom: 8px;
  border-bottom: 2px solid var(--color-rule);
}

/* Skills definition list */
.cv-skills {
  display: grid;
  grid-template-columns: 140px 1fr;
  gap: var(--s-1) var(--s-3);
  margin: 0;
}
.cv-skills dt {
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-muted);
  align-self: baseline;
}
.cv-skills dd {
  margin: 0;
  font-size: 15px;
  line-height: 1.7;
  color: var(--color-fg);
}

/* Role / education entries */
.cv-role { margin-bottom: var(--s-3); }
.cv-role-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--s-1);
  margin-bottom: 4px;
}
.cv-role-head strong { font-weight: 500; }
.cv-role-dates {
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--color-muted);
  white-space: nowrap;
}
.cv-role p {
  margin: 0 0 var(--s-1);
  font-size: 15px;
  line-height: 1.75;
  color: var(--color-fg);
}
.cv-highlights {
  margin: var(--s-1) 0 0;
  padding-left: 18px;
}
.cv-highlights li {
  font-size: 14px;
  line-height: 1.7;
  color: var(--color-fg);
  margin-bottom: 6px;
}

@media (max-width: 700px) {
  .cv-skills { grid-template-columns: 1fr; gap: 4px var(--s-3); }
  .cv-skills dt { margin-top: var(--s-2); }
  .cv-skills dt:first-of-type { margin-top: 0; }
}

/* ---------- About panel v2 ----------
   Folded in from about-v2.html. Adds the terminal-style READ.ME
   header, tiered Skills layout, bracket-date Experience treatment,
   footnote-style Education, and twin CTA bar. Reuses the panel
   shell (.panel.panel--about) and the existing .about-hero +
   .about-photo + .about-video rules — only the new selectors live
   below. */

/* READ.ME prompt above the hero */
.about-readme {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--color-link);
  margin-bottom: var(--s-4);
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.about-readme::before { content: "> "; opacity: 0.7; }
.about-readme::after {
  content: "";
  display: inline-block;
  width: 8px;
  height: 12px;
  background: var(--color-link);
  animation: about-cursor-blink 0.85s step-end infinite;
}
@keyframes about-cursor-blink {
  0%, 50%      { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* Update the existing .about-hero h1 to be more confident (was light
   weight 300 with positive letter-spacing — felt timid). And bump
   photo column to 320px max. */
.panel--about .about-hero {
  grid-template-columns: minmax(220px, 320px) 1fr;
  margin-bottom: var(--s-5);
}
.panel--about .about-hero .about-prose h1 {
  font-weight: 500;
  letter-spacing: -0.02em;
}
.panel--about .about-hero .about-prose p {
  font-size: 17px;
  line-height: 1.7;
  margin: 0 0 var(--s-3);
  max-width: 56ch;
}
.panel--about .about-hero .about-prose p:last-child { margin-bottom: 0; }
.panel--about .about-hero .about-prose .credentials {
  color: var(--color-muted);
  font-size: 15px;
  line-height: 1.75;
}
.panel--about .about-hero .about-prose .credentials strong {
  color: var(--color-fg);
  font-weight: 500;
}
.panel--about .about-hero .about-prose .credentials a {
  color: var(--color-link);
  text-decoration: none;
  border-bottom: 1px dotted rgba(92, 255, 142, 0.4);
}
.panel--about .about-hero .about-prose .credentials a:hover { border-color: var(--color-link); }

/* Terminal section divider — replaces the old .cv-section-title
   block treatment. Phosphor line extends to the right of the label. */
.cv-divider {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: var(--s-5) 0 var(--s-3);
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--color-link);
  font-weight: 500;
}
.cv-divider::before { content: "//"; opacity: 0.65; }
.cv-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--color-rule);
}

/* Tiered Skills list */
.cv-skills-v2 {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 12px var(--s-4);
  margin: 0;
  padding: 0;
}
.cv-skills-v2 dt {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--color-muted);
  align-self: baseline;
  padding-top: 4px;
}
.cv-skills-v2 dt::before { content: "> "; }
.cv-skills-v2 dd {
  margin: 0;
  font-size: 16px;
  line-height: 1.65;
  color: var(--color-fg);
}
.cv-skills-v2 dd .core {
  color: var(--color-link);
  font-weight: 500;
}

/* Experience role — bracket date stamp, phosphor left-border on
   the current role to weight it visually. */
.cv-role-v2 {
  margin-bottom: var(--s-4);
  padding-left: var(--s-3);
  border-left: 2px solid var(--color-rule);
}
.cv-role-v2.is-current { border-left-color: var(--color-link); }
.cv-role-v2 .role-date {
  display: inline-block;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--color-muted);
  margin-bottom: 8px;
}
.cv-role-v2 .role-date::before { content: "[ "; }
.cv-role-v2 .role-date::after  { content: " ]"; }
.cv-role-v2 .role-title {
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 500;
  color: var(--color-fg);
  margin: 0 0 var(--s-2);
}
.cv-role-v2 .role-company { color: var(--color-link); }
.cv-role-v2 p,
.cv-role-v2 ul {
  font-size: 15px;
  line-height: 1.7;
  color: var(--color-fg);
  margin: 0 0 var(--s-2);
}
.cv-role-v2 ul { list-style: none; padding: 0; }
.cv-role-v2 li {
  position: relative;
  padding-left: 16px;
  margin-bottom: 6px;
}
.cv-role-v2 li::before {
  content: "·";
  position: absolute;
  left: 0;
  color: var(--color-link);
}

/* Education — footnote style, tighter than experience */
.cv-edu {
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: 14px;
  color: var(--color-muted);
}
.cv-edu .edu-line {
  display: flex;
  gap: var(--s-3);
  align-items: baseline;
}
.cv-edu .edu-line strong {
  color: var(--color-fg);
  font-weight: 500;
}
.cv-edu .edu-date {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.18em;
  color: var(--color-muted);
  flex-shrink: 0;
}

/* CTA bar at the bottom — twin buttons matching the site CTA family */
.about-ctas {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-5);
  padding-top: var(--s-3);
  border-top: 1px solid var(--color-rule);
}
/* .btn-contact mirrors .btn-cv chrome — same sweep + colour family,
   different glyph after the label. */
.btn-contact {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 9px 16px;
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-link);
  background: transparent;
  border: 2px solid var(--color-link);
  border-radius: 999px;
  text-decoration: none;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  transition:
    color 0.18s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
    border-color 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-contact::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-link);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
  z-index: -1;
}
/* Hover handled by the shared .tile-frame-link/.btn-cv/.btn-contact
   rules above (power-on-wipe + escalating glitch). */
/* Icon inside .btn-contact — inherits color from button (phosphor at
   rest, dark on hover thanks to the sweep). flex-shrink keeps it
   anchored next to the label even on tight viewports. */
.btn-icon {
  flex-shrink: 0;
  display: block;
}

@media (max-width: 720px) {
  .panel--about .about-hero {
    grid-template-columns: 1fr;
    gap: var(--s-4);
  }
  .panel--about .about-photo { max-width: 280px; }
  .cv-skills-v2 { grid-template-columns: 1fr; gap: 4px; }
  .cv-skills-v2 dt { margin-top: var(--s-2); }
  .cv-skills-v2 dt:first-of-type { margin-top: 0; }
  .cv-edu .edu-line { flex-direction: column; gap: 2px; }
}

/* ---------- Contact page ---------- */
.contact-main {
  padding: var(--s-6) var(--s-5);
  max-width: 880px;
  margin: var(--s-4) auto;
  border: 2px solid var(--color-rule);
  border-radius: var(--site-frame-radius);
}
.contact-hero h1 {
  font-family: var(--font-display);
  font-size: clamp(48px, 6vw, 96px);
  font-weight: 300;
  letter-spacing: 0.04em;
  line-height: 1;
  margin: 0 0 var(--s-3);
  color: var(--color-fg);
}
.contact-intro {
  font-size: 17px;
  line-height: 1.75;
  color: var(--color-fg);
  max-width: 60ch;
  margin: 0 0 var(--s-4);
}

/* Direct contact list — email + LinkedIn */
.contact-direct {
  list-style: none;
  padding: var(--s-3) 0;
  margin: 0 0 var(--s-5);
  border-top: 2px solid var(--color-rule);
  border-bottom: 2px solid var(--color-rule);
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-2) var(--s-4);
}
.contact-direct li {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.contact-direct-label {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-muted);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.contact-direct-icon {
  flex-shrink: 0;
  display: block;
}
.contact-direct a {
  font-size: 15px;
  color: var(--color-link);
  word-break: break-all;
}
.contact-direct a:hover { color: var(--color-link-hover); }

/* Form */
.contact-form {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  width: 100%;
}
.contact-row {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  width: 100%;
}
.contact-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 100%;
  position: relative;
}
/* Phosphor sweep underline — grows in from the left when the field's
   input is focused, matching the sweep-highlight language used on the
   CTA buttons. Sits at the bottom of the field's input/textarea so
   it reads as an "active line" rather than a doubled border. */
.contact-field::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: var(--color-link);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
  border-radius: 0 0 4px 4px;
  box-shadow: 0 0 10px rgba(92, 255, 142, 0.35);
}
.contact-field:focus-within::after {
  transform: scaleX(1);
}
@media (prefers-reduced-motion: reduce) {
  .contact-field::after { transition: none; }
}
.contact-label {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-muted);
  transition: color 220ms ease;
}
.contact-field:focus-within .contact-label {
  color: var(--color-link);
}
.contact-field input,
.contact-field textarea {
  display: block;
  width: 100%;
  box-sizing: border-box;
  font-family: var(--font-body);
  font-size: 15px;
  color: var(--color-fg);
  background: var(--color-bg-2);
  border: 2px solid var(--color-rule);
  border-radius: 4px;
  padding: 12px 14px;
  outline: none;
  transition: border-color 180ms ease, background-color 180ms ease;
}
.contact-field textarea {
  resize: vertical;
  min-height: 140px;
  font-family: var(--font-body);
}
.contact-field input:focus,
.contact-field textarea:focus {
  border-color: var(--color-link);
}
.contact-field input::placeholder,
.contact-field textarea::placeholder {
  color: var(--color-muted);
}

/* Submit — pill button matching the rest of the system */
/* Same CTA chrome as .tile-frame-link and .btn-cv — matched padding,
   border, and the left-to-right sweep highlight on hover. */
.contact-submit {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  margin-top: var(--s-2);
  padding: 9px 16px;
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-link);
  background: transparent;
  border: 2px solid var(--color-link);
  border-radius: 999px;
  cursor: pointer;
  transition:
    color 0.18s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
    border-color 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Sweep highlight — phosphor block grows from the left edge */
.contact-submit::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-link);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
  z-index: -1;
  pointer-events: none;
}
.contact-submit::after { content: " →"; margin-left: 6px; }
.contact-submit:hover,
.contact-submit:focus-visible {
  color: var(--color-bg);
  border-color: var(--color-link);
}
.contact-submit:hover::before,
.contact-submit:focus-visible::before {
  transform: scaleX(1);
}

@media (max-width: 700px) {
  .contact-direct { grid-template-columns: 1fr; }
}

.about-prose { max-width: 60ch; }
.about-prose p { font-size: 17px; line-height: 1.85; color: var(--color-fg); }
.about-prose h3 {
  font-size: 28px;
  margin: var(--s-4) 0 var(--s-2);
}

.client-list, .credit-list {
  list-style: none;
  padding: 0;
  margin: 0 0 var(--s-3);
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0;
}
.client-list li, .credit-list li {
  font-family: var(--font-accent);
  font-size: 13px;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: var(--color-muted);
  padding: 0.6rem 0;
  border-bottom: 2px solid var(--color-rule);
}

/* CV / experience list */
.cv-item {
  padding: var(--s-3) 0;
  border-bottom: 2px solid var(--color-rule);
}
.cv-item:last-child { border-bottom: 0; }
.cv-role {
  font-family: var(--font-display);
  font-size: 28px;
  margin: 0 0 0.25rem;
}
.cv-meta {
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: var(--color-muted);
  margin: 0 0 var(--s-2);
}
.cv-item p { color: var(--color-fg); }

/* ---------- Contact ---------- */
.contact-grid {
  display: grid;
  grid-template-columns: 1.5fr 1fr;
  gap: var(--s-6);
  align-items: start;
}

/* Legacy .contact-form / .contact-aside styles removed — superseded by
   the .contact-main, .contact-direct, .contact-field rules above. */

/* ---------- Case study ---------- */
.case-study .meta-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--s-3);
  margin: var(--s-4) 0 0;
}
.case-study .meta-grid div {
  border-top: 2px solid var(--color-rule);
  padding-top: var(--s-2);
}
.case-study dt {
  font-family: var(--font-accent);
  font-size: 11px;
  text-transform: uppercase;
  color: var(--color-muted);
  letter-spacing: 2px;
  margin-bottom: 0.4rem;
}
.case-study dd { margin: 0; font-size: 15px; color: var(--color-fg); }

.case-hero {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: var(--color-bg-2);
  background-size: cover;
  background-position: center;
  border-radius: var(--radius);
  margin-bottom: var(--s-5);
  /* Shared-element view transition target — the homepage tile's media
     morphs into this on navigation. */
  view-transition-name: project-image;
}

.prose { max-width: 65ch; margin: 0 auto var(--s-5); }
.prose h2 {
  font-size: 44px;
  margin: var(--s-5) 0 var(--s-2);
}
.prose p { color: var(--color-fg); font-size: 17px; }

.gallery {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--s-3);
  margin-bottom: var(--s-5);
}
.gallery-item {
  aspect-ratio: 4 / 3;
  background-color: var(--color-bg-2);
  background-size: cover;
  background-position: center;
  border-radius: var(--radius);
}
.gallery-wide { grid-column: 1 / -1; aspect-ratio: 16 / 7; }

.case-nav {
  display: flex;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-5) 0;
  font-family: var(--font-accent);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 2px;
}
.case-nav a { color: var(--color-muted); }
.case-nav a:hover { color: var(--color-link); }

/* ---------- Footer ---------- */
.site-footer {
  background: var(--color-bg-2);
  border-top: 1px solid var(--color-rule);
  padding: var(--s-3) var(--s-4);
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-fg);
  /* Flex so the .footer-info wiping content takes the left space
     and the .footer-colophon link sits at the right (via its own
     margin-left: auto). Without this, .footer-info would block the
     button from finding the right side. */
  display: flex;
  align-items: center;
  gap: var(--s-4);
}
/* Override the original .footer-info width: 100% so it shares the
   row with the colophon button. flex: 1 1 auto lets it take what's
   left after the button is laid out. */
.site-footer > .footer-info { width: auto; flex: 1 1 auto; }
.footer-info {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-4);
  width: 100%;
  clip-path: inset(0 100% 0 0);
  animation: footer-type 7s linear infinite;
}
@keyframes footer-type {
  0%   { clip-path: inset(0 100% 0 0); }
  45%  { clip-path: inset(0 0 0 0); }
  90%  { clip-path: inset(0 0 0 0); }
  91%  { clip-path: inset(0 100% 0 0); }
  100% { clip-path: inset(0 100% 0 0); }
}
@media (prefers-reduced-motion: reduce) {
  .footer-info { clip-path: none; animation: none; }
}
.footer-stat { white-space: nowrap; }
.footer-label { color: var(--color-muted); margin-right: 0.5em; }
.footer-label::after { content: ":"; }
.footer-stat a {
  color: var(--color-fg);
  text-decoration: none;
  transition: color 0.18s ease;
}
.footer-stat a:hover { color: var(--color-link); }
.footer-cursor {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--color-fg);
  animation: footer-cursor-blink 1s steps(2) infinite;
}
@keyframes footer-cursor-blink {
  0%, 50%      { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .footer-cursor { animation: none; }
}

/* Colophon trigger — sits in the footer alongside .footer-info as a
   sibling, so it stays put while the footer types itself on. Styled
   as muted terminal text matching the footer rhythm. Aligned to the
   far right via margin-left: auto on the footer's flex layout. */
.footer-colophon {
  background: transparent;
  border: 0;
  padding: 0;
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-muted);
  cursor: var(--cursor-pointer) 12 12, pointer;
  transition: color 0.18s ease;
}
.footer-colophon__prefix { opacity: 0.6; }
.footer-colophon:hover,
.footer-colophon:focus-visible {
  color: var(--color-link);
  outline: none;
}

/* Colophon panel content typography — tighter than About since each
   section is just a short paragraph or list. */
.panel--colophon h1 {
  font-family: var(--font-display);
  font-size: clamp(40px, 4.5vw, 64px);
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1;
  margin: 0 0 var(--s-3);
  color: var(--color-fg);
}
.panel--colophon .colophon-lede {
  font-size: 16px;
  line-height: 1.65;
  color: var(--color-fg);
  margin: 0 0 var(--s-3);
}
.panel--colophon .colophon-lede strong {
  color: var(--color-link);
  font-weight: 500;
}
/* Tighter section dividers inside the colophon — overrides the
   About-panel default (var(--s-5) top / var(--s-3) bottom) since
   colophon sections are short. */
.panel--colophon .cv-divider {
  margin: var(--s-4) 0 var(--s-2);
}
.panel--colophon .colophon-body {
  font-size: 14px;
  line-height: 1.65;
  color: var(--color-fg);
  margin: 0;
}
.panel--colophon .colophon-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
.panel--colophon .colophon-list li {
  position: relative;
  padding-left: 14px;
  margin-bottom: 6px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--color-fg);
}
.panel--colophon .colophon-list li::before {
  content: "·";
  position: absolute;
  left: 0;
  color: var(--color-link);
}
/* Also tighten the panel-inner padding for colophon — it's a smaller
   panel and doesn't need the desktop About-panel breathing room. */
.panel--colophon .panel-inner {
  padding: var(--s-4) var(--s-5) var(--s-6);
}
.panel--colophon .colophon-body code,
.panel--colophon .colophon-list code {
  font-family: var(--font-accent);
  font-size: 12px;
  color: var(--color-link);
  background: rgba(92, 255, 142, 0.08);
  padding: 1px 4px;
  border-radius: 2px;
}

/* ---------- Case study panel typography + layout ----------
   Slides in from the right at full content width. Built for deep
   project reads: hero image, // section dividers (reusing the same
   .cv-divider class as the About panel), 2-up and 3-up image grids,
   credits block. The pattern's intentionally simple so it can be
   duplicated per project with just content swapped. */

.case-eyebrow {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--color-link);
  margin-bottom: var(--s-2);
}
.case-eyebrow::before { content: "// "; opacity: 0.6; }

.case-title {
  font-family: var(--font-display);
  /* Reduced 30% from the original clamp(48px, 5.5vw, 88px) — the
     panel max-width now caps the comfortable headline length, so
     the previous setting was reading a touch heavy. */
  font-size: clamp(34px, 3.85vw, 62px);
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1;
  margin: 0 0 var(--s-5);
  color: var(--color-fg);
}

/* Hero image — full-width inside the .case-top wrapper, sits above
   the brief. When used standalone (without .case-top wrapper), same
   thing: full panel width. */
.case-hero {
  margin: 0 0 var(--s-5);
  background: var(--color-bg);
  border-radius: 4px;
  overflow: hidden;
}
.case-hero img {
  display: block;
  width: 100%;
  height: auto;
}

/* Top stack for hero + brief — single column at every width now that
   the panel max-width caps reading length anyway. The 2-column
   variant was crowding the hero against the brief at the new
   narrower panel size. Hero on top, brief beneath. */
.case-top {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-5);
  align-items: start;
  margin-bottom: var(--s-5);
}
.case-top .case-hero { margin: 0; }
.case-top .case-intro > .cv-divider:first-child { margin-top: 0; }
.case-top .case-intro > .case-prose:last-child { margin-bottom: 0; }

/* Body prose — fills the panel inner width. The panel itself is now
   capped at a comfortable reading width via --expanded-max-width, so
   capping the prose on top of that just creates an awkward right gutter.
   Previously this was 76ch; removed in favour of letting the panel's
   own max-width do the work. */
.case-prose {
  font-size: 16px;
  line-height: 1.7;
  color: var(--color-fg);
  margin: 0 0 var(--s-4);
}

/* Action-block container for a pill button inside a case-study
   panel — standalone block rather than buried inside a <p>. Earlier
   versions wrapped the button in .case-prose, which gave the
   inline-flex pill a line-box too short to contain its padded
   height, so the bottom 2px of the button's border was being
   clipped by the next <h2>. Note: distinct from .case-cta below,
   which is the *inline-text* CTA treatment (small underlined link
   with a trailing →) used inside case-study panels. They look
   totally different — name kept apart on purpose. */
.case-action {
  margin: 0 0 var(--s-4);
}
.case-prose strong { color: var(--color-link); font-weight: 500; }
.case-prose a {
  color: var(--color-link);
  text-decoration: none;
  border-bottom: 1px dotted rgba(92, 255, 142, 0.4);
}
.case-prose a:hover { border-color: var(--color-link); }

/* Image grids — 2-up and 3-up */
.case-grid {
  display: grid;
  gap: var(--s-3);
  margin: 0 0 var(--s-4);
}
.case-grid--2 { grid-template-columns: 1fr 1fr; }
.case-grid--3 { grid-template-columns: 1fr 1fr 1fr; }
.case-grid figure { margin: 0; }
.case-grid img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 4px;
}

/* Single feature image */
.case-figure { margin: 0 0 var(--s-4); }
.case-figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 4px;
}

/* Captions for both grids and single figures */
.case-grid figcaption,
.case-figure figcaption {
  font-size: 13px;
  line-height: 1.5;
  color: var(--color-muted);
  margin-top: var(--s-2);
  font-family: var(--font-accent);
  letter-spacing: 0.04em;
}

/* CTA link inside case study (e.g. "View the live system →") */
.case-cta {
  display: inline-block;
  margin-top: var(--s-2);
  font-family: var(--font-accent);
  font-size: 12px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--color-link);
  text-decoration: none;
  border-bottom: 1px solid rgba(92, 255, 142, 0.4);
  padding-bottom: 3px;
  transition: border-color 200ms ease;
}
.case-cta:hover { border-color: var(--color-link); }
.case-cta::after { content: " →"; }

/* Credits grid — terminal-style key/value */
.case-credits {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 12px var(--s-4);
  margin: 0;
  padding: 0;
  max-width: 760px;
}
.case-credits dt {
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--color-muted);
  padding-top: 4px;
}
.case-credits dt::before { content: "> "; }
.case-credits dd {
  margin: 0;
  font-size: 15px;
  line-height: 1.65;
  color: var(--color-fg);
}

/* Case study button — primary action when an expanded tile has both
   a live link and a case study. Filled phosphor-green pill (dark
   text on light) at rest, with a gentle resting halo so it reads as
   the "already lit" option versus the outline-only secondary.
   Arrow is "//" instead of "↗" to signal "in-SPA panel".

   Hover language is inverse/armed — the lit pill DE-FILLS to an
   outline, the dark text becomes bright phosphor, and the border
   thickens slightly. Reads as the button "arming itself" rather
   than getting brighter. Mirror-inverse of the secondary's hover
   (which goes outline-rest → filled-hover), so the pair feels
   coherent: secondary lights up to engage, primary tenses up to
   engage. */
.tile-frame-link.tile-frame-link--case {
  background-color: var(--color-link);
  color: var(--color-bg);
  border-color: var(--color-link);
  border-width: 2px;
  box-shadow: 0 0 8px rgba(92, 255, 142, 0.25);
  /* Transition-based hover so hover-out smoothly reverses back to
     the lit rest state. Keyframes can't reverse cleanly, but a
     single rest↔hover transition pair does. */
  transition:
    background-color 280ms cubic-bezier(0.16, 1, 0.3, 1),
    color 280ms cubic-bezier(0.16, 1, 0.3, 1),
    border-color 280ms cubic-bezier(0.16, 1, 0.3, 1),
    border-width 280ms cubic-bezier(0.16, 1, 0.3, 1),
    text-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
.tile-frame-link.tile-frame-link--case::after {
  content: " //";
  margin-left: 6px;
}
.tile-frame-link.tile-frame-link--case:hover,
.tile-frame-link.tile-frame-link--case:focus-visible {
  /* Armed state: fill recedes, text comes forward as bright phosphor
     with a layered glow, border thickens by 1px. The 1px border
     bump steals 2px of inner width (box-sizing: border-box) so the
     text squeezes inward by a hair — feels like the pill is
     tightening, not just changing colour. */
  background-color: transparent;
  color: var(--color-link-hover);
  border-color: var(--color-link-hover);
  border-width: 3px;
  text-shadow: var(--phosphor-glow-hover);
  box-shadow:
    0 0 12px rgba(92, 255, 142, 0.4),
    0 0 28px rgba(92, 255, 142, 0.18);
}
/* Glitch escalation on the primary — the :hover transition already
   handles the rest↔armed state change, so these rules only need to
   layer the jitter / rgb / border-flash animations on top. The
   delay matches the 280ms hover transition so glitch kicks in
   AFTER the armed state has settled. */
.tile-frame-link.tile-frame-link--case.is-glitching-1 {
  animation: pill-jitter-soft 0.9s linear infinite 280ms;
}
.tile-frame-link.tile-frame-link--case.is-glitching-2 {
  animation:
    pill-jitter-soft 0.6s linear infinite 280ms,
    pill-rgb-pulse 1.6s ease-in-out infinite 800ms;
}
.tile-frame-link.tile-frame-link--case.is-glitching-3 {
  animation:
    pill-jitter-heavy 0.4s linear infinite 280ms,
    pill-rgb-pulse 0.9s ease-in-out infinite 800ms,
    pill-border-flash 1.6s ease-in-out infinite 1.2s;
}
/* Reduced-motion: jump to the armed state on hover, no transition. */
@media (prefers-reduced-motion: reduce) {
  .tile-frame-link.tile-frame-link--case {
    transition: none;
  }
  .tile-frame-link.tile-frame-link--case.is-glitching-1,
  .tile-frame-link.tile-frame-link--case.is-glitching-2,
  .tile-frame-link.tile-frame-link--case.is-glitching-3 {
    animation: none;
  }
}

/* Mobile collapse — single-column grids, single-column credits */
@media (max-width: 720px) {
  .case-grid--2,
  .case-grid--3 { grid-template-columns: 1fr; }
  .case-credits { grid-template-columns: 1fr; }
  .case-credits dt { padding-top: var(--s-2); }
}

/* ---------- Sidebar (homepage) — vertical rail ---------- */
:root { --sidebar-width: 60px; --sidebar-height-mobile: 56px; }

.sidebar {
  position: fixed;
  top: var(--site-frame-margin);
  left: var(--site-frame-margin);
  bottom: var(--site-frame-margin);
  width: var(--sidebar-width);
  background: var(--color-bg-2);
  border-radius: var(--site-frame-radius) 0 0 var(--site-frame-radius);
  z-index: 250;
  overflow: visible;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 14px;
  padding: 10px 0;
  box-shadow: 8px 0 32px -8px rgba(0, 0, 0, 0.5);
}
.sidebar-logo {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 36px;
  background: transparent;
  /* Dropped the box border + low opacity now that there's an actual
     logo mark inside. */
  flex-shrink: 0;
  color: var(--color-link);
  transition: color 220ms ease, filter 220ms ease;
}
.sidebar-logo:hover,
.sidebar-logo:focus-visible {
  filter: drop-shadow(0 0 8px rgba(92, 255, 142, 0.6));
}
.logo-bp {
  display: block;
  width: 100%;
  height: 100%;
}

.rail-nav {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  margin-top: 6px;
  flex: 1 0 auto;
}
.rail-nav__divider {
  width: 14px;
  height: 1px;
  background: var(--color-fg);
  opacity: 0.2;
  margin: 6px 0;
  flex-shrink: 0;
}
.rail-nav__item {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 76px;
  width: 36px;
  text-decoration: none;
  flex-shrink: 0;
  color: var(--color-link);
  transition: color 220ms ease;
}
.rail-nav__label {
  display: inline-block;
  transform: rotate(-90deg);
  transform-origin: center center;
  white-space: nowrap;
  font-family: var(--font-accent);
  font-size: 11px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--color-link);
  transition:
    color 220ms ease,
    text-shadow 220ms ease,
    transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
  padding: 0 8px;
}
.rail-nav__item:hover .rail-nav__label,
.rail-nav__item:focus-visible .rail-nav__label {
  transform: rotate(-90deg) translateX(3px);
  text-shadow: var(--phosphor-glow-hover);
}
.rail-nav__item.is-active .rail-nav__label {
  color: var(--color-watermelon);
  transform: rotate(-90deg) translateX(3px);
  text-shadow:
    0 0 8px rgba(253, 92, 99, 0.6),
    0 0 18px rgba(253, 92, 99, 0.3);
}
.rail-nav__item:focus-visible {
  outline: 2px solid var(--color-link);
  outline-offset: 2px;
  border-radius: 4px;
}

/* Icon-only variant — external links rendered as upright icons rather
   than rotated text labels. Sits flush in the rail with no rotation,
   slightly tighter height so a short stack of icons doesn't bloat the
   rail compared to the text labels above. */
.rail-nav__item--icon {
  height: 44px;
  width: 36px;
}
.rail-nav__icon {
  flex-shrink: 0;
  display: block;
  transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
              filter 220ms ease;
}
.rail-nav__item--icon:hover .rail-nav__icon,
.rail-nav__item--icon:focus-visible .rail-nav__icon {
  transform: translateX(3px);
  filter: drop-shadow(0 0 6px rgba(92, 255, 142, 0.6));
}

/* Active marker — small watermelon bar that pokes out from the right
   edge of the rail item whose panel is currently open. */
.rail-nav__item::after {
  content: "";
  position: absolute;
  top: 50%;
  right: -16px;
  width: 3px;
  height: 28px;
  background: var(--color-watermelon);
  transform: translateY(-50%) scaleY(0);
  transform-origin: center center;
  transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
  box-shadow: 0 0 8px rgba(253, 92, 99, 0.5);
  pointer-events: none;
}
.rail-nav__item.is-active::after {
  transform: translateY(-50%) scaleY(1);
  animation: rail-active-pulse 1.6s ease-in-out infinite;
}
@keyframes rail-active-pulse {
  0%, 100% { opacity: 1;    }
  50%      { opacity: 0.55; }
}
@media (prefers-reduced-motion: reduce) {
  .rail-nav__item.is-active::after { animation: none; }
}

.menu-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  pointer-events: none;
  /* z-index 140 sits BELOW the panel (z 150) so the panel renders
     on top, but ABOVE the work grid + tiles (which top out at z 300
     when hovered — see note below for that case). On modal/tablet
     widths the @media block further down promotes the backdrop to
     z 310 so it dims everything including the sidebar. */
  z-index: 140;
  transition: opacity 580ms cubic-bezier(0.83, 0, 0.17, 1);
}
/* Activate the backdrop whenever a panel is open, regardless of
   viewport. Dims the grid and blocks pointer events on it — clicking
   the backdrop closes the panel (handled in main.js). Hovered tiles
   use z-index 300 which would normally beat the backdrop, but
   tiles only get z 300 ON hover; with the backdrop intercepting
   pointer events, the hover state can't trigger. */
body.panel-open .menu-backdrop {
  opacity: 1;
  pointer-events: auto;
}

body.has-sidebar { padding-left: calc(var(--site-frame-margin) + var(--sidebar-width)); }

@media (max-width: 700px) {
  .sidebar {
    top: var(--site-frame-margin);
    left: var(--site-frame-margin);
    right: var(--site-frame-margin);
    bottom: auto;
    width: auto;
    height: var(--sidebar-height-mobile);
    flex-direction: row;
    justify-content: flex-start;
    align-items: center;
    gap: var(--s-2);
    padding: 0 var(--s-3);
    border-radius: var(--site-frame-radius) var(--site-frame-radius) 0 0;
  }
  .sidebar-logo,
  .sidebar-logo .logo-mark { width: 36px; height: 36px; }
  .rail-nav {
    flex-direction: row;
    gap: var(--s-3);
    margin-top: 0;
    margin-left: var(--s-3);
    align-items: center;
  }
  .rail-nav__item { height: auto; width: auto; padding: 6px 4px; }
  .rail-nav__item--icon { height: auto; width: auto; }
  .rail-nav__label { transform: none; padding: 0; }
  .rail-nav__item:hover .rail-nav__label,
  .rail-nav__item:focus-visible .rail-nav__label,
  .rail-nav__item.is-active .rail-nav__label { transform: translateY(-1px); }
  .rail-nav__divider { width: 1px; height: 14px; margin: 0 2px; }
  body.has-sidebar {
    padding-left: var(--site-frame-margin);
    padding-top: calc(var(--site-frame-margin) + var(--sidebar-height-mobile));
  }
}

@media (max-width: 1280px) {
  .work-grid { grid-template-columns: repeat(4, 1fr); grid-auto-rows: 280px; }
  .tile--w3 { grid-column: span 3; }
}
@media (max-width: 1120px) {
  h1 { font-size: 76px; line-height: 88px; }
  h2 { font-size: 58px; line-height: 70px; }
  h3 { font-size: 36px; line-height: 46px; }
  h4 { font-size: 24px; line-height: 32px; }
  .work-grid { grid-template-columns: repeat(3, 1fr); grid-auto-rows: 260px; }
  .tile--w3 { grid-column: 1 / -1; }
}
@media (max-width: 860px) {
  h1 { font-size: 56px; line-height: 64px; }
  h2 { font-size: 44px; line-height: 54px; }
  h3 { font-size: 28px; line-height: 36px; }
  main { padding: 0 var(--s-3) var(--s-6); }
  .work-grid { grid-template-columns: repeat(2, 1fr); grid-auto-rows: 240px; }
  .tile--w2, .tile--w3, .tile--w2.tile--h2 { grid-column: 1 / -1; }
  .about-photo { max-width: 280px; }
}
/* ----- Sidebar system-stats meters -----------------------------------
   Two tiny phosphor LED bars at the bottom of the sidebar, masquerading
   as a CRT system monitor. LOAD = rAF frame-time (page-wide responsive-
   ness). MEM = JS heap as % of limit (Chrome-only, "--" elsewhere).
   Segments light from left to right; the last few segments shift to
   warm/red so high load reads as "warm". Driven by main.js systemStats. */
.stat-rack {
  margin-top: auto;        /* pin to bottom of the flex sidebar column */
  padding: 14px 0 22px;
  display: flex;
  flex-direction: column;  /* meters stacked one above the other */
  align-items: center;
  gap: 14px;
  width: 100%;
}
.stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  font-family: var(--font-accent);
  color: var(--color-link);
  line-height: 1;
}
.stat__label {
  font-size: 9px;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  opacity: 0.7;
  /* Match the rail-nav labels — reads bottom-to-top. */
  writing-mode: vertical-rl;
  transform: rotate(180deg);
}
.stat__bar {
  /* Outer track. The .stat__fill child grows up from the bottom
     based on its height %, revealing only the bottom slice of the
     gradient — so low values are pure phosphor green, high values
     stretch up into yellow and red. */
  position: relative;
  width: 16px;
  height: 90px;
  background: rgba(92, 255, 142, 0.10);
  border-radius: 2px;
  overflow: hidden;
}
.stat {
  /* --pct is set per-frame by JS (0..100). Drives the fill colour:
     phosphor green at 0, sliding through yellow to watermelon red at
     100. Single hue calc keeps the whole fill a uniform colour
     (rather than a gradient slice), so a high value reads instantly
     as "the bar has gone red". */
  --pct: 0;
}
.stat__fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0%;
  /* Hue interpolates from 140 (phosphor green) → 0 (red) as --pct
     climbs from 0 → 100. Saturation/lightness stay constant so the
     transition feels like a temperature change, not a brightness one. */
  background-color: hsl(calc(140 - var(--pct) * 1.4), 100%, 62%);
  box-shadow: 0 0 8px hsla(calc(140 - var(--pct) * 1.4), 100%, 62%, 0.55);
  transition:
    height           280ms cubic-bezier(0.16, 1, 0.3, 1),
    background-color 280ms ease,
    box-shadow       280ms ease;
}
.stat__value {
  font-size: 11px;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  opacity: 0.95;
  text-shadow: 0 0 4px rgba(92, 255, 142, 0.4);
  margin-top: 2px;
}
/* On narrow viewports the sidebar becomes a horizontal strip at the
   top — the rack moves inline to its right rather than wasting a
   second row. */
@media (max-width: 700px) {
  .stat-rack {
    margin-top: 0;
    margin-left: auto;
    flex-direction: row;
    padding: 0;
    width: auto;
    gap: 12px;
  }
  .stat__bar { width: 30px; }
}
@media (prefers-reduced-motion: reduce) {
  .stat__seg { transition: none; }
}
/* Touchscreen devices: hide the load/mem rack entirely.
   The stats are diagnostic flavour that only really make sense
   when you have a hover-capable pointer (the rack is also
   Chrome-only for mem, so on iOS/Android it was half-empty
   anyway). hover:none catches phones, tablets, and touch
   laptops without keyboard/mouse attached. */
@media (hover: none) {
  .stat-rack { display: none !important; }
}

/* ----- 26 Agency brand case study — figure styling -------------
   Frames for the before/after wordmark + mark display, and the
   placeholder slot for Ben\'s upcoming construction-lines figure.
   Both SVG assets sit inside dark phosphor-tinted frames so they
   read consistently against the panel background. */
.case-figure-frame {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  aspect-ratio: 1 / 1;
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid rgba(92, 255, 142, 0.18);
  border-radius: 4px;
  padding: 8%;
  box-sizing: border-box;
  overflow: hidden;
}
.case-figure-frame img {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
  object-fit: contain;
}
/* Wordmark frame — the agency\'s magenta on near-black reads as it
   would have on their original site. No filter, no recoloring. */
.case-figure-frame--wordmark img {
  filter: drop-shadow(0 0 8px rgba(230, 10, 101, 0.25));
}
/* Mark frame — color the SVG using currentColor by setting `color`
   on the frame. Phosphor green matches the portfolio palette. */
.case-figure-frame--mark {
  color: var(--color-link);
}
.case-figure-frame--mark img {
  filter: drop-shadow(0 0 10px rgba(92, 255, 142, 0.35));
}
/* Placeholder slot for the construction-lines diagram, until Ben
   has time to make it. Dashed phosphor border with a small label
   so the gap reads as intentional ("coming") rather than broken. */
.case-figure-frame--placeholder {
  border-style: dashed;
  border-color: rgba(92, 255, 142, 0.3);
  background: rgba(0, 0, 0, 0.15);
  aspect-ratio: 16 / 9;
}
.case-figure-placeholder-label {
  font-family: var(--font-accent);
  font-size: 10px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: rgba(92, 255, 142, 0.5);
}
/* Hero specific — keep the centred-mark hero from looking lost in
   a big landscape image slot. */
.case-hero--brand {
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid rgba(92, 255, 142, 0.18);
  border-radius: 4px;
  padding: var(--s-5);
  color: var(--color-link);
}
.case-hero--brand img {
  max-width: 220px;
  width: 60%;
  height: auto;
  filter: drop-shadow(0 0 14px rgba(92, 255, 142, 0.4));
}

@media (max-width: 560px) {
  h1 { font-size: 44px; line-height: 50px; }
  h4 { font-size: 20px; line-height: 28px; }
}
