/* =========================================================================
   terminal.css — the single shared stylesheet.
   Design tokens first; nothing below may hardcode a colour.
   ========================================================================= */

:root {
  --bg:            #000000;
  --green:         #00ff41;   /* primary text, borders */
  --green-bright:  #66ff88;   /* highlights, active states */
  --green-dim:     #0a8f2f;   /* secondary text, inactive nav */
  --green-vivid:   #00f52a;   /* purer green than --green: less blue (42 vs 65),
                                 a hair less bright. For large solid areas like
                                 the name banner, where --green's blue channel
                                 plus its bloom read as pale mint. */
  --green-mid:     #00b833;   /* between dim and full — straplines, sub-labels.
                                 Already the middle of the globe's and the
                                 rain's shade ramps; named here so CSS does not
                                 have to hardcode it. */
  --green-glow:    0 0 6px rgba(0, 255, 65, 0.55);
  --border:        1px solid var(--green);
  --border-dim:    1px solid var(--green-dim);
  --font-mono:     "JetBrains Mono", "IBM Plex Mono", "Fira Code", ui-monospace, monospace;

  /* Layout */
  --nav-h:         64px;
  --gutter:        28px;
  --split:         50%;       /* left column width on two-column pages */
}

/* ---------- Fonts (self-hosted; no CDN) ---------------------------------- */

@font-face {
  font-family: "JetBrains Mono";
  src: url("../assets/fonts/JetBrainsMono-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "JetBrains Mono";
  src: url("../assets/fonts/JetBrainsMono-Medium.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

/* The real Matrix code glyphs — 33 mirrored katakana plus digits and symbols.
   Copied from matrix/assets/ (Rezmason, MIT) rather than referenced in place:
   matrix/ is a vendored drop-in that could be re-vendored or removed, and the
   contact page's globe should not break with it. Carrying its own glyphs is
   also what makes the globe safe on a machine with no CJK font installed —
   JetBrains Mono has no Japanese coverage, so katakana would otherwise fall
   back to whatever the OS provides, or to empty boxes. */
@font-face {
  font-family: "Matrix Code";
  src: url("../assets/fonts/Matrix-Code.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: block;   /* the glyph atlas is baked once; no FOUT to swap */
}

/* ---------- Reset ------------------------------------------------------- */

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

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--green);
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  /* Stop iOS inflating text on its own. Safari's text autosizing rescales font
     sizes it judges too small for a block's width, independently of any CSS
     here — and this site renders the ASCII portrait at a couple of PIXELS a
     character, which is precisely the input that triggers it. Everything that
     measures a glyph (js/about.js, and the cqw-sized banner) assumes the size
     it asked for is the size it gets. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  overflow-x: hidden;
}

a { color: inherit; }

/* ---------- Background raindrop (contact page) --------------------------- */

/* `html` and `body` both carry background: var(--bg) above. Since `html` has
   one, THAT is what paints the viewport canvas, and body's is then an ordinary
   in-flow block background — which paints ABOVE a z-index:-1 element and would
   hide the rain completely. Dropping body's is visually identical (the root
   still paints black everywhere) and is what makes "behind everything"
   possible at all.
   Scoped to the class js/rain.js adds when it mounts, so it applies only where
   the effect exists — not on the other pages, and not under reduced motion. */
body.has-bg-rain { background: transparent; }

.bg-rain {
  position: fixed;
  inset: 0;
  /* Negative, so it paints under the in-flow page content instead of over it.
     z-index:0 would put it in the positioned-descendant layer, i.e. on top. */
  z-index: -1;
  pointer-events: none;
}

/* The same engine mounted inside the About page's portrait box. No z-index and
   no transparent-background rule: this one is a LATER sibling of the three
   <pre> layers inside .portrait-stack and is positioned like them, so it
   already paints above them — which is the point, the art is dense enough that
   a drop behind it would be almost entirely occluded. */
.portrait-rain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* This mount renders at a coarser grid than the display (pixelSize in
     js/about.js), so the canvas is upscaled to its CSS box — nearest-neighbour,
     or the browser smooths the very pixel steps the low resolution exists to
     produce. The canvas-side half of this is imageSmoothingEnabled in rain.js;
     both are needed, and either one alone leaves the edges soft. NOT on
     .bg-rain, which renders at full device resolution. */
  image-rendering: pixelated;
}

/* ---------- CRT scanlines ----------------------------------------------- */

.scanlines {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 255, 65, 0.03) 0px,
    rgba(0, 255, 65, 0.03) 1px,
    transparent 1px,
    transparent 3px
  );
}

/* ---------- Blinking cursor --------------------------------------------- */

@keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }

.cursor {
  display: inline-block;
  width: 0.6em;
  height: 1em;
  vertical-align: text-bottom;
  background: var(--green);
  animation: blink 1s steps(1) infinite;
}

/* ---------- Logo (top-left of every page) -------------------------------- */

.site-logo {
  position: absolute;
  top: 18px;
  left: var(--gutter);
  z-index: 20;
  display: block;
  width: 26px;
  color: var(--green);
  opacity: 0.9;
  transition: opacity 0.15s ease, filter 0.15s ease;
}

.site-logo svg { display: block; width: 100%; height: auto; }

.site-logo:hover,
.site-logo:focus-visible {
  opacity: 1;
  filter: drop-shadow(0 0 5px rgba(0, 255, 65, 0.7));
}

/* ---------- Navigation --------------------------------------------------- */

.nav {
  position: relative;
  flex: 0 0 auto;
  min-height: var(--nav-h);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(18px, 4vw, 58px);
  padding: 12px calc(var(--gutter) + 40px);
  border-bottom: var(--border);
}

.nav a {
  text-decoration: none;
  color: var(--green-dim);
  font-size: 15px;
  padding: 6px 14px;
  border: 1px solid transparent;
  white-space: nowrap;
  transition: color 0.15s ease, text-shadow 0.15s ease;
}

.nav a:hover,
.nav a:focus-visible {
  color: var(--green);
  text-shadow: var(--green-glow);
}

.nav a[aria-current="page"] {
  color: var(--green);
  border-color: var(--green);
  text-shadow: var(--green-glow);
}

/* "follow the white rabbit" sits slightly outside the system. */
.nav a.rabbit {
  letter-spacing: 0.06em;
  color: var(--green-dim);
}

.nav a.rabbit:hover {
  color: var(--green-bright);
  animation: flicker 1.4s steps(1) infinite;
}

@keyframes flicker {
  0%, 100% { opacity: 1; }
  92% { opacity: 1; }
  93% { opacity: 0.45; }
  94% { opacity: 1; }
  97% { opacity: 0.7; }
  98% { opacity: 1; }
}

/* ---------- Page shell / two-column split -------------------------------- */

.page {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: stretch;
}

.col {
  min-width: 0;
  padding: 22px var(--gutter) 28px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.col--left  { flex: 0 0 var(--split); border-right: var(--border); }
.col--right { flex: 1 1 auto; }

/* ---------- Resume page: a fixed frame, not a scrolling document ---------- */

/* body is min-height:100vh, which is a floor and not a cap, so without this the
   columns grow to their content and the whole document scrolls — which dragged
   the deep dive off the top of the screen as soon as you scrolled to reach a
   lower resume section. Capped, the PDF scrolls inside its own green box and the
   nav stays put, which is what the two-panel design always assumed. */
.resume-page { height: 100dvh; overflow: hidden; }

/* The deep-dive block is positioned against this column. */
.resume-page .col--left { position: relative; }

/* A FIXED FRAME. The block used to float — js/resume.js centred it on the
   hovered section and clamped it against its own natural height, which meant the
   box, the command line and the trailing cursor all slid up and down the column
   as you moved down the resume. It also capped every dive at roughly one
   section's worth of copy.

   Pinned top and bottom instead, the box is always the full height of the column
   and never moves; the connector alone tracks the hovered section, sliding its
   left endpoint down this box's right edge (see connector.js). That is where the
   sense of "the line follows my cursor" now lives — and the dives get the whole
   viewport to fill rather than ~600px. */
.resume-page .deep-dive {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  top: 22px;
  bottom: 28px;
  display: flex;
  flex-direction: column;
}

.resume-page .deep-dive .panel {
  /* `1 1 auto`, not `0 1 auto`: the green border spans the whole frame rather
     than hugging its content, so it reads as one fixed screen whose contents
     change — which is the point of pinning it. Long dives scroll inside it. */
  flex: 1 1 auto;
  position: relative;
  /* Half a pixel down from the site's 14px. Buys roughly two more lines per
     dive, which is what lets the longer sections fit without scrolling. */
  font-size: 13.5px;
  /* Same glow as the hovered section and the trace — see trace-pulse. */
  box-shadow: 0 0 10px rgba(0, 255, 65, 0.30), inset 0 0 13px rgba(0, 255, 65, 0.07);
  animation: trace-pulse 2.4s ease-in-out infinite;
}

/* The RESTING panel only — the `cat resume.md` explainer, not a dive.
   js/resume.js toggles .is-resting in showDefaultPanel/showSection.

   13.5px exists to buy lines for the longest dives (Woven, D&B run ~730px at
   that size against an inner height of ~905px). The resting state carries three
   short sentences in that same full-height box and is mostly empty, so it pays
   the cost of that compromise for nothing. It is also the first thing anyone
   reads on this page, and it is the instructions for how the page works.

   Scoped rather than global precisely so the dive budget is untouched — raise
   the dives to 16px and the long ones start scrolling. Measured after this
   change: resting 16px, Woven and D&B still 13.5px at 727px and 723px, i.e.
   exactly what they were.

   Note this also outranks the stacked-layout override below (four classes to
   its three), so under 1024px the resting panel stays 16px while the dives go
   to 14px. That is the intended relationship — resting larger than a dive —
   and 16px is ordinary body size on a phone, so it is left to win. */
.resume-page .deep-dive .panel.is-resting { font-size: 16px; }

/* Its copy is one <p> per statement that wraps to the full width, so the gap
   between paragraphs is the gap between statements — 4px reads as a wrap
   rather than a break. This replaces the "" spacer entries the content used
   to carry; an empty <p> has no line box and never bought the blank line it
   looked like it was buying. */
.resume-page .deep-dive .panel.is-resting .blk-p { margin-bottom: 11px; }
.resume-page .deep-dive .panel.is-resting .blk-p:last-child { margin-bottom: 0; }

/* Tighter block rhythm than the About page's. A deep dive carries five or six
   headed blocks where About carries three or four, so the 20px gap alone costs
   most of a heading's worth of height. Spacing is the cheapest thing to spend
   here — the alternative is cutting copy the sections need. */
.resume-page .deep-dive .blk { margin-bottom: 15px; }
.resume-page .deep-dive .blk-rule { margin-bottom: 6px; }

/* The clipped layer. Separate from .panel because .panel carries the border and
   clip-path would cut it — see the comment in resume.html. min-height fills the
   frame so a short dive still wipes across the whole visible box. */
.panel-body { position: relative; min-height: 100%; }

/* The reveal: js/typer.js wipes the body in from the top with a clip-path and
   drives --wipe from 100% to 0. The band rides the leading edge so it reads as
   a display coming up to voltage rather than as a plain wipe. */
.panel-body.is-revealing::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--wipe, 100%);
  height: 2px;
  background: var(--green-bright);
  box-shadow: 0 0 8px rgba(102, 255, 136, 0.9);
  pointer-events: none;
}

/* Single-column pages (contact) */
.page--single { justify-content: flex-start; }
.page--single .col { flex: 1 1 auto; }

/* ---------- Terminal prompt lines ---------------------------------------- */

.prompt {
  /* Never shrink. A prompt is one line of fixed height, and in a
     height-capped column (the resume page) the flex layout will otherwise take
     its space out of here — 7px, enough that `overflow: hidden` crops the
     descenders off `akshajmody` and `pdf`. The panel or the viewer absorbs the
     shrink instead, which is what they are sized to do. */
  flex: 0 0 auto;
  color: var(--green);
  font-size: 14px;
  margin: 0 0 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.prompt .user { color: var(--green); }
.prompt .cmd  { color: var(--green-bright); }

.prompt--tail { margin: 14px 0 0; }

.breadcrumb {
  color: var(--green-dim);
  font-size: 13px;
  margin: -8px 0 14px;
  /* Reserve the line even when empty. defaultPanel carries no breadcrumb, so
     without this the box below would jump ~20px between the resting state and
     any hovered section — exactly the movement the fixed frame exists to end. */
  min-height: 20px;
}

/* ---------- Bordered content panel ---------------------------------------- */

.panel {
  border: var(--border);
  padding: 18px 20px;
  overflow-y: auto;
  overflow-x: hidden;
  flex: 1 1 auto;
  min-height: 0;
  scrollbar-width: thin;
  scrollbar-color: var(--green-dim) transparent;
}

.panel::-webkit-scrollbar { width: 8px; height: 8px; }
.panel::-webkit-scrollbar-track { background: transparent; }
.panel::-webkit-scrollbar-thumb { background: var(--green-dim); }

/* ---------- Content blocks (js/render.js) -------------------------------- */

.blk { margin: 0 0 20px; }
.blk:last-child { margin-bottom: 0; }

.blk-heading {
  margin: 0;
  color: var(--green-bright);
  font-weight: 500;
  letter-spacing: 0.04em;
}

.blk-rule {
  margin: 0 0 8px;
  color: var(--green-dim);
  user-select: none;
}

.blk-p { margin: 0 0 4px; }
.blk-p:last-child { margin-bottom: 0; }

.blk-list { margin: 0; padding: 0; list-style: none; }

.blk-list li {
  position: relative;
  padding-left: 1.35em;
  margin-bottom: 6px;
}

.blk-list li::before {
  content: "\2022";           /* bullet, matching the mockups */
  position: absolute;
  left: 0.25em;
  color: var(--green-dim);
}

.blk-rows {
  margin: 0;
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 1.2em;
  row-gap: 3px;
}

.blk-rows dt { color: var(--green-dim); white-space: nowrap; }
.blk-rows dd { margin: 0; }

.blk-stack { margin: 0; color: var(--green); }

.blk-draft {
  margin: 0 0 10px;
  color: var(--green-dim);
  font-size: 12px;
  letter-spacing: 0.1em;
}

/* ---------- About page ---------------------------------------------------- */

.banner {
  margin: 2px 0 12px;
  /* Sized against the column, which `container-type: inline-size` on .col--left
     makes possible. The banner's widest line is 65 characters (M and N are five
     columns wide — see tools/make-banner.py) and JetBrains Mono's advance is
     0.602em, so a fill fraction F needs F * 100 / (65 * 0.602) cqw — 2.0cqw is
     ~78% of the column. Recompute if the name or a glyph width changes.

     The outer min() is what stops the 8px FLOOR from overflowing a narrow
     column. Filling the column exactly is 100cqw / (65 * 0.602) = 2.554cqw, so
     2.5cqw is 98% of it — and below a ~400px column, where 2cqw drops under
     8px, that is the term that wins. Without it the floor held the banner at
     8px (313px of text) inside a 286px column and `overflow: hidden` below
     silently ate the last character: the name rendered as "ALEX AKSHAJ MOD".
     A slightly smaller name beats a truncated one. Inert at any width where
     2cqw >= 8px, i.e. everywhere the floor was not already active. */
  font-size: min(clamp(8px, 2cqw, 24px), 2.5cqw);
  line-height: 1.04;
  color: var(--green-vivid);
  text-shadow: var(--green-glow);
  white-space: pre;
  overflow: hidden;
}

.col--left { container-type: inline-size; }

.tagline {
  margin: 0 0 20px;
  color: var(--green-mid);
  font-size: 15px;
  letter-spacing: 0.04em;
}

/* Prompt + numbered keys on one line, above the art. The keys used to sit
   BELOW it, where every change of image moved them — .portrait-wrap's height is
   pinned to the art, and the art's row count varies from 196 to 262. Above it,
   nothing the gallery does can shift them.
   wrap: six keys beside a ~33-character prompt only just fit the half-width
   column, so at narrower widths the key row drops onto its own line rather than
   overflowing. Still above the art, so still fixed. */
.portrait-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 4px 16px;
}

.portrait-wrap {
  /* NO border, on the owner's call: the art reads better floating against the
     black than boxed in. A bordered pane aligned to the left panel was tried and
     reverted — do not "fix" the asymmetry back.
     `flex-basis: auto` with no explicit height means this box takes its height
     from .portrait-stack, which js/about.js sizes to the art. That is why fit()
     does not pin an inline height. There is deliberately NO stacked-layout
     height override either: one used to live at the foot of this file
     (`min(68vh, 88vw)`) and it cropped the art on a phone, because it is a pure
     viewport function that knows nothing about the row count. Content-driven at
     every width is the only way the aspect can be right. */
  flex: 0 0 auto;
  min-height: 0;
  display: flex;
  /* flex-start, NOT center: the left column's content makes this column
     taller than the viewport, so centring pushes the art below the fold even
     when it has been sized to fit. Top-aligned, it starts at the prompt and
     stays on screen. */
  align-items: flex-start;
  justify-content: center;
  overflow: hidden;
}

/* Base art and the glow copy occupy the same box, exactly aligned. */
.portrait-stack {
  position: relative;
  /* js/about.js sets width AND height here in px, and both must survive. Without
     this the box is `flex: 0 1 auto` in a flex .portrait-wrap, and because every
     child is position:absolute its automatic minimum size is 0 — so a stack wider
     than the column was compressed on the X axis alone while the inline height
     stood, genuinely destroying the aspect and dragging .portrait-rain (inset: 0)
     out of register with the art. fit() now fits the width exactly, so this only
     ever guards float rounding — but it is the difference between rounding and
     an unbounded squash. */
  flex-shrink: 0;
  /* The glow IS the pointer here — an OS arrow sitting on top of it works
     against the effect. Restored under reduced motion below, where the glow
     is disabled and hiding the cursor would leave no feedback at all. */
  cursor: none;
}

/* The three text layers, in their own box so the phone-width `transform:
   scale()` js/about.js puts here cannot reach the rain canvas — that is a
   SIBLING of this element, a direct child of .portrait-stack, and is sized from
   the stack's real on-screen box. Scaling it too would shrink the raindrops
   ~4.5x into invisible speckles.

   `transform-origin` must be top left: the default centre would scale the box
   about its middle and leave it straddling .portrait-wrap's edges, since a
   transform contributes nothing to layout and the wrap only knows the size
   fit() wrote onto the stack. */
.portrait-art {
  position: relative;
  transform-origin: top left;
}

.portrait-art .ascii-portrait { position: absolute; inset: 0; }

/* The glow layer is revealed only inside a small radial mask centred on the
   cursor, so a couple of characters light up rather than the whole portrait.
   Cheap: only --hx/--hy change on pointermove, never the DOM. */
.ascii-portrait--glow {
  color: var(--green-bright);
  opacity: 0;
  text-shadow:
    0 0 4px rgba(102, 255, 136, 0.95),
    0 0 12px rgba(0, 255, 65, 0.55);
  -webkit-mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    #000 0%, rgba(0, 0, 0, 0.55) 55%, transparent 100%);
  mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    #000 0%, rgba(0, 0, 0, 0.55) 55%, transparent 100%);
  transition: opacity 0.12s ease;
}

/* The photograph's own colours. js/about.js fills this with the characters
   inside the hover circle only, each span carrying its cell's colour from the
   source image.

   This layer REPLACES the green inside the circle, it does not sit on top of
   it. Overlaying was the first attempt and it did not work: glyphs here are
   2.8-3.7px, while the two green layers underneath carry 4px and 12px glow
   radii, so every pixel around a coloured character was saturated green from
   below and the whole effect read as green. The green is masked away instead —
   see .has-colors. No opacity transition, because the hole in the base cannot
   crossfade and a fade here would show a dark flash while it opened. */
.ascii-portrait--color { opacity: 0; }

/* Circle shape only. The glitch shape needs no mask on this layer — it only
   ever builds the cells it means to reveal, and its edge is meant to be hard. */
.shape-circle .ascii-portrait--color {
  -webkit-mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    #000 0%, #000 70%, transparent 100%);
  mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    #000 0%, #000 70%, transparent 100%);
}

/* currentColor resolves per span, so every character glows in its own colour. */
.ascii-portrait--color span { text-shadow: 0 0 4px currentColor; }

.portrait-stack.is-hovered .ascii-portrait--glow,
.portrait-stack.is-hovered .ascii-portrait--color { opacity: 1; }

/* --- with a colour grid loaded: the window shows the photo, not green ------ */

/* Punch the circle out of the green art. Both layers hold the same characters
   at the same cells, so the seam is invisible — it reads as one image whose
   colours change inside the window. Turning opaque from 62% to 88% while the
   colour layer fades out from 70% to 100% crossfades the rim. */
.portrait-stack.shape-circle.has-colors.is-hovered .ascii-portrait--base {
  -webkit-mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    transparent 0%, transparent 62%, #000 88%);
  mask-image: radial-gradient(
    circle var(--hr, 40px) at var(--hx, -999px) var(--hy, -999px),
    transparent 0%, transparent 62%, #000 88%);
}

/* The bright-green glow is what was doing most of the swamping, and it has
   nothing to add once the photo's colours are showing. It stays in the markup
   purely as the fallback for a portrait whose colour grid 404s or cannot be
   read, which is why this suppresses it rather than removing the layer. */
.portrait-stack.has-colors .ascii-portrait--glow {
  opacity: 0;
  /* Not faded out — switched off. There is no reason to animate a layer that
     should not be participating, and leaving a transition here makes the state
     depend on an animation clock rather than on the class. */
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .ascii-portrait--glow,
  .ascii-portrait--color { display: none; }
  .portrait-stack { cursor: default; }
}

.ascii-portrait {
  margin: 0;
  /* font-size is measured and set in px by js/about.js — it fits the art to
     BOTH the width and the height of this box. Sizing it in CSS is a trap:
     container-type:size would apply size containment and collapse the
     wrapper to zero height in the stacked layout, where the column is
     auto-height. The value below is only a pre-script fallback. */
  font-size: 10px;
  line-height: 1;
  letter-spacing: 0;
  white-space: pre;
  color: var(--green);
  opacity: 0.88;
  text-shadow: 0 0 4px rgba(0, 255, 65, 0.35);
}

/* Numbered keys, matching the nav's active-tab treatment. Only positioning
   lives here — the key styling below is deliberately unchanged. */
.portrait-switch {
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 10px;
  padding: 0 0 10px;
}

.portrait-key {
  min-width: 34px;
  padding: 4px 10px;
  background: transparent;
  border: 1px solid var(--green-dim);
  color: var(--green-dim);
  font: inherit;
  font-size: 13px;
  line-height: 1.4;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, text-shadow 0.15s ease;
}

.portrait-key:hover,
.portrait-key:focus-visible {
  color: var(--green);
  border-color: var(--green);
  text-shadow: var(--green-glow);
}

.portrait-key.is-active {
  color: var(--green-bright);
  border-color: var(--green-bright);
  text-shadow: var(--green-glow);
}

/* ---------- Contact page -------------------------------------------------- */

/* Links left at their natural width, globe filling the space to the right that
   was otherwise empty. */
.panel--contact {
  display: flex;
  align-items: center;
  gap: 32px;
  /* Query container for .contact-value's font size. It goes HERE and not on
     .contact-list because a container must have a definite inline size, and
     .contact-list is `flex: 0 0 auto` — its width comes from its content, so
     sizing that content from the container would be circular and the browser
     would drop the containment. .panel--contact fills the column at every
     width, which is exactly the box the values have to fit inside. */
  container-type: inline-size;
}

.contact-list {
  flex: 0 0 auto;
  padding-left: 46px;   /* indented in from the panel's edge */
  /* Sits a little above the panel's vertical centre. Offset rather than a
     margin because the item is centred by the flex parent — a margin would
     move it half as far and read as a spacing value rather than a nudge, and
     `top` leaves the reserved space alone so the globe does not shift. */
  position: relative;
  top: -26px;
}

/* The globe and its readout. This carries the flex role and the positioning
   context that .globe-mount used to: at desktop width it is the SAME rectangle
   the mount was, so .locator's `top: 6px; right: 18px` lands in the identical
   place and nothing on desktop moves. What it buys is a box the readout can
   share with the globe without being inside it — see the comment in
   contact.html for why being inside the mount breaks the canvas sizing. */
.globe-block {
  flex: 1 1 auto;
  min-width: 0;
  align-self: stretch;
  display: flex;
  position: relative;
}

.globe-mount {
  flex: 1 1 auto;
  min-width: 0;
  /* Square-ish and generous, but never taller than the panel can afford. The
     canvas is sized from this box by js/globe.js, so the aspect is set here
     and nowhere else — which is why nothing else may live inside it. */
  min-height: 320px;
  position: relative;
}

/* Crosshair, and NOT `cursor: none`. Hiding it was tried on the grounds that
   the fizzing patch already tracks the pointer — but unlike .portrait-stack,
   where the glow is the whole content of a small box, the globe fills a large
   panel you also cross on the way to nothing in particular, and losing the
   arrow there reads as the page breaking rather than as an effect. */
.globe-canvas { display: block; cursor: crosshair; }

/* The GPS-style readout js/locator.js types into the top right of the globe.
   FIXED WIDTH IN ch, not shrink-to-fit: anchored to the right edge, a box that
   sizes to its content would slide left with every character typed. Monospace
   makes ch exact, so this is just "the longest line, plus a little". */
.locator {
  position: absolute;
  top: 6px;
  right: 18px;
  z-index: 1;
  /* Sized to the LONGEST line plus the trailing cursor, with headroom. `ch` is
     the advance of "0" WITHOUT letter-spacing and this block adds 0.08em a
     character, so a line costs more than its length in ch: at 16px the
     22-character searching line runs ~239px, plus ~8.8px of cursor, against
     1ch = 9.6px — 26ch minimum, so 28ch.

     Nothing wraps here (white-space: pre), so undersizing overflows the panel
     edge rather than failing visibly. Re-measure after changing the copy or the
     size, and measure the TEXT SPAN, not the line's scrollWidth: scrollWidth
     clamps to the container, so an overflowing line reads as an exact fit. */
  width: 28ch;
  font-size: 16px;
  line-height: 1.7;
  letter-spacing: 0.08em;
  color: var(--green-dim);
  pointer-events: none;
  white-space: pre;
}

/* The place line is the answer, so it gets the brighter green. */
.locator .locator-line:last-child { color: var(--green); }
.locator .cursor { width: 0.55em; margin-left: 1px; }

/* The barcode beat between the two phases: a scanner line sweeps left to right
   revealing the bars, the strip glows, then it fades out.

   The DURATIONS come from js/locator.js as --sweep / --glow / --fade, so the
   schedule lives in one place instead of being duplicated here and drifting
   out of step with the JS that waits on it. */
.locator-barcode {
  /* Wider than the text column, extending LEFT so the right edge stays aligned
     with the readout — that keeps it in the empty corner rather than over the
     globe, which is a circle inscribed in this box. */
  width: 32ch;
  margin: 6px 0 0 -4ch;
  height: 30px;
  position: relative;
  /* TWO pulses, each half the glow budget — so it beats twice at double speed
     while the overall schedule js/locator.js waits on is unchanged. */
  animation:
    barcode-glow calc(var(--glow) / 2) ease-in-out var(--sweep) 2 both,
    barcode-fade var(--fade) ease-out calc(var(--sweep) + var(--glow)) forwards;
}

.locator-bars {
  position: absolute;
  inset: 0;
  /* Bars are a generated linear-gradient of hard stops — see js/locator.js.
     No canvas, no image, and a different pattern every visit. */
  animation: barcode-scan var(--sweep) linear both;
}

/* The scanner line is a SIBLING of the clipped bars, never a child: the clip is
   what reveals them, so a line inside it would be cut off exactly where it is
   meant to be brightest. */
.locator-scan {
  position: absolute;
  top: -2px;
  bottom: -2px;
  width: 2px;
  background: var(--green-bright);
  box-shadow: 0 0 8px 2px rgba(102, 255, 136, 0.85);
  animation: barcode-sweep var(--sweep) linear both;
}

@keyframes barcode-scan {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

@keyframes barcode-sweep {
  from { left: 0; opacity: 1; }
  85%  { opacity: 1; }
  to   { left: 100%; opacity: 0; }
}

@keyframes barcode-glow {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(102, 255, 136, 0.45)); }
  50%      { filter: drop-shadow(0 0 9px rgba(102, 255, 136, 0.95)); }
}

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

.contact-entry { margin: 0 0 40px; }
.contact-entry:last-child { margin-bottom: 0; }

.contact-label {
  margin: 0;
  color: var(--green-dim);
  font-size: 17px;
  letter-spacing: 0.14em;
}

/* Sized from .panel--contact, not fixed, because 30px does not fit a phone.
   The longest value is "linkedin.com/in/akshajmody" — 26 chars, and JetBrains
   Mono's advance is 0.602em, so the line costs 15.65em and filling the
   container needs 100cqw / 15.65 = 6.39cqw. Backed off to 5.9 to leave the
   list's left indent room. min()-capped at 30px because on desktop the
   container is the whole panel (globe included) and cqw would wildly
   overshoot — so the cap is what holds the desktop design exactly. */
.contact-value { margin: 7px 0 0; font-size: min(30px, 5.9cqw); }

.contact-value a {
  color: var(--green);
  text-decoration: none;
  /* Last-resort guard. An email address has no line-break opportunity under
     UAX-14 — no spaces, and `@` is not a break point — so at any size that
     does not fit it would silently run out through the panel border rather
     than wrapping. The sizing above is what should keep it in; this is what
     stops a future copy change from escaping the box unnoticed. */
  overflow-wrap: anywhere;
  border-bottom: 1px solid var(--green-dim);
  transition: color 0.15s ease, text-shadow 0.15s ease, border-color 0.15s ease;
}

.contact-value a:hover,
.contact-value a:focus-visible {
  color: var(--green-bright);
  border-bottom-color: var(--green-bright);
  text-shadow: var(--green-glow);
}

/* ---------- PDF viewer ---------------------------------------------------- */

.viewer {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  border: var(--border);
}

.viewer-bar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 7px 12px;
  border-bottom: var(--border);
  font-size: 12px;
  color: var(--green-dim);
  white-space: nowrap;
}

.viewer-name { color: var(--green); }
.viewer-pages { margin-left: auto; font-variant-numeric: tabular-nums; }

/* Phone-only tap hint — see the element in resume.html. Declared HERE, before
   the phone block, because the override there has the same specificity and
   source order decides; after it, this would silently win and the hint would
   never appear. */
.viewer-hint { display: none; }

/* Prompt on the left, the download control on the right of the same line.
   Wraps rather than colliding when the column gets narrow. */
.viewer-head {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 4px 16px;
}

.viewer-dl {
  flex: 0 0 auto;
  margin-bottom: 14px;          /* matches .prompt, so the row sits level */
  padding: 7px 14px;
  border: 1px solid var(--green);
  color: var(--green-bright);
  font-size: 13px;
  letter-spacing: 0.04em;
  text-decoration: none;
  white-space: nowrap;
  /* Static glow first, then the shared cycle — the reduced-motion block at the
     foot of this file zeroes animations, and without a base the button would
     lose its glow rather than simply hold still. */
  box-shadow: 0 0 8px rgba(0, 255, 65, 0.35), inset 0 0 10px rgba(0, 255, 65, 0.08);
  animation: trace-pulse 2.4s ease-in-out infinite;
  transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}

.viewer-dl:hover,
.viewer-dl:focus-visible {
  border-color: var(--green-bright);
  background: rgba(0, 255, 65, 0.11);
  text-shadow: var(--green-glow);
}

.viewer-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 12px;
  scrollbar-width: thin;
  scrollbar-color: var(--green-dim) transparent;
}

.viewer-scroll::-webkit-scrollbar { width: 8px; }
.viewer-scroll::-webkit-scrollbar-track { background: transparent; }
.viewer-scroll::-webkit-scrollbar-thumb { background: var(--green-dim); }

.viewer-status { color: var(--green-dim); font-size: 13px; margin: 0; }

/* Diagnostics rail — see the comment on the element in resume.html. Hidden in
   the normal case; carries ?trace=1 progress and, ALWAYS, a real message if
   rendering fails. Styled as a status line rather than an alert box: this is a
   terminal, and a red-box error treatment would be the only non-green chrome
   on the site. */
.viewer-log {
  flex: 0 0 auto;
  padding: 8px 12px;
  border-bottom: var(--border-dim);
  font-size: 12px;
  line-height: 1.55;
  color: var(--green-dim);
  /* Error text is a URL or an exception message — neither of which wraps on
     its own, and both of which are wider than a phone. */
  overflow-wrap: anywhere;
}

/* Fallback flat render, shown only when PDF.js cannot run (see fail() in
   js/resume.js). Sized like the canvas it stands in for: full width of the
   scroller, aspect preserved, and the same thin frame the real page gets. */
.pdf-fallback {
  display: block;
  width: 100%;
  height: auto;
  margin: 0 auto;
}

.viewer-log p { margin: 0; }
.viewer-log .log-err { color: var(--green-bright); text-shadow: var(--green-glow); }
.viewer-log a { color: var(--green-bright); }

/* One wrapper per PDF page: canvas + (from M5) an absolutely-placed
   hotspot layer sized exactly to the canvas. */
.pdf-page {
  position: relative;
  margin: 0 auto 12px;
  line-height: 0;
  width: fit-content;
}

.pdf-page:last-child { margin-bottom: 0; }
.pdf-page canvas { display: block; }

.hotspot-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;   /* only the hotspots themselves are interactive */
}

.hotspot {
  position: absolute;
  pointer-events: auto;
  cursor: pointer;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 0;
  padding: 0;
  transition: border-color 0.12s ease, background-color 0.12s ease,
              box-shadow 0.12s ease;
}

/* Instant, pure-CSS feedback — deliberately never debounced. */
.hotspot:hover,
.hotspot:focus-visible,
.hotspot.is-active {
  outline: none;
  border-color: var(--green-bright);
  background: rgba(0, 255, 65, 0.19);
  /* Static value first: the reduced-motion block at the foot of this file zeroes
     every animation, and without a base here the glow would vanish entirely for
     those readers instead of simply holding still. Same reasoning wherever
     trace-pulse is used. */
  box-shadow: 0 0 0 1px rgba(0, 255, 65, 0.6),
              0 0 15px rgba(0, 255, 65, 0.6),
              inset 0 0 18px rgba(0, 255, 65, 0.24);
  animation: trace-pulse-hot 2.4s ease-in-out infinite;
}

/* One glow for the whole interaction — the hovered section, the deep-dive box
   and the trace between them all breathe on this, so they read as one object
   rather than three highlights that happen to be green. Only the glow moves;
   borders hold still, or the bounds of things would look unstable. */
@keyframes trace-pulse {
  0%, 100% {
    box-shadow: 0 0 7px rgba(0, 255, 65, 0.32), inset 0 0 10px rgba(0, 255, 65, 0.10);
  }
  50% {
    box-shadow: 0 0 14px rgba(0, 255, 65, 0.55), inset 0 0 16px rgba(0, 255, 65, 0.18);
  }
}

/* Same 2.4s cadence, bigger amplitude — the hotspot is the one member of that
   trio sitting on a WHITE page, and the deep-dive box and the trace sit on black.
   Identical alphas do not read as identical strength across those two grounds:
   what carries on black washes out on white. So the cadence is shared (they still
   breathe together) and only the amplitude differs.
   The first, zero-blur shadow is a hairline ring outside the 1px border — it
   sharpens the section's edge against white without thickening the border, which
   would make the bounds look heavier than the rest of the terminal's chrome. */
@keyframes trace-pulse-hot {
  0%, 100% {
    box-shadow: 0 0 0 1px rgba(0, 255, 65, 0.45),
                0 0 11px rgba(0, 255, 65, 0.45),
                inset 0 0 14px rgba(0, 255, 65, 0.18);
  }
  50% {
    box-shadow: 0 0 0 1px rgba(0, 255, 65, 0.75),
                0 0 20px rgba(0, 255, 65, 0.75),
                inset 0 0 22px rgba(0, 255, 65, 0.30);
  }
}

/* A stroke cannot take a box-shadow, so the trace glows with the filter
   equivalent on the same cycle. */
@keyframes trace-pulse-stroke {
  0%, 100% { filter: drop-shadow(0 0 2px rgba(0, 255, 65, 0.55)); }
  50%      { filter: drop-shadow(0 0 5px rgba(0, 255, 65, 0.85)); }
}

/* ?dev=1 — make every hotspot permanently visible while measuring.
   Needs strong contrast: these sit on top of a white PDF page. */
.dev-hotspots .hotspot {
  border: 1px dashed #ff00d4;
  background: rgba(255, 0, 212, 0.10);
}

.dev-hotspots .hotspot:hover,
.dev-hotspots .hotspot.is-active {
  border-style: solid;
  border-color: var(--green-bright);
  background: rgba(0, 255, 65, 0.18);
}

.dev-label {
  position: absolute;
  top: 0;
  left: 0;
  font-size: 10px;
  color: #fff;
  background: #ff00d4;
  padding: 0 4px;
  pointer-events: none;
  white-space: nowrap;
}

/* Drag rectangle drawn by the measuring tool. */
.dev-marquee {
  position: absolute;
  border: 1px dashed var(--green-bright);
  background: rgba(0, 255, 65, 0.12);
  pointer-events: none;
}

.dev-readout {
  position: fixed;
  left: 12px;
  bottom: 12px;
  z-index: 80;
  max-width: 46vw;
  padding: 8px 10px;
  border: var(--border);
  background: rgba(0, 0, 0, 0.92);
  color: var(--green);
  font-size: 11px;
  line-height: 1.5;
  white-space: pre;
  overflow-x: auto;
}

/* ---------- Site intro (matrix rain) -------------------------------------- */

#intro { display: none; }

/* js/intro-boot.js adds .intro-pending in <head>, before first paint, so the
   site never flashes into view and is then covered. */
.intro-pending #intro { display: block; }

/* The page keeps loading underneath (PDF.js warms up for free), but a scroll
   flick mid-intro must not leave the reveal mid-document. */
.intro-pending { overflow: hidden; }

#intro {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--bg);
  opacity: 1;
  will-change: opacity;
}

/* ---------- Boot: the page arrives top to bottom -------------------------- */

/* The rain genuinely empties itself before any of this runs — the drain gate in
   matrix/ (see matrix/LOCAL-MODIFICATIONS.md) stops new drops and lets the
   in-flight ones fall out — so by now the overlay is nothing but black. */

/* The overlay is already a full-screen black layer over the page, so rather
   than dissolving it we slide it off downward — the site is uncovered from the
   top, the same gesture the deep-dive panel uses when it wipes in.

   Transforming a fixed overlay and nothing else is deliberate: clip-path on
   <body> would have worked too, but it makes body a containing block for every
   position:fixed descendant, which would dislodge the scanlines and the
   connector mid-animation.

   The travel OVERSHOOTS by --wipe-clear rather than stopping at 100%. At
   exactly 100% the overlay's top edge rests on the bottom of the viewport, and
   the leading line lives on that edge — so the line parked at the bottom of the
   screen instead of leaving with the wipe. The overshoot carries the line and
   its glow clear of the fold.

   Durations must match WIPE_OUT_MS / SCAN_MS in js/intro.js. */
#intro {
  --wipe-clear: 34px;   /* the 2px line plus its ~26px bloom, with margin */
}

#intro.is-wiping {
  transition: transform 450ms cubic-bezier(0.4, 0, 0.2, 1);
  transform: translateY(calc(100% + var(--wipe-clear)));
  pointer-events: none;
}

/* ---- The same reveal, on internal navigation ---------------------------- */

/* Clicking between pages of the site gets the wipe without the rain.
   js/intro-boot.js sets .wipe-pending in <head>, before the first paint, so the
   page is covered rather than flashing and then being covered.

   Deliberately CSS-ONLY. A deferred module cannot run before first paint, so a
   JS-driven version would need the overlay to sit there until the module
   arrived — and if that module ever failed, the site would stay black. An
   animation with `forwards` cannot strand it. `pointer-events: none` for the
   same reason: even interrupted, this can never eat a click.
   Duration matches WIPE_OUT_MS in js/intro.js closely enough to read as the
   same gesture, shortened a little because it happens on every click. */
.wipe-pending #intro {
  display: block;
  pointer-events: none;
  animation: page-wipe 380ms cubic-bezier(0.4, 0, 0.2, 1) 1 forwards;
}

/* Overshoots for the same reason as .is-wiping above: stopping at 100% leaves
   the leading line sitting on the bottom edge of the screen. It matters more
   here — nothing removes this overlay, so `forwards` holds the end state. */
@keyframes page-wipe {
  from { transform: translateY(0); }
  to   { transform: translateY(calc(100% + var(--wipe-clear))); }
}

/* The leading edge of that slide, so the reveal has a scan line rather than
   being a plain shutter. TOP, not bottom: the overlay slides downward, so the
   boundary between the revealed page and the remaining black is its top edge. */
.wipe-pending #intro::after,
#intro.is-wiping::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -2px;
  height: 2px;
  background: var(--green-bright);
  box-shadow: 0 0 10px rgba(102, 255, 136, 0.95), 0 0 26px rgba(0, 255, 65, 0.6);
}

/* Then one more pass on its own: a glowing line down the revealed page. */
#boot-scan {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  height: 2px;
  z-index: 9998;
  pointer-events: none;
  background: var(--green-bright);
  box-shadow: 0 0 10px rgba(102, 255, 136, 0.9), 0 0 30px rgba(0, 255, 65, 0.5);
  animation: boot-scan 320ms linear 1 forwards;
}

@keyframes boot-scan {
  0%   { transform: translateY(0); opacity: 0; }
  12%  { opacity: 1; }
  100% { transform: translateY(100vh); opacity: 0; }
}

/* ---- CRT power-on ------------------------------------------------------- */

/* The picture arrives and jolts a few times while the brightness pops and
   settles, like a tube finding its sync. Fresh arrivals only — added by
   js/intro.js after the overlay clears. Internal navigation gets the wipe
   alone, so this stays an event rather than a tax on every click.

   Targets the CONTENT, never <html> or <body>: a transform or filter on the
   root makes it a containing block for every position:fixed descendant, which
   would drag .scanlines and the resume page's #connector along with the jolt.
   None of these three is fixed.

   Duration must match CRT_MS in js/intro.js. */
.crt-boot .nav,
.crt-boot .page,
.crt-boot .site-logo {
  animation: crt-boot 380ms ease-out 1;
}

/* The jolts snap (steps) and only the last beat eases out. Interpolating the
   whole thing would read as a wobble; a tube losing and regaining sync jumps. */
@keyframes crt-boot {
  0%   { transform: translateY(0)    scaleY(0.985); filter: brightness(1.9);  animation-timing-function: steps(1, end); }
  14%  { transform: translateY(-4px) scaleY(1);     filter: brightness(0.75); animation-timing-function: steps(1, end); }
  30%  { transform: translateY(3px);                filter: brightness(1.6);  animation-timing-function: steps(1, end); }
  48%  { transform: translateY(-2px);               filter: brightness(0.9);  animation-timing-function: steps(1, end); }
  70%  { transform: translateY(1px);                filter: brightness(1.15); }
  100% { transform: translateY(0);                  filter: brightness(1); }
}

.intro-rain {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
  /* The overlay above captures input, so matrix/'s ondblclick ->
     requestFullscreen handler can never fire. */
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  #intro { display: none !important; }
  .intro-pending { overflow: auto; }
}

/* ---------- Connector line ------------------------------------------------ */

#connector {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  z-index: 40;
  pointer-events: none;
  color: var(--green-bright);
  opacity: 0;
  transition: opacity 0.12s ease;
  /* Static base, then the same 2.4s cycle the hotspot and the deep-dive box
     are on, so the whole trace breathes as one thing. */
  filter: drop-shadow(0 0 3px rgba(0, 255, 65, 0.6));
  animation: trace-pulse-stroke 2.4s ease-in-out infinite;
}

#connector.is-visible { opacity: 1; }

@media (max-width: 1023px) {
  #connector { display: none; }
}

/* ---------- Rabbit page --------------------------------------------------- */

.rabbit-page .rain {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  z-index: 0;
  opacity: 0.5;           /* atmosphere, not competition — the text has to win */
  pointer-events: none;   /* never let the iframe take clicks (it binds dblclick->fullscreen) */
}

/* Chrome floats above the rain.
   NOTE: .site-logo is deliberately absent here. It is position:absolute in the
   base rules; forcing it to relative puts it back in normal flow, where it
   occupies vertical space and pushes the whole nav down. It only needs a
   z-index to clear the rain. */
.rabbit-page .nav,
.rabbit-page .rabbit-stage { position: relative; z-index: 10; }

.rabbit-page .site-logo {
  z-index: 20;   /* stays absolutely positioned, per the base rule */
  filter: drop-shadow(0 0 6px #000) drop-shadow(0 0 3px #000);
}

.rabbit-page .nav {
  border-bottom-color: var(--green-dim);
  background: rgba(0, 0, 0, 0.72);
  backdrop-filter: blur(2px);
}

.rabbit-stage {
  align-items: center;
  justify-content: center;
  text-align: center;
  position: relative;
}

/* Radial scrim so the centred line reads cleanly over dense glyphs. */
.rabbit-stage::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(760px, 92vw);
  height: 300px;
  transform: translate(-50%, -50%);
  background: radial-gradient(ellipse at center, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.75) 45%, rgba(0,0,0,0) 72%);
  pointer-events: none;
}

.rabbit-soon {
  position: relative;   /* must sit above .rabbit-stage::before, which is positioned */
  z-index: 1;
  margin: 0;
  font-size: clamp(20px, 3.4vw, 34px);
  letter-spacing: 0.14em;
  color: var(--green-bright);
  text-shadow: 0 0 10px rgba(0, 255, 65, 0.75), 0 0 26px rgba(0, 255, 65, 0.35);
}

.rabbit-soon .cursor { margin-left: 0.35em; background: var(--green-bright); }

/* ---------- Responsive ---------------------------------------------------- */

@media (max-width: 1023px) {
  html, body { overflow-x: hidden; }
  body { min-height: 100dvh; }

  /* Stacked, the page scrolls normally again and the deep dive goes back into
     the flow — there is no second column for it to float alongside, and the
     connector is hidden at this width anyway. */
  .resume-page { height: auto; overflow: visible; }
  .resume-page .col--left { position: static; }
  .resume-page .deep-dive {
    position: static;
    top: auto;
    bottom: auto;
    max-height: none;
    transition: none;
  }

  /* Back in the flow there is no frame to fill, so the box hugs its copy again
     and the page does the scrolling. */
  .resume-page .deep-dive .panel { flex: 0 1 auto; font-size: 14px; }

  .page { flex-direction: column; }

  .col {
    flex: 0 0 auto;
    overflow: visible;
    padding: 18px var(--gutter) 22px;
  }

  .col--left { border-right: none; }

  /* Spec: stacked order is nav, PDF, then the deep dive. The resume page's
     DOM order is panel-then-PDF for desktop, so flip it here. */
  .page .col--right { order: 1; }
  .page .col--left { order: 2; border-top: var(--border); }

  /* About page: the PORTRAIT leads on a phone, on the owner's call. The art is
     what is worth seeing first at this size, and the name banner plus the
     tagline still clear the fold underneath it — the height envelope js/about.js
     gives the art when stacked is what buys that room, so the two are tied: if
     the envelope grows, the banner stops being visible on arrival.
     (This was the other way round, name-first, until the portrait moved up.) */
  .page--about .col--right { order: 1; border-top: none; }
  .page--about .col--left { order: 2; border-top: var(--border); }

  /* Panels scroll with the page rather than inside themselves. */
  .panel { overflow: visible; max-height: none; }

  /* Stacked: globe below the links, and smaller — it is decoration, and the
     contact details are the reason anyone is on this page. */
  .panel--contact { flex-direction: column; align-items: stretch; gap: 24px; }
  /* Nothing is vertically centred once stacked, so the nudge that offsets the
     list from the panel's middle would just pull it up into the padding. */
  .contact-list { top: 0; }
  .globe-mount { min-height: min(62vw, 300px); }

  /* The readout drops OUT of the globe's top-right corner and into normal flow
     above it. That corner only exists while the block is much wider than the
     globe inscribed in it; stacked, the globe fills the block and the readout
     (28ch = ~269px) lands squarely on it. The barcode was worse still — 32ch
     with a -4ch margin, so it started at a negative x and bled out through the
     panel's left border.

     This is at 1023px and not in the phone block on purpose: at ~800px the
     readout already overlaps the globe's right limb, so the corner is gone well
     before phone width. */
  .globe-block { flex-direction: column; }

  .locator {
    position: static;
    width: auto;
    font-size: 13px;
    margin-bottom: 8px;
    /* RESERVE THE TALLEST STATE UP FRONT. In flow above the globe this box
       changes height six times as js/locator.js plays out — empty (0), the
       searching line (1), empty, the barcode (36px), the heading (1 line), then
       both lines — and every change slid the globe up or down. Two lines is the
       final resting state AND the tallest, comfortably over the barcode's 36px,
       so reserving it holds every phase still.

       In `em` so it tracks the font-size above rather than needing a second
       edit; line-height is 1.7, hence 2 x 1.7. Nothing in js/locator.js or
       js/globe.js has to know — the globe never re-fits, it was only ever being
       translated by layout. */
    min-height: calc(2 * 1.7em);
  }

  /* The negative margin exists only to hang the strip left of an absolutely
     positioned box so their RIGHT edges line up. In flow it is left-aligned
     like the lines above it, so the offset is just overflow. */
  .locator-barcode {
    width: 100%;
    max-width: 28ch;
    margin-left: 0;
  }

  /* The viewer frame is sized by the canvas inside it, NOT by the viewport.
     `min-height: 70vh` lived here to stop the box collapsing before the PDF
     arrived — but it is also what turned a failed render into a screen and a
     half of empty green box, which is exactly what the mobile PDF bug looked
     like from the outside. js/resume.js gives every canvas an explicit CSS
     height, so the frame hugs it and an empty frame is visibly empty. */
  .viewer { flex: 0 0 auto; min-height: 0; }

  /* Release the nested scroller. A scroll region inside the page's own scroll
     region traps flicks on iOS: a swipe started over the resume scrolls the
     inner box to its (nonexistent) end and the page never moves. The resume is
     one page — there has never been anything in here to scroll. Safe against
     horizontal overflow: the canvas CSS width is floor(base.width * cssScale)
     where cssScale = avail / base.width, so it is always <= avail. */
  .viewer-scroll { overflow: visible; flex: 0 0 auto; }

  /* NOTE: .portrait-wrap deliberately has NO height here. It used to be pinned
     to `min(68vh, 88vw)`, a pure viewport function with no knowledge of the
     art's row count — at 390x844 that is 343px, against art that wants 675px,
     and .portrait-wrap is overflow:hidden + align-items:flex-start, so half the
     portrait was simply cropped away. It is content-driven now and js/about.js
     fits the art to the column width, so the aspect is right by construction. */

  .nav {
    gap: 10px 14px;
    flex-wrap: wrap;
    padding: 10px var(--gutter);
    min-height: 0;
  }
  .nav a { font-size: 13px; padding: 5px 9px; }

  .site-logo {
    position: static;
    width: 22px;
    margin: 12px 0 -4px var(--gutter);
  }

  .rabbit-page .site-logo { margin-bottom: 4px; }
}

/* The dismiss control exists only for the phone-width sheet. Declared HERE,
   before the phone block, because that override has the same specificity and
   source order is what decides — declared after it, this would silently win and
   the sheet would have no way to close. */
.deep-close { display: none; }

/* ---------- Phone --------------------------------------------------------- */

/* A second breakpoint, because 1023px was doing two jobs. That block is "the
   two columns stopped fitting" and its values were tuned at the tablet end of
   its range; this one is "the page is a hand's width", where the constraint
   stops being layout and becomes width itself — every pixel of chrome is a
   pixel the PDF does not get to render into.

   AFTER the 1023px block on purpose: same specificity, so source order is what
   makes these win. */
@media (max-width: 640px) {
  /* 28px a side is a seventh of a 390px screen. 16px hands ~24px back to the
     canvas, which is the cheapest legibility win available on the resume page.
     This cascades to nav, logo, About and Contact too — intended, so the gutter
     stays one value across the site rather than becoming per-page. */
  :root { --gutter: 16px; }

  .col { padding: 14px var(--gutter) 20px; }

  /* The prompts are `white-space: nowrap` by design — a shell prompt is one
     line — so they cannot reflow out of an overflow, they can only run off the
     edge. The longest is the resume page's
     `alex-mody@white-rabbit:~/resume$ cat resume.pdf` at 46 characters, which
     at 14px and a 0.602em advance is 388px against the 358px this layout
     leaves. 12px brings it to 332px and fits. Set on .prompt rather than
     per-page so every prompt on the site shrinks in step. */
  .prompt { font-size: min(12px, 3.1vw); margin-bottom: 10px; }

  /* The prompt and the download button stop sharing a row. Side by side at this
     width the button either wraps mid-label or squeezes the prompt to nothing;
     stacked, it becomes a full-width tap target. */
  .viewer-head { flex-direction: column; align-items: stretch; }

  .viewer-dl {
    margin-bottom: 12px;
    padding: 12px;        /* >=44px tall — a real touch target */
    text-align: center;
  }

  /* The hint takes its own row: 38 characters at 11px is ~252px and the bar's
     inner width is ~336px at 390px, of which the filename, the page count and
     the gaps already spend 119px. flex-basis 100% is what breaks the row
     WITHOUT disturbing .viewer-pages' `margin-left: auto`, which is still what
     pins the counter to the right of the first row. */
  .viewer-bar { gap: 4px 10px; padding: 6px 10px; font-size: 11px; flex-wrap: wrap; }

  .viewer-hint {
    display: block;
    flex: 0 0 100%;
    /* An instruction, not chrome — it is the only thing telling a phone reader
       the resume is interactive at all, so it takes the bright ramp rather than
       the bar's --green-dim. */
    color: var(--green-bright);
    letter-spacing: 0.04em;
  }
  .viewer-log { font-size: 11px; padding: 7px 10px; }

  /* The contact list's 46px indent is a desktop flourish that costs a fifth of
     the text column here, and the values are sized off the container, so the
     indent comes straight out of their size. */
  .contact-list { padding-left: 14px; }
  .contact-entry { margin-bottom: 28px; }

  /* --- Deep dive as a slide-up sheet ------------------------------------

     Stacked, .col--right is order 1 and .col--left order 2, so the deep-dive
     panel sits below the whole PDF: tapping a section updated a box a screen
     and a half away, which reads as the tap having done nothing. Hover cannot
     rescue it here — there is no hover — so the panel comes to the reader
     instead. Pinned to the bottom of the VIEWPORT, shown only while a section
     is open, so the resume never scrolls out from under you.

     Transform, not `display`, so it slides; and `visibility` on top of it so a
     closed sheet is not a focus trap sitting off-screen. */
  /* The sheet is position:fixed, so it leaves .col--left with no content at
     all. Left alone that column still paints its stacked-layout top border and
     padding — an empty bordered strip under the PDF with nothing in it. Zero it
     out rather than display:none, which would take the fixed child with it. */
  .resume-page .col--left {
    padding: 0;
    border-top: none;
  }

  .resume-page .deep-dive {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    margin: 0;
    padding: 10px var(--gutter) 14px;
    max-height: 68dvh;
    display: flex;
    flex-direction: column;
    background: var(--bg);
    border-top: var(--border);
    box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.9);
    transform: translateY(101%);   /* 101% clears the border-top's own pixel */
    visibility: hidden;
    transition: transform 0.22s ease, visibility 0.22s;
  }

  .resume-page .deep-dive.is-open {
    transform: translateY(0);
    visibility: visible;
  }

  /* The sheet is bounded by max-height above, so the panel scrolls inside it
     rather than the sheet growing past the top of the screen. */
  .resume-page .deep-dive .panel {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    font-size: 13.5px;
  }

  /* In the sheet the prompt shares its line with [ close ], and the full
     `alex-mody@white-rabbit:~/resume$` is 32 of its 47 characters — at any size
     that fits the screen it runs straight under the button. The host prefix is
     also the one part of the line carrying no information here: it is identical
     on every page and already shown twice above. Dropping it to a bare `$`
     keeps the prompt reading as a prompt and leaves the command, which is the
     half that changes with the section. */
  .resume-page .deep-dive .prompt {
    margin-bottom: 6px;
    font-size: 12px;
    padding-right: 74px;   /* the [ close ] button's own width, plus air */
  }

  .resume-page .deep-dive .prompt .user { display: none; }
  .resume-page .deep-dive .prompt .cmd::before {
    content: "$ ";
    color: var(--green-dim);
  }

  /* Long enough to need wrapping once the button has taken its corner. */
  .resume-page .deep-dive .breadcrumb {
    font-size: 12px;
    padding-right: 74px;
    white-space: normal;
  }

  /* Only ever visible in the sheet — above this width the panel is a permanent
     column with nothing to dismiss. Sized as a real touch target and parked in
     the corner the thumb reaches. */
  .deep-close {
    position: absolute;
    top: 6px;
    right: var(--gutter);
    display: block;
    padding: 8px 10px;
    background: transparent;
    border: 1px solid var(--green-dim);
    color: var(--green);
    font: inherit;
    font-size: 12px;
    cursor: pointer;
  }
}


/* ---------- Focus visibility ---------------------------------------------- */

:where(a, button, [tabindex]):focus-visible {
  outline: 2px solid var(--green-bright);
  outline-offset: 2px;
}

.hotspot:focus-visible { outline: none; }   /* has its own highlight treatment */

/* ---------- Reduced motion ------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .cursor { animation: none; opacity: 1; }
  .scanlines { display: none; }
}
