/* Home hero — the front door to both sides of the site. Went through a
   near-black phase (see git history / CLAUDE.md tooling log) built around
   assets/js/beyond-horizon.js, an ambient WebGL glow, then back to light —
   see beyond-horizon.js's own "LIGHT-THEME REMAP" comment. That effect is
   itself now deprecated in favor of assets/js/globe.js (a rotating dot
   sphere), per explicit request. --accent (#1d4ed8) is the site's existing
   Grind blue, not an invented color — the globe's dots use it directly, so
   the background effect and the site's own established accent are one
   swatch, not two independently-chosen ones. */
.theme-home {
  --bg: #ffffff;
  --surface: #f5f6f8;
  --text: #14171c;
  --muted: #5b6572;
  --accent: #1d4ed8;
  --border: #e3e6ea;

  background: var(--bg);
  color: var(--text);
  /* No scroll on the homepage at all, per explicit request — the hero fills
     the viewport exactly and there's nothing below it to scroll to anymore
     (the footer moved inside .hero as .hero-copyright). A wheel/touch
     scroll attempt navigates to /resume/ instead (hero.js) — this is what
     makes that a safe, non-destructive gesture rather than a dead end. */
  overflow: hidden;
}

/* The hero needs full viewport width for the background blobs; tokens.css
   constrains main#main to --max-width by default, so override it here and
   do the centering inside .hero-inner instead. */
.theme-home main#main {
  max-width: none;
  padding: 0;
}

.hero {
  position: relative;
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  padding: var(--space-4) var(--space-3);
}

/* Intro Sequence — full-screen "Bhavesh" splash (assets/js/intro-sequence.js),
   playing before the rest of the homepage is revealed. Per explicit
   sequencing request: the name appears alone, full-screen, first; once it
   settles the splash fades away and everything else — the globe, glitter,
   the subhead's own Scramble Text reveal — is revealed together. Those
   other pieces aren't actually delayed or coordinated in any way: they've
   been running/rendering underneath this opaque overlay the entire time,
   so "reveal together" is just this overlay's own opacity transition, not
   a scheduling change to any other script. Supersedes .name-kinetic-*
   (deprecated below) — same grid-spread-then-survivor mechanic, just
   full-screen instead of inline inside the h1.
   display:none by default — reduced motion / no JS / a script error all
   leave this never shown at all, so the real page (already fully
   rendered underneath, since none of that markup depends on this splash)
   is what those visitors see immediately. JS only ever adds
   .intro-splash-active once it can actually run the sequence, same
   progressive-enhancement shape as every other effect on this page. */
.intro-splash {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 999;
  overflow: hidden;
  background: var(--bg);
  align-items: center;
  justify-content: center;
  transition: opacity 0.5s ease;
}

.intro-splash.intro-splash-active {
  display: flex;
}

.intro-splash.intro-splash-out {
  opacity: 0;
  pointer-events: none;
}

/* Font-size is intentionally small relative to the old single-digit-copy
   version — the grid needs to hold 100+ words on screen at once (see
   intro-sequence.js), and a large font-size there would mean the density
   only exists as invisible DOM nodes spilling past the viewport edge,
   not anything a visitor actually perceives as "more." The survivor
   makes up the difference by growing on its own, below. */
.intro-splash-grid {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.12em;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(0.85rem, 2.6vw, 1.6rem);
  color: var(--text);
  transform: scale(1.18);
  transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

.intro-splash-grid.intro-splash-in {
  transform: scale(1);
}

.intro-splash-row {
  display: flex;
  gap: 0.35em;
  white-space: nowrap;
  transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

.intro-splash-row-top {
  transform: translateX(0.6em);
}

.intro-splash-row-bottom {
  transform: translateX(-0.6em);
}

.intro-splash-grid.intro-splash-in .intro-splash-row-top,
.intro-splash-grid.intro-splash-in .intro-splash-row-bottom {
  transform: translateX(0);
}

.intro-splash-word {
  display: inline-block;
  transition: opacity 0.5s ease 0.15s;
}

.intro-splash-grid.intro-splash-in .intro-splash-word:not(.intro-splash-survivor) {
  opacity: 0;
}

.intro-splash-survivor {
  transition: opacity 0.5s ease 0.15s;
}

/* The survivor morphs from its small grid cell directly into the real
   h1's own on-screen position and size, added 2026-09-12 per "text size
   should be small first, after this name should stay and in center" —
   replaces an earlier version that scaled up to a fixed 3.2x in place
   first, then cross-faded to the separately-positioned real heading (a
   size/position jump at the handoff, not a continuous "the name stays").
   The exact translate/scale can't be a static value here — it depends on
   runtime layout (viewport size, real font metrics) at both ends — so
   this class only owns the transition timing, same "class owns timing,
   JS sets the value" split `.life-wipe` already uses for the identical
   reason. `assets/js/intro-sequence.js` computes the transform via a
   standard FLIP calculation (natural rect -> target rect) and applies it
   inline the instant this class is added. */
.intro-splash-survivor.intro-splash-survivor-morph {
  transition: opacity 0.5s ease 0.15s, transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
}

/* Hidden until the survivor above has finished landing on the real name
   (assets/js/intro-sequence.js), so "Hi, I'm"/"." complete the sentence
   around the name once it's in place instead of appearing simultaneously
   with it. Not hidden by a bare .hero-greeting rule: JS only adds
   .hero-greeting-hidden once it has confirmed the splash sequence is
   actually going to run, so a reduced-motion/no-JS/error path — which
   never adds this class — leaves the real heading fully visible
   immediately, same progressive-enhancement shape as every other effect
   on this page. */
.hero-greeting {
  transition: opacity 0.5s ease;
}

.hero-greeting.hero-greeting-hidden {
  opacity: 0;
}

/* DEPRECATED (not deleted): .hero-glow (Beyond Horizon's WebGL canvas),
   superseded by .hero-globe below per explicit request ("instade of
   horaizon add this glob animation"). Markup removed from index.md; CSS and
   assets/js/beyond-horizon.js both left fully intact. Re-enabling: restore
   the markup in index.md and swap the script tag in home.html back. */
.hero-glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: #ffffff;
  overflow: hidden;
}

.hero-glow-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* Globe — a rotating dot-sphere WebGL background (assets/js/globe.js). A
   fixed-size square container, not full-bleed: sized and positioned so a
   circle (the sphere's silhouette) reads clearly rather than filling the
   whole hero. On desktop it's shifted down by exactly half its own height
   (translate 50%, not a bottom% offset — bottom/top percentages resolve
   against the *containing block*'s height for an absolutely positioned
   element, not the element's own, which would make this depend on the
   hero's height instead of the globe's own size) so its vertical center
   lands on .hero's bottom edge — combined with .hero's own overflow:hidden,
   exactly the top half is visible, like a planet's curve rising into frame,
   per explicit request ("in desktop i want globe to be visible half only
   like cut from center"). Mobile drops that offset entirely so the whole
   circle shows, per the same request's mobile carve-out.
   pointer-events: auto (was none) + the grab cursor is for drag-to-rotate
   (globe.js) per explicit follow-up request. .hero's own overflow:hidden
   clips hit-testing along with paint, so dragging only works where the
   dots are actually visible, not across the whole clipped square — and
   .scroll-cue sits at a higher z-index, so it stays clickable where the
   two overlap. */
.hero-globe {
  position: absolute;
  z-index: 0;
  left: 50%;
  bottom: 0;
  /* The 100vh term is the real constraint at typical laptop heights (the
     820px flat cap only binds on tall/external monitors). Without it,
     "bigger" on a shorter window collides with .hero-inner: since
     .hero-globe's visible top is hero.height - diameter/2 (the half-cut
     trick, see the comment above) while .hero-inner sits flex-centered
     at roughly (hero.height + hero-inner's own height) / 2, a fixed
     diameter has *less* clearance the shorter the window gets, not more.
     Reported "looks small on a 16-inch laptop" (2026-09-12) — measured a
     real MacBook-Pro-16-shaped viewport (1728x1000) and found the *old*
     640px cap left 65px of unused headroom below what the height term
     would actually have allowed there, and separately that .hero-inner's
     real height had dropped to 215px (from 247px) since the subhead
     rewrite, both of which this formula now actually uses instead of
     leaving on the table. Solving "keep a 12px gap" for diameter with
     that real 215px height gives 100vh - 239px — re-verified by
     measurement, not just algebra, at 1728x1000, 1512x900, 1440x900,
     1440x800 (the original short-window regression case), and 1024x768:
     holds a consistent small-but-real gap at every one, never negative. */
  width: min(66vw, 820px, calc(100vh - 239px));
  aspect-ratio: 1;
  /* The sphere/marker rendering only ever draws a circular silhouette
     within this square box — nothing previously clipped the box's own
     corners into that shape, so they stayed invisible at rest (empty
     canvas there) but became real, visible rectangle corners once the
     resume-exit zoom's "fill the frame" quad (globe.js) grew past the
     sphere's own render radius: reported "yellow dot becomes a
     rectangle." aspect-ratio:1 keeps this box a perfect square at every
     width, and --globe-zoom scales it *uniformly* (one scalar, not
     scaleX/scaleY), so a circular clip here stays a perfect circle at
     any zoom level rather than drifting into an ellipse — border-radius
     clips the box itself before the transform scales the already-round
     result, the same reason a circle scaled up stays a circle. Verified
     the sphere's own dots at rest already render comfortably inside this
     radius (camDist leaves real margin), so this doesn't crop anything
     that was visible before. */
  border-radius: 50%;
  overflow: hidden;
  transform: translate(-50%, 50%) scale(var(--globe-zoom, 1));
  cursor: grab;
  /* Without this, a browser can claim a click-drag as its own pan/scroll
     gesture before pointermove ever reaches globe.js's handler — mobile's
     coarse touch-drag mostly survives that anyway, but a trackpad or
     precision-pointer drag on desktop is exactly the case this breaks. */
  touch-action: none;
  /* --globe-zoom is 1 at rest. A WebGL canvas can only ever render within
     its own drawing buffer (sized to this element's own box, e.g. up to
     560px square) — so globe.js growing the marker's gl_PointSize alone
     can make the amber dot dominate that small box, but can't make it
     cover an actual 1280px+ viewport on its own. This CSS scale is what
     actually balloons the whole element up to fill the screen; hero.js
     sets it right as globe.js's own spin-then-zoom sequence reaches its
     zoom phase, so the two effects land together — the sphere is already
     facing the marker and its dot is already growing internally by the
     time the container itself starts visibly expanding. */
  transition: transform 0.7s cubic-bezier(0.55, 0, 1, 0.45);
}

.hero-globe:active {
  cursor: grabbing;
}

/* DEPRECATED (not deleted): a soft ambient blue glow behind the
   dot-sphere, added 2026-09-12 in the same session per feedback that the
   globe read as flat/"just ok" even after two size passes — reused the
   site's own accent blue (#1d4ed8), `::before` so it painted behind the
   canvas through its own transparent alpha:true gaps. Explicitly asked
   to be removed minutes later ("remove color haz (blue) from globe") —
   the depth-contrast and rotation-speed changes made in the same pass
   stay, only the glow itself was the problem. Re-enabling: uncomment the
   rule below and restore `isolation: isolate` on `.hero-globe` above (it
   existed only to pin a clean stacking context for this).
.hero-globe::before {
  content: "";
  position: absolute;
  inset: -18%;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    rgba(29, 78, 216, 0.2) 0%,
    rgba(29, 78, 216, 0.09) 42%,
    rgba(29, 78, 216, 0) 70%
  );
  pointer-events: none;
}
*/

.hero-globe-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

@media (max-width: 640px) {
  .hero-globe {
    width: min(94vw, 430px);
    bottom: 6%;
    transform: translateX(-50%) scale(var(--globe-zoom, 1));
  }
}

/* DEPRECATED (not deleted): .hero-blobs. .hero-glitter runs again below,
   layered on top of the current background per explicit request ("i want
   both effect in landing page") — its canvas background stays transparent
   between star draws (see glitter.js), so whatever's behind it shows
   through everywhere except the star pixels themselves. .hero-blobs is
   still off; three animated backgrounds at once is more than two.
   Re-enabling is deleting this one display:none rule, nothing to rebuild. */
.hero-blobs {
  display: none;
}

.hero-blobs {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  /* --mx/--my: set by hero.js on mousemove, a subtle parallax shift. */
  transform: translate(var(--mx, 0px), var(--my, 0px));
  transition: transform 0.4s ease-out;
}

/* Builds up as the user scrolls toward the sentinel (--scroll-progress,
   0 to 1, set on <html> by hero.js) — a hint that something's about to
   happen, well before the actual navigation fires at the bottom. Fixed to
   the viewport, not .hero, so it's still visible once the hero itself has
   scrolled out of view. */
.scroll-veil {
  position: fixed;
  inset: 0;
  z-index: 5;
  background: radial-gradient(circle at 50% 100%, rgba(29, 78, 216, 0.4), transparent 70%);
  opacity: var(--scroll-progress, 0);
  pointer-events: none;
}

/* Pure scroll distance for the veil to build up over — the hero itself is
   ~one viewport tall, leaving almost no room to scroll "within" it before
   hitting the sentinel. */
.scroll-runway {
  height: 60vh;
}

.blob {
  position: absolute;
  border-radius: 50%;
  filter: blur(70px);
  opacity: 0.4;
  animation: drift 20s ease-in-out infinite;
  will-change: transform;
}

.blob-a {
  width: 26rem;
  height: 26rem;
  background: #93c5fd;
  top: -8%;
  left: -10%;
}

.blob-b {
  width: 22rem;
  height: 22rem;
  background: #fbbf24;
  bottom: -12%;
  right: -8%;
  animation-delay: -7s;
}

.blob-c {
  width: 18rem;
  height: 18rem;
  background: #6ee7b7;
  top: 45%;
  left: 62%;
  animation-delay: -13s;
}

@keyframes drift {
  0%, 100% { transform: translate(0, 0) scale(1); }
  33% { transform: translate(2rem, -2.5rem) scale(1.08); }
  66% { transform: translate(-1.5rem, 1.5rem) scale(0.95); }
}

/* Hero glitter — canvas warp-tunnel background (assets/js/glitter.js),
   ported from a React/canvas component the user supplied. Sits between the
   blobs and the text content. Fully skipped (script returns early, canvas
   stays empty/transparent) under prefers-reduced-motion. */
.hero-glitter {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Hero stars — the galaxy button's star motif, carried into the background.
   A plain white-on-black starfield doesn't work on this light page, so
   these are small saturated dots (richer siblings of the blob palette)
   with a soft glow, twinkling gently. Reuses .galaxy__container's
   star-twinkle keyframe (defined below) rather than a second one that
   does the same thing. Positions are hand-placed via a Liquid loop in
   index.md, not random — random would need JS or would be unstable
   between builds, and a fixed scattered layout looks just as natural. */
.hero-stars {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}

/* DEPRECATED (not deleted): .hero-stars is superseded by .hero-glitter
   above, which covers the same "twinkling star field" role with actual
   motion. Everything above is untouched and fully working — re-enabling
   is deleting this one rule, nothing to rebuild. */
.hero-stars {
  display: none;
}

@keyframes star-twinkle {
  0%, 100% { opacity: 0.25; }
  50% { opacity: 1; }
}

.hero-star {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: #60a5fa;
  box-shadow: 0 0 6px 1px rgba(96, 165, 250, 0.6);
  animation: star-twinkle 3.2s ease-in-out infinite;
}

.hero-star:nth-child(3n) {
  background: #f59e0b;
  box-shadow: 0 0 6px 1px rgba(245, 158, 11, 0.55);
}

.hero-star:nth-child(3n + 1) {
  background: #34d399;
  box-shadow: 0 0 6px 1px rgba(52, 211, 153, 0.5);
}

.hero-star:nth-child(4n) {
  width: 2px;
  height: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .hero-star {
    animation: none;
    opacity: 0.5;
  }
}

/* Pixel Name — canvas particle formation for the word "Bhavesh" inside the
   hero heading (assets/js/pixel-name.js), adapted from a pasted Originkit
   "Pixel Drift" React reference. Sized to cover the whole hero (not just
   the word) so particles genuinely travel in "from around the screen"
   rather than a tight ring right next to the letters — same absolute-inset
   pattern as .hero-globe/.hero-glitter. z-index above .hero-inner
   (including its mobile backdrop panel above) so the formed word reads
   clearly instead of being dimmed by that panel; the brief mid-flight
   overlap with "Hi, I'm"/the subhead during the ~1.1s formation is an
   acceptable trade for particles crossing real screen distance.
   pointer-events:none and aria-hidden: purely decorative, the real text
   underneath (.pixel-name-plain) is what a screen reader/no-JS/reduced-
   motion visitor actually sees. */
.pixel-name-canvas {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Made transparent, not display:none/clipped out of flow — it needs to
   keep reserving its normal inline space in "Hi, I'm ___ ." (including
   through the h1's responsive clamp() font-size) so hiding it never
   shifts the surrounding plain text. Screen readers/SEO see plain visible
   text regardless of color, so nothing is lost for those visitors; no JS
   or reduced motion just leaves it looking exactly as it always did. */
.pixel-name-plain.pixel-name-hidden {
  color: transparent;
}

/* DEPRECATED — Name Kinetic, a small inline one-shot "appear" flourish for
   the word "Bhavesh" (assets/js/name-kinetic.js), adapted from a pasted
   Originkit "Appear Text" / KineticTextGrid React+Framer-Motion reference.
   Superseded by .intro-splash above per explicit request to sequence the
   intro — the same grid-spread-then-survivor mechanic now plays full-
   screen, alone, before the rest of the page, rather than inline inside
   the h1 while everything else is already visible. Script tag commented
   out in home.html; CSS and markup (`.name-kinetic-wrap` in index.md) left
   intact — re-enabling is one uncommented script tag, and the two effects
   don't conflict since `.intro-splash` covers the whole screen regardless
   of what the (now-inert) inline version would have done underneath it. */
.name-kinetic-wrap {
  position: relative;
  display: inline-block;
}

.name-kinetic-plain.name-kinetic-hidden {
  color: transparent;
}

.name-kinetic-grid {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(1.18);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.12em;
  pointer-events: none;
  transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

.name-kinetic-grid.name-kinetic-in {
  transform: translate(-50%, -50%) scale(1);
}

.name-kinetic-row {
  display: flex;
  gap: 0.35em;
  white-space: nowrap;
  transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}

.name-kinetic-row-top {
  transform: translateX(0.6em);
}

.name-kinetic-row-bottom {
  transform: translateX(-0.6em);
}

.name-kinetic-grid.name-kinetic-in .name-kinetic-row-top,
.name-kinetic-grid.name-kinetic-in .name-kinetic-row-bottom {
  transform: translateX(0);
}

.name-kinetic-word {
  display: inline-block;
  transition: opacity 0.5s ease 0.15s;
}

.name-kinetic-grid.name-kinetic-in .name-kinetic-word:not(.name-kinetic-survivor) {
  opacity: 0;
}

/* Dynamic Weight — "after everything, hovering the name gets it bold,
   that's it." A plain CSS :hover on the same shared "Bhavesh" span
   (.pixel-name-plain/.name-kinetic-plain), genuinely interpolated rather
   than synthetically faked: Fraunces is a variable font and its loaded
   weight range was widened to 400..900 (see head.html) specifically so
   this transition has real in-between weights to animate through, not
   just a jump between two static cuts. No JS, no hover-capability
   gating — it's a harmless enhancement on non-hover devices (a tap either
   does nothing or transiently applies :hover, both fine), so there's
   nothing here that needs feature-detecting the way cursor-tracking
   effects elsewhere on this page do. */
.dynamic-weight {
  transition: font-weight 0.3s ease;
}

.dynamic-weight:hover {
  font-weight: 900;
}

.hero-inner {
  position: relative;
  z-index: 1;
  max-width: 640px;
  text-align: center;
}

/* On mobile the globe (see .hero-globe) sits tall enough to run straight
   through the heading/subhead, not just brush past them — a real overlap
   confirmed via getBoundingClientRect, not a hunch. Desktop's globe stays
   low enough that this never triggers there, so the panel is scoped to the
   same max-width:640px breakpoint the globe's own mobile sizing uses.
   Same treatment as .scroll-cue's pill: a plain blurred white wash rather
   than a globe-matched color, so it stays correct if the background effect
   changes again.
   Backdropped per text block, not one shared box around .hero-inner as a
   whole — a single enclosing box added its own padding/border-radius/
   margin-bottom gap on top of the text, covering noticeably more of the
   globe than the text itself needed. First fix moved to one inline-block
   pill per block (h1, then the subhead paragraph) — still too much,
   because .hero-sub wraps across 3-4 lines and a single inline-block box
   is one rectangle sized to the WIDEST line, so shorter lines (e.g. "...
   than it looks.") got backdrop stretching well past their own text on
   both sides, still eating into the globe. Fixed with the standard
   multi-line-highlight technique instead: the subhead's actual text is a
   `.hero-sub-text` span (see index.md) that goes `display: inline` with
   `box-decoration-break: clone`, so the browser gives each wrapped line
   its own tightly-sized background box rather than one shared rectangle.
   The outer `<p class="hero-sub">` stays a normal block — critical,
   because making the PARAGRAPH itself `display:inline` (the first attempt)
   removed the automatic line break between it and the h1 above, letting
   the first few characters of the subhead render up on the heading's own
   line before wrapping, a real rendering bug caught via getClientRects()
   (5 fragments, the first only 16px wide sitting in the h1's row) rather
   than just eyeballing a screenshot. h1 stays inline-block since it's
   always one line — no per-line issue to fix there. */
@media (max-width: 640px) {
  .hero-inner h1 {
    display: inline-block;
    background: rgba(255, 255, 255, 0.78);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border-radius: 10px;
    padding: 0.05em 0.4em;
  }

  .hero-inner .hero-sub-text {
    display: inline;
    background: rgba(255, 255, 255, 0.78);
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
    border-radius: 6px;
    padding: 0.2em 0.35em;
  }
}

.hero-inner h1 {
  font-size: clamp(2.2rem, 6vw, 3.2rem);
  margin-bottom: var(--space-2);
  /* A touch of negative tracking at display size — every dark-canvas
     reference checked (Linear -3px @80px, Vercel -2.4px @48px, Raycast's
     ss03 tightening) does this on display type; kept modest here since
     Fraunces is a serif, not the geometric sans those systems tighten. */
  letter-spacing: -0.01em;
}

.hero-inner .hero-sub {
  color: var(--muted);
  font-size: clamp(1.05rem, 2.2vw, 1.2rem);
  max-width: 52ch;
  margin: 0 auto var(--space-4);
}

/* Hero-wide glow bloom on galaxy-button hover/focus — DORMANT. The galaxy
   button moved to the résumé nav (see professional.css), so no
   `.galaxy-button` element exists inside `.hero` anymore and the :has()
   selector below never matches. Left in place, not deleted, in case a
   future hero CTA wants the same "hovering this lights up the scene"
   treatment. Was a scoped stand-in for the reference galaxy-button's
   `body:has(button:hover)` full-page black-out. */
.hero-hover-glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: radial-gradient(120% 90% at 50% 65%, rgba(29, 78, 216, 0.28), transparent 70%);
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}

@supports selector(:has(a)) {
  .hero:has(.galaxy-button:hover, .galaxy-button:focus-visible) .hero-hover-glow {
    opacity: 1;
  }
}

/* DEPRECATED (not deleted): the page-corner (desktop) / portal-orb (mobile)
   hint at /life/, and the .dissolving "Thanos snap" transition it triggered
   (assets/js/dissolve.js), per explicit user feedback ("remove that page and
   thanos effect for now it not what i am looking for"). .life-pull-btn (the
   bubble-expand button/swipe-left gesture, added later — see below) is now
   the primary path into /life/; the spotlight-text hint (desktop-only, see
   below) is the secondary one. Both page-corner/portal-orb are fully intact
   below — markup still in index.md, JS files untouched, dissolve.js just no
   longer loaded (see home.html) since its only triggers are now hidden.
   Re-enabling: delete the one display:none rule below and restore
   dissolve.js's <script> tag. */
.page-corner,
.portal-orb {
  display: none !important;
}

.page-corner {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 3;
  width: 96px;
  height: 96px;
  text-decoration: none;
  cursor: pointer;
}

.page-corner-back,
.page-corner-front {
  position: absolute;
  inset: 0;
  clip-path: polygon(100% 0, 100% 100%, 0 0);
  transform-origin: 100% 0;
}

.page-corner-back {
  background: #1c1430;
  box-shadow: 0 0 24px 6px rgba(110, 193, 255, 0.35);
}

.page-corner-front {
  background: #ffffff;
  box-shadow: -6px 6px 16px rgba(20, 23, 28, 0.12);
  animation: corner-flutter 5s ease-in-out infinite;
  transition: transform 0.4s var(--ease);
}

.page-corner:hover .page-corner-front,
.page-corner:focus-visible .page-corner-front {
  transform: scale(0.82) rotate(-8deg);
}

.page-corner:focus-visible {
  outline: 2px solid #6ec1ff;
  outline-offset: 4px;
}

@keyframes corner-flutter {
  0%, 100% { transform: rotate(0deg) scale(1); }
  50% { transform: rotate(-3deg) scale(0.97); }
}

@media (prefers-reduced-motion: reduce) {
  .page-corner-front {
    animation: none;
    transform: rotate(-3deg) scale(0.94);
  }
}

@media (max-width: 640px) {
  .page-corner {
    display: none;
  }
}

/* Portal orb — mobile only (see .page-corner above). A small glowing dot,
   real <a href="/life/">, whose label only appears on hover/focus; the
   label text is always in the DOM so screen readers announce it regardless
   of the opacity trick. Visual dot is 16px but the tap target is 44px via
   an invisible ::before overlay. */
.portal-orb {
  display: none;
}

/* Was a dark dot on a light page; the homepage background is dark now too,
   so it flips to a small bright star instead — same idea (a point of light
   hinting at the Chaos side), same blue glow, just visible against its new
   backdrop. */
@media (max-width: 640px) {
  .portal-orb {
    display: block;
    position: absolute;
    z-index: 3;
    left: 8%;
    bottom: 8%;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: #eaf4ff;
    box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 22px 6px rgba(110, 193, 255, 0.65);
    text-decoration: none;
    cursor: pointer;
    animation: orb-pulse 3.5s ease-in-out infinite;
    transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease);
  }

  .portal-orb::before {
    content: "";
    position: absolute;
    inset: -14px;
  }

  .portal-orb:hover,
  .portal-orb:focus-visible {
    transform: scale(1.2);
    box-shadow: 0 0 0 6px rgba(234, 244, 255, 0.1), 0 0 32px 10px rgba(110, 193, 255, 0.9);
  }

  .portal-orb:focus-visible {
    outline: 2px solid #6ec1ff;
    outline-offset: 6px;
  }

  .portal-orb-label {
    position: absolute;
    right: 50%;
    bottom: 140%;
    transform: translate(50%, 6px);
    white-space: nowrap;
    background: #1c1430;
    border: 1px solid rgba(183, 155, 250, 0.35);
    color: #eaf4ff;
    font-size: 0.8rem;
    font-weight: 600;
    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s ease, transform 0.25s ease;
  }

  .portal-orb:hover .portal-orb-label,
  .portal-orb:focus-visible .portal-orb-label {
    opacity: 1;
    transform: translate(50%, 0);
  }
}

@keyframes orb-pulse {
  0%, 100% { box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 22px 6px rgba(110, 193, 255, 0.65); }
  50% { box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 28px 9px rgba(110, 193, 255, 0.85); }
}

@media (prefers-reduced-motion: reduce) {
  .portal-orb {
    animation: none;
  }
}

/* Spotlight text — a second, quieter hint at /life/, à la a flashlight
   sweeping dim text into brightness. Desktop only, per explicit request
   ("hiddentxt should be hidden totally and it should work on only desktop
   ... in mobile i dont want that effect or feature") — hidden outright at
   max-width:640px below, not just left in its unenhanced fallback state.
   On desktop: once hero.js confirms hover + motion are both available it
   adds .spotlight-ready, which dims the text almost to nothing and switches
   on the cursor-following reveal — genuinely hidden until hovered, not just
   quiet. Without JS/with reduced motion on desktop, it falls back to plain
   readable muted text rather than disappearing, since there's no way to
   detect "hover" without JS to gate the enhancement on. */
@property --spot-size {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.spotlight-text {
  position: absolute;
  z-index: 2;
  top: 16%;
  right: 8%;
  max-width: 15rem;
  text-align: right;
  text-decoration: none;
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.15rem;
  line-height: 1.5;
}

/* Both layers must share every metric that affects glyph width/wrapping
   (weight, style, size) — the two layers are drawn from the same text flow
   and have to break onto the same lines, or the mask reveals bright glyphs
   sitting in a different spot than the dim ones underneath them. That
   mismatch is what "misaligned" was: an earlier version only bolded the
   bright layer, which widened its glyphs enough to reflow differently at
   some widths. */
.spotlight-dim,
.spotlight-bright {
  display: block;
  margin: 0;
  font: inherit;
}

.spotlight-dim {
  color: var(--muted);
}

.spotlight-bright {
  display: none;
}

.spotlight-text.spotlight-ready {
  cursor: none;
}

/* Fully transparent, not just faint — per explicit request ("make it
   complitly hidden only if cursor is on top of text than it should be
   visible"). Combined with .spotlight-bright's --spot-size:0px default
   (below), nothing here is visible at all until the cursor's flashlight
   circle reveals it. */
.spotlight-text.spotlight-ready .spotlight-dim {
  color: transparent;
}

.spotlight-text.spotlight-ready .spotlight-bright {
  display: block;
  position: absolute;
  inset: 0;
  color: #12181f;
  pointer-events: none;
  --spot-size: 0px;
  transition: --spot-size 0.3s ease;
  -webkit-mask-image: radial-gradient(circle var(--spot-size) at var(--spot-x, 50%) var(--spot-y, 50%), black, black 55%, transparent 100%);
  mask-image: radial-gradient(circle var(--spot-size) at var(--spot-x, 50%) var(--spot-y, 50%), black, black 55%, transparent 100%);
}

.spotlight-text.spotlight-ready:hover .spotlight-bright,
.spotlight-text.spotlight-ready:focus-visible .spotlight-bright {
  --spot-size: 140px;
}

.spotlight-text:focus-visible {
  outline: 2px solid #6ec1ff;
  outline-offset: 6px;
}

/* Hover-only hint: touch tablets (no hover, but wider than 640px) used to
   fall through to the plain-text fallback above and show it permanently.
   No hover means nothing can ever reveal it, so hide it like on phones. */
@media (max-width: 640px), (hover: none) {
  .spotlight-text {
    display: none;
  }
}

/* A real link, not just a hint — the only way into /resume/ that doesn't
   depend on scrolling or motion (hero.js skips the whole scroll mechanic
   under prefers-reduced-motion, so this has to work standing alone). Styled
   quietly on purpose; it's still a full link underneath.
   A soft backdrop pill was added once the bigger globe (see .hero-globe)
   started reaching high enough to sit directly behind this text on wide
   viewports — z-index already kept the text on top and clickable, but a
   dense field of dots right behind it made it genuinely harder to read.
   The pill is deliberately background-agnostic (a plain blurred white
   wash, not "globe-colored") so it stays correct if what's behind it ever
   changes again.

   The backdrop lives on the icon and the text separately now, not on
   this shared flex container — per explicit follow-up that one pill
   wrapping both was still covering more globe than it needed to, since
   the gap between the arrow and "keep going" got backdrop too despite
   having no content in it worth protecting. Two tightly-fitted shapes
   (below) cover only the icon's own circle and the text's own pill,
   leaving the gap between them showing the globe through untouched. */
.scroll-cue {
  position: absolute;
  bottom: var(--space-3);
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  color: var(--muted);
  font-size: 0.8rem;
  text-decoration: none;
}

.scroll-cue svg,
.scroll-cue span {
  background: rgba(255, 255, 255, 0.45);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

/* The icon needs more cover than the text: getBoundingClientRect() at
   every tested breakpoint (375/768/1440) shows the svg's own box sits
   fully inside .hero-globe's circle, not just brushing it, while the
   "keep going" span mostly clears the globe's bottom edge. Per the
   explicit "nudge the opacity back up first, not the padding" note left
   when this was last turned down (see DESIGN.md) — the globe has grown
   since then, so the 0.45 base opacity no longer reliably reads a thin
   2px stroke against a dense, rotating dot field at every angle. Bumped
   for the icon only; the text pill is untouched since it isn't actually
   sitting on the globe. */
.scroll-cue svg {
  display: block;
  box-sizing: content-box;
  padding: 6px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.65);
  animation: bob 1.8s ease-in-out infinite;
}

.scroll-cue span {
  display: block;
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
}

/* Mobile-only: matches .hero-sub-text's own treatment for the exact same
   "text over the globe" problem — a solid-ish background, no
   backdrop-filter at all — per explicit feedback that the pill's blur
   looked heavier here than the equivalent hint elsewhere on the page. */
@media (max-width: 640px) {
  .scroll-cue svg,
  .scroll-cue span {
    background: rgba(255, 255, 255, 0.55);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* Mobile's globe sits lower (bottom: 6%) but has grown enough since that
     note was written that the icon's box is now fully inside its circle
     too (measured, not assumed) — same fix, same reasoning as the base
     rule above, just within the no-blur mobile treatment. */
  .scroll-cue svg {
    background: rgba(255, 255, 255, 0.75);
  }
}

.scroll-cue:hover,
.scroll-cue:focus-visible {
  color: var(--text);
}

.scroll-cue:focus-visible {
  outline: 2px solid #1d4ed8;
  outline-offset: 4px;
  border-radius: 4px;
}

@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(6px); }
}

/* Pull dot into /life/ — a real link, same "always works standing alone"
   rule as .scroll-cue. Colored from the personal theme's own palette
   (#12181f fill, reused from .keycap-btn; #e8eef7 personal text for the
   arrow) rather than anything homepage-specific, since it's the button's
   destination that should read on it, not whatever's behind it — same
   convention as every other cross-side button on this site. A small
   circle, not the earlier rectangular pull-tab, per explicit request for
   the trigger to read as a "dot" that then expands into a "big circle" —
   the resting shape and the expansion shape needing to already match is
   what "seamless" means here.

   Seamless into the edge, not floating near it: `right: -20px` (half the
   button's own width) puts the circle's *center* exactly on the
   viewport's right edge, so only its left half ever renders — the
   viewport boundary itself does the clipping, no clip-path needed. That
   silhouette is exactly "width 0 at the top, curving out to full width
   in the middle, back to 0 at the bottom" per explicit request: the
   top/bottom of a circle sit directly above/below its center (zero
   horizontal offset from it), so those points land precisely on the
   edge, while the leftmost point — the vertical middle — is a full
   radius away, the widest the bulge ever gets. */
.life-pull-btn {
  position: fixed;
  top: 50%;
  right: -20px;
  transform: translateY(-50%);
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  padding-left: 2px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #12181f;
  color: #e8eef7;
  text-decoration: none;
  box-shadow: 0 2px 14px rgba(0, 0, 0, 0.22);
  transition: transform 0.25s var(--ease), background 0.25s ease;
}

/* Real hit target is half a 40px circle (mobile: half of 32px) sitting at
   the viewport edge — well under WCAG 2.5.8's 24px minimum. Same
   invisible-::before pattern .portal-orb already uses elsewhere in this
   file for the identical small-dot problem: extends the clickable area
   without changing the visible circle's shape at all. Half of this box
   is off-screen too (nothing to click there regardless), so only the
   on-screen extension actually helps, but the symmetric inset is simpler
   than a one-sided one and costs nothing on the wasted side. */
.life-pull-btn::before {
  content: "";
  position: absolute;
  inset: -14px;
}

.life-pull-btn:hover,
.life-pull-btn:focus-visible {
  transform: translateY(-50%) scale(1.12);
  background: #1a232e;
}

/* An outward `outline` on a circle that's already half off-screen draws
   almost entirely into the clipped half — confirmed live, ~82% of the
   ring rendered off-canvas. box-shadow's inset keyword draws inward from
   the border box instead, so it's confined to whatever portion of the
   circle is actually visible, on any viewport. Combined with the resting
   drop-shadow in one declaration since box-shadow doesn't merge across
   rules. */
.life-pull-btn:focus-visible {
  outline: none;
  box-shadow: 0 2px 14px rgba(0, 0, 0, 0.22), inset 0 0 0 2px #6ec1ff;
}

/* Centered within the whole circle (half of which is off-screen) would
   put the icon right on the edge, half invisible — shifted into the
   visible left half instead via the button's own flex/padding above. */
.life-pull-btn svg {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
}

/* A real overlap, not a hypothetical one: at 50% vertical center, this
   button's box genuinely intersects .hero-sub-text's own bounding box on
   a typical phone height (confirmed via getBoundingClientRect — the
   subhead wraps to 4 lines on narrow viewports, reaching well past the
   screen's vertical center). Moved down to 62% instead, clearing the
   subhead with margin to spare; the globe sits in that same region but
   overlapping a decorative dot field is a non-issue next to overlapping
   readable text. */
@media (max-width: 640px) {
  .life-pull-btn {
    top: 62%;
    right: -16px;
    width: 32px;
    height: 32px;
  }

  /* The visible half is only 16px wide here — a 16px icon would exactly
     fill it edge to edge with zero breathing room, so it shrinks a
     touch to leave a hair of margin on both sides. */
  .life-pull-btn svg {
    width: 13px;
    height: 13px;
  }
}

/* The "bubble" that plays on the way into /life/ — a swipe-left gesture
   or a .life-pull-btn click both call goToLife() (hero.js), which grows
   this from a small circle centered exactly on the button into one big
   enough to cover the whole screen, then navigates. Plain clip-path
   circle(), not @property-registered custom properties: a circle only
   ever needs one number (its radius) to grow, since the center point
   never moves, so there's no multi-property choreography to build here
   at all — hero.js sets the literal "from" and "to" clip-path strings
   directly via inline style (both the rest radius, matching the dot, and
   the active radius, computed from the button's center to the farthest
   corner of the viewport so the circle is guaranteed to cover every
   pixel regardless of aspect ratio), and this rule only owns the
   transition's timing.

   This replaces an earlier rectangular version (a clip-path: inset()
   sweep, plain transform: scaleX() before that) — worth calling out
   directly since the earlier inset() version used @property to give
   its four sides independent timing, and @property support is real but
   not universal (a materially newer addition than plain clip-path
   interpolation, which has worked in every evergreen browser for years);
   on a browser without it, those custom properties silently stop being
   animatable and the whole transition just snaps to its end state
   instantly — reported directly as "the transition is very fast and
   unable to see it happening." A single-value circle() radius needs no
   such registration, so this version doesn't carry that risk. At rest
   this sits one z-index below .life-pull-btn (hidden behind the opaque
   dot); .life-wipe-active jumps it above instead, at the exact moment
   its clip-path starts matching the dot's own shape/color/position
   exactly, for the same reason the rectangular version needed that swap
   — otherwise the dot, still opaque and still on top, would just sit
   there unchanged while the circle grew everywhere around it. #12181f
   is the button's own fill, not a new color, so that swap is invisible.
   The earlier, more elaborate "Thanos snap" (assets/js/dissolve.js) was
   explicitly cut for being more than what was wanted; this is the
   simpler effect asked for instead. */
.life-wipe {
  position: fixed;
  inset: 0;
  z-index: 39;
  background: #12181f;
  pointer-events: none;
}

.life-wipe-active {
  z-index: 41;
  transition: clip-path 0.6s cubic-bezier(0.3, 1.2, 0.4, 1);
}

@media (prefers-reduced-motion: reduce) {
  .life-wipe-active {
    transition: none;
  }
}

/* The white handoff on the way into /resume/ (hero.js's goToResume,
   triggered once globe.js's marker-zoom finishes) — a real full-viewport
   cover, not just a fade on the globe's own canvas. Fading only the
   canvas used to reveal the rest of .hero (headline, subhead, glitter,
   the life pull-tab, copyright) still sitting there untouched, which
   showed up as "the landing page again for half a second" between the
   amber zoom and the actual navigation — see globe.js and hero.js for the
   fuller story. #ffffff, not a new shade: both .theme-home and /resume/
   share the same white background, so fading to opaque white lands on
   exactly the color the next page opens on. */
.resume-wipe {
  position: fixed;
  inset: 0;
  z-index: 950;
  background: #ffffff;
  opacity: 0;
  pointer-events: none;
}

.resume-wipe-active {
  transition: opacity 0.3s ease;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .resume-wipe-active {
    transition: none;
  }
}

/* Copyright line — no longer a separate <footer> after the hero (that
   added document height beyond .hero's own 100dvh, which is exactly the
   scroll this page isn't supposed to have — see the no-scroll rule on
   .theme-home below). Lives inside .hero as a small corner mark instead,
   bottom-left so it doesn't compete with .scroll-cue (center) or
   .spotlight-text (top-right, desktop). Email address dropped per explicit
   request — it's still one click away via /resume/'s own contact line,
   this was purely decorative legal text on the homepage, not a unique
   contact path. */
.hero-copyright {
  position: absolute;
  z-index: 1;
  left: var(--space-3);
  bottom: var(--space-2);
  margin: 0;
  color: var(--muted);
  font-size: 0.75rem;
  white-space: nowrap;
}

/* Status dock — top-left, the one hero corner nothing else claims
   (.page-corner/.portal-orb, both deprecated, used to own top-right;
   .spotlight-text sits top-right on desktop; .hero-copyright/.scroll-cue
   own the bottom). One slim glass bar with the clock, the weather and the
   distance as segments (hero.js, weather.js and distance.js build them).
   It came out of "explore some useful UX designs instead of putting blocks
   for all three": three separate cards weighed far more than the single
   Getting bored? pill opposite them and stacked into a column on a phone;
   the bar is about that pill's weight and one row at every width. The
   detail lives in a panel each segment opens on hover (or tap), and the
   segments take turns catching the eye until someone touches the bar
   (assets/js/status-dock.js). The segments are position:static, so every
   panel anchors under the bar: the same spot for all three, and always on
   screen. On a phone the bar is centred at the top and has the top to
   itself: the game button moves to the bottom-right (below). */
.hero-status {
  position: absolute;
  z-index: 2;
  top: var(--space-3);
  left: var(--space-3);
  display: flex;
  align-items: center;
  width: max-content;
  max-width: calc(100% - 48px);
  padding: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.88);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(29, 78, 216, 0.16);
  box-shadow: 0 2px 10px rgba(20, 23, 28, 0.07);
}

@media (max-width: 640px) {
  .hero-status {
    left: 0;
    right: 0;
    margin-inline: auto;
  }
}

/* .hero-bored-trigger: the homepage's own Slice Blade trigger — .bored-
   trigger's shared styling (tokens.css) reused here with its own fixed
   position, since the homepage has no footer.html to hold the inline
   version everywhere else on the site does. Top-right mirrors .hero-
   status's own top-left corner — the one spot nothing else here claims
   now that .page-corner/.portal-orb (which used to own it) are both
   deprecated. Persistent, not timed like .joke-hint below, per the
   explicit "make sure users sees it" ask — margin-left:0 overrides the
   footer version's inline spacing, irrelevant to a standalone button.
   Recolored to .hero-status-chip's own quiet glass pill instead of
   .bored-trigger's solid accent-gradient fill, per direct feedback that
   it was "highlighting too much" — the loud version made sense in a
   muted footer competing with plain text, but here it was the single
   most saturated thing on the whole hero, outshouting the actual content.
   .bored-trigger itself (the footer version on /resume/, /life/, /lab/)
   is untouched; this only overrides the homepage's own instance. On a
   phone it leaves the top altogether (see the max-width:640px block below
   it): the page opened on a game button, then on one in the middle. */
.hero-bored-trigger {
  position: absolute;
  z-index: 2;
  top: var(--space-3);
  right: var(--space-3);
  margin-left: 0;
  /* The coarse-pointer rule in tokens.css makes every .bored-trigger 44px
     tall on a touch screen. This one stays the quiet 26px and gets its 44px
     tap target from the ::before below instead, the way .life-pull-btn does,
     so it looks the same on a phone, a tablet and a desktop. */
  min-height: 0;
  background: rgba(255, 255, 255, 0.85);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(29, 78, 216, 0.16);
  color: var(--text) !important;
  box-shadow: 0 2px 10px rgba(20, 23, 28, 0.07);
}

.hero-bored-trigger::before {
  content: "";
  position: absolute;
  inset: -10px -6px;
}

/* Centred on the dock's row rather than level with its top edge. */
@media (min-width: 641px) {
  .hero-bored-trigger {
    top: calc(var(--space-3) + 9px);
  }
}

.hero-bored-trigger:hover,
.hero-bored-trigger:focus-visible {
  box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
}

.hero-bored-trigger .bored-trigger-icon {
  color: var(--accent);
  /* No idle wiggle here — the hero already has the globe as its one
     ambient motion; a second, permanently-animating element competes for
     attention instead of just being noticed once. The footer version
     keeps its wiggle (tokens.css), since a quiet footer has no such
     competition. */
  animation: none;
}

/* On a phone it sits on the footer row, bottom-right, with the copyright line
   at the other end: 13px up centres the 26px pill on that line (16px up, 20px
   tall). "keep going" moves up one row (52px) so the three do not share a line:
   the full-width pill cannot sit beside the cue text below about 400px, and
   the cue text and the copyright used to overlap by a few pixels at every
   phone size. */
@media (max-width: 640px) {
  .hero-bored-trigger {
    top: auto;
    bottom: calc(var(--space-2) - 3px);
    padding: 0.4em 0.7em;
    font-size: 0.78em;
  }

  .theme-home .scroll-cue {
    bottom: calc(var(--space-3) + 28px);
  }
}

/* At 320px the pill would sit 4px from the copyright text; a size down gives
   it about 16px (and a taller ::before keeps the tap target at 44px). */
@media (max-width: 340px) {
  .hero-bored-trigger {
    padding: 0.35em 0.6em;
    font-size: 0.72em;
  }

  .hero-bored-trigger::before {
    inset: -11px -6px;
  }
}

/* A short landscape phone has no spare row for the cue (the subhead runs down
   to it), so it stays on the bottom line there; the screen is wide enough that
   the pill still clears it. */
@media (max-width: 640px) and (max-height: 480px) {
  .theme-home .scroll-cue {
    bottom: var(--space-3);
  }
}

/* One segment of the dock: a glyph (the clock character, the round sky, the
   little map), then the value, then a label that stays folded away until the
   segment is hovered, open (a tap, where there is no hover) or has the
   spotlight (status-dock.js), when it slides open. The distance keeps its
   label always: "apart" is what makes the number mean something. gap is 0
   and the spacing sits on the children, so a folded label leaves no dead
   space. A soft ring pings out of the segment that has the spotlight. */
.hero-status-chip {
  display: inline-flex;
  align-items: center;
  height: 36px;
  padding: 0 14px 0 10px;
  border-radius: 999px;
  color: var(--text);
  font-size: 0.9rem;
  font-weight: 500;
  line-height: 1;
  cursor: default;
  flex-shrink: 0;
  white-space: nowrap;
  /* Digits update once a minute (assets/js/hero.js): tabular-nums keeps the
     segment's width from twitching as e.g. "9:59" becomes "10:00". */
  font-variant-numeric: tabular-nums;
  transition: background-color 0.2s ease;
}

.hero-status-chip + .hero-status-chip {
  background-image: linear-gradient(rgba(29, 78, 216, 0.16), rgba(29, 78, 216, 0.16));
  background-size: 1px 18px;
  background-repeat: no-repeat;
  background-position: 0 50%;
}

.hero-status-chip:hover,
.hero-status-chip.is-open,
.hero-status-chip.is-spot {
  background-color: rgba(29, 78, 216, 0.08);
}

.hero-status-chip.is-spot {
  animation: seg-ping 1.5s ease-out 1;
}

@keyframes seg-ping {
  from { box-shadow: 0 0 0 0 rgba(29, 78, 216, 0.3); }
  to { box-shadow: 0 0 0 9px rgba(29, 78, 216, 0); }
}

.hero-seg-val {
  margin-left: 0.5rem;
}

.hero-seg-sub {
  max-width: 0;
  margin-left: 0;
  overflow: hidden;
  opacity: 0;
  white-space: nowrap;
  color: var(--muted);
  font-weight: 400;
  transition: max-width 0.45s cubic-bezier(0.3, 0.8, 0.3, 1), margin-left 0.45s ease, opacity 0.3s ease;
}

.hero-status-chip:hover .hero-seg-sub,
.hero-status-chip.is-open .hero-seg-sub,
.hero-status-chip.is-spot .hero-seg-sub {
  max-width: 110px;
  margin-left: 0.35rem;
  opacity: 1;
}

.hero-distance-chip .hero-seg-sub {
  max-width: 60px;
  margin-left: 0.3rem;
  opacity: 1;
}

.hero-status-icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  color: var(--accent);
}

@media (pointer: coarse) {
  .hero-status-chip {
    height: 40px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-status-chip.is-spot {
    animation: none;
  }

  .hero-seg-sub {
    transition: none;
  }
}

/* Weather (assets/js/weather.js). The weather is a sky, drawn once and shown
   at two sizes: .hero-weather-orb (a round 28px thumbnail, the segment's
   glyph) and the wide header of the hover panel (.wx-pop-sky). Each is an
   <svg> drawn 1:1 by skyScene(), so a gradient or a star is the size it
   looks. Everything that moves is transform or opacity only, slow on
   purpose, and stops under reduced motion (the scene is then a still
   picture). The orb overhangs its 36px segment through negative block
   margins. */
.hero-weather-orb {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  margin: -6px -2px -6px -4px;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(20, 23, 28, 0.14), 0 1px 4px rgba(20, 23, 28, 0.18);
}

.sk-drift { animation: sk-drift 16s ease-in-out infinite alternate; }
.sk-tw { animation: sk-tw 3s ease-in-out infinite; }
.sk-spin { animation: sk-spin 80s linear infinite; }
.sk-glow { transform-box: fill-box; transform-origin: center; animation: sk-glow 6s ease-in-out infinite alternate; }
.sk-rain { animation: sk-rain 0.7s linear infinite; }
.sk-fall { animation: sk-fall 4s linear infinite; }
.sk-sway { animation: sk-sway 3s ease-in-out infinite alternate; }
.sk-fog { animation: sk-fog 24s ease-in-out infinite alternate; }
.sk-slide { animation: sk-slide 9s ease-in-out infinite; }
.sk-bolt { animation: sk-bolt 6s linear infinite; }
.sk-flash { opacity: 0; animation: sk-flash 6s linear infinite; }
.sk-shoot { animation: sk-shoot 9s linear infinite; }
.sk-plane { animation: sk-plane 24s linear infinite; }

@keyframes sk-drift {
  from { transform: translateX(-7px); }
  to { transform: translateX(7px); }
}
@keyframes sk-tw {
  0%, 100% { opacity: 0.25; }
  50% { opacity: 1; }
}
@keyframes sk-spin {
  to { transform: rotate(360deg); }
}
@keyframes sk-glow {
  from { opacity: 0.8; transform: scale(0.96); }
  to { opacity: 1; transform: scale(1.05); }
}
/* Each drop slides along its own slant, --dx and --dy set per scene so it
   stops at the bottom of whatever size the scene is. */
@keyframes sk-rain {
  0% { transform: translate(0, 0); opacity: 0; }
  8%, 92% { opacity: 1; }
  100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@keyframes sk-fall {
  from { transform: translateY(0); }
  to { transform: translateY(var(--dy)); }
}
@keyframes sk-sway {
  from { transform: translateX(-3px); }
  to { transform: translateX(3px); }
}
@keyframes sk-fog {
  from { transform: translateX(-12px); }
  to { transform: translateX(12px); }
}
/* A drop on the glass: it sits, runs, sits, runs, and is gone. */
@keyframes sk-slide {
  0%, 12% { transform: translateY(0); opacity: 0; }
  16% { opacity: 1; }
  34% { transform: translateY(calc(var(--dy) * 0.28)); }
  42% { transform: translateY(calc(var(--dy) * 0.3)); }
  70% { transform: translateY(calc(var(--dy) * 0.72)); opacity: 1; }
  88%, 100% { transform: translateY(var(--dy)); opacity: 0; }
}
/* Bolt and flash share one 6s loop, a double flicker, so the clouds light
   up as it strikes. */
@keyframes sk-bolt {
  0%, 7% { opacity: 0; }
  8% { opacity: 1; }
  10% { opacity: 0.15; }
  12% { opacity: 1; }
  16%, 100% { opacity: 0; }
}
@keyframes sk-flash {
  0%, 7% { opacity: 0; }
  8% { opacity: 0.42; }
  10% { opacity: 0.08; }
  12% { opacity: 0.5; }
  18%, 100% { opacity: 0; }
}
@keyframes sk-shoot {
  0%, 90% { transform: translateX(0); opacity: 0; }
  91% { opacity: 1; }
  100% { transform: translateX(46px); opacity: 0; }
}
/* A plane and its contrail cross a clear day sky now and then. */
@keyframes sk-plane {
  0%, 70% { transform: translate(0, 0); opacity: 0; }
  71%, 99% { opacity: 1; }
  100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}

/* The panel's sky is in the page even while the panel is shut (hidden by
   opacity and visibility, which do not stop animations), so its animations
   wait paused until the panel opens: about a hundred idle animations for
   something nobody can see is a waste. */
.wx-pop-sky * {
  animation-play-state: paused;
}

.hero-status-chip:hover .wx-pop-sky *,
.hero-status-chip.is-open .wx-pop-sky * {
  animation-play-state: running;
}

/* The weather panel's body (the sky header is .wx-pop-sky). */
.wx-pop-sky {
  position: relative;
  display: block;
  height: 104px;
  margin: -0.9rem -1rem 0.9rem;
  border-radius: 17px 17px 0 0;
  overflow: hidden;
  color: #fff;
}

.wx-pop-sky svg {
  display: block;
  width: 100%;
  height: 100%;
}

.wx-pop-sky.is-light {
  color: var(--text);
}

.wx-pop-city {
  position: absolute;
  top: 10px;
  left: 14px;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.88;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.3);
}

.wx-pop-sky.is-light .wx-pop-city,
.wx-pop-sky.is-light .wx-pop-now {
  text-shadow: 0 1px 6px rgba(255, 255, 255, 0.6);
}

.wx-pop-now {
  position: absolute;
  left: 14px;
  bottom: 10px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}

.wx-pop-now b {
  font-size: 2.1rem;
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.02em;
}

.wx-pop-now span {
  font-size: 0.75rem;
  font-weight: 500;
  opacity: 0.92;
}

@media (prefers-reduced-motion: reduce) {
  .hero-weather-orb *,
  .wx-pop-sky * {
    animation: none;
  }
}

/* The panel each segment opens on hover (or on tap, hero.js adds .is-open
   where there is no hover). It is absolutely positioned, so opening one
   never moves anything else. Nearly opaque white rather than blurred
   glass: the bar has backdrop-filter, which makes it a backdrop root, so a
   blur on a child would only see the bar. */
.hero-pop {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 5;
  display: block;
  width: 17.5rem;
  max-width: calc(100vw - 2 * var(--space-3));
  padding: 0.9rem 1rem 0.95rem;
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.97);
  border: 1px solid rgba(29, 78, 216, 0.16);
  box-shadow: 0 16px 36px rgba(20, 23, 28, 0.17);
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 500;
  line-height: 1.35;
  text-align: left;
  white-space: normal;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translateY(-4px) scale(0.98);
  transform-origin: 24px 0;
  transition:
    opacity 0.2s ease,
    transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
    visibility 0s linear 0.2s;
}

/* Keeps the pointer inside the chip's hover area while it crosses the gap. */
.hero-pop::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -10px;
  height: 10px;
}

.hero-status-chip:hover .hero-pop,
.hero-status-chip.is-open .hero-pop {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: none;
  transition-delay: 0s;
}

.hero-pop-k {
  display: block;
  color: var(--muted);
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.hero-pop-big + .hero-pop-k {
  margin-top: 0.8rem;
}

.hero-pop-big {
  display: block;
  margin: 0.15rem 0 0.15rem;
  font-size: 1.1rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}

.hero-pop-small {
  display: block;
  margin-bottom: 0.7rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.hero-pop-line {
  display: block;
  margin-top: 0.35rem;
  padding-top: 0.6rem;
  border-top: 1px solid rgba(29, 78, 216, 0.12);
  font-weight: 600;
}

.hero-pop-fine {
  display: block;
  margin-top: 0.4rem;
  color: var(--muted);
  font-size: 0.68rem;
  font-weight: 400;
}

.hero-pop-row {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.14rem 0;
  font-variant-numeric: tabular-nums;
}

.hero-pop-row > span:first-child {
  color: var(--muted);
}

/* Fixed visual order (clock, weather, distance) regardless of which
   chip's own async chain actually finishes first — weather.js needs a
   second network hop after location resolves (Open-Meteo), distance.js
   doesn't, so without this the distance chip would routinely beat
   weather into the DOM and the stack would reorder itself unpredictably
   between page loads. */
.hero-clock-chip {
  order: 1;
}

.hero-weather-chip {
  order: 2;
}

.hero-distance-chip {
  order: 3;
}

/* Clock character (_includes/clock-mascot.svg, driven by hero.js): an
   orange rubber-hose cartoon clock, calm on the surface and a little bit
   scheming underneath. Everything here is slow on purpose: a pendulum
   sway, a blink every six seconds, a glance to the side now and then. The
   eyes follow the cursor (hero.js sets --ex/--ey), and when the cursor
   stays close hero.js adds .is-watching: the lids settle a notch, the
   calm smile gives way to a smirk, and the gloved fingers start to tap.
   .is-hello (a polite wave, once) and .is-ding (a hop on each new minute)
   are also added by hero.js. Pivots are in the SVG's own coordinates
   (viewBox 0 0 64 76), since there is no wrapper transform in this file.
   Compact (phone) size overhangs the pill through negative block margins,
   the way the weather icon does, so the pill keeps its 36px height. */
.hero-clock-mascot {
  width: 34px;
  height: 40px;
  flex-shrink: 0;
  margin: -12px -2px -12px -5px;
  overflow: visible;
  --ex: 0px;
  --ey: 0px;
}

.wxk-sway { transform-origin: 32px 70px; animation: wxk-sway 5s ease-in-out infinite; }
.wxk-hand { transform-origin: 32px 30px; transform: rotate(var(--r, 0deg)); transition: transform 0.7s cubic-bezier(0.34, 1.56, 0.64, 1); }
.wxk-orbit { transform-origin: 32px 30px; animation: wxk-orbit 60s steps(60) infinite; }
.wxk-eyes { transform: translate(var(--ex), var(--ey)); transition: transform 0.16s ease-out; }
.wxk-glance { animation: wxk-glance 13s ease-in-out infinite; }
.wxk-lidrest { transition: transform 0.6s ease; }
.wxk-lid { animation: wxk-lid 6.4s infinite; }
.wxk-glint { opacity: 0; transform-origin: 28.4px 26.3px; animation: wxk-glint 7s ease-out infinite; }
.wxk-brow { transform-origin: 40px 17px; animation: wxk-brow 13s ease-in-out infinite; }
.wxk-smile, .wxk-smirk { transition: opacity 0.45s ease; }
.wxk-smirk { opacity: 0; }
.wxk-tap { transform-origin: 37.6px 66px; animation: wxk-tap 5.2s ease-in-out infinite; }
.wxk-armR { transform-origin: 53.4px 36px; }
.wxk-fingers { transform-origin: 38.6px 52px; }
.wxk-spark { opacity: 0; transform-origin: 55px 7px; }

.hero-clock-mascot.is-watching .wxk-lidrest { transform: translateY(1.3px); }
.hero-clock-mascot.is-watching .wxk-smile { opacity: 0; }
.hero-clock-mascot.is-watching .wxk-smirk { opacity: 1; }
.hero-clock-mascot.is-watching .wxk-fingers { animation: wxk-scheme 1.4s ease-in-out infinite; }
.hero-clock-mascot.is-hello .wxk-armR { animation: wxk-hello 2s ease-in-out both; }
.hero-clock-mascot.is-hello .wxk-armR .wxk-fingers { animation: wxk-waggle 0.5s ease-in-out 0.4s 3; }
.hero-clock-mascot.is-ding .wxk-sway { animation: wxk-hop 0.9s cubic-bezier(0.3, 0, 0.3, 1); }
.hero-clock-mascot.is-ding .wxk-spark { animation: wxk-spark 0.9s ease-out; }

@keyframes wxk-glint {
  0%, 62%, 100% { transform: scale(0.2); opacity: 0; }
  72% { transform: scale(1); opacity: 1; }
  84% { transform: scale(0.4); opacity: 0; }
}
@keyframes wxk-sway {
  0%, 100% { transform: rotate(-1.4deg); }
  50% { transform: rotate(1.4deg); }
}
/* One step per second; hero.js starts it at the current second. */
@keyframes wxk-orbit {
  to { transform: rotate(360deg); }
}
/* A slow, deliberate blink. */
@keyframes wxk-lid {
  0%, 92%, 100% { transform: translateY(0); }
  95% { transform: translateY(10.5px); }
}
/* A sly glance away and back, with the right brow lifting as it goes. */
@keyframes wxk-glance {
  0%, 40%, 60%, 100% { transform: translate(0, 0); }
  46%, 54% { transform: translate(-2.2px, 0.4px); }
}
@keyframes wxk-brow {
  0%, 40%, 60%, 100% { transform: translateY(0) rotate(0deg); }
  46%, 54% { transform: translateY(-1.6px) rotate(-5deg); }
}
/* The foot taps twice, then waits. */
@keyframes wxk-tap {
  0%, 70%, 100% { transform: rotate(0deg); }
  76% { transform: rotate(-9deg); }
  82% { transform: rotate(0deg); }
  88% { transform: rotate(-7deg); }
  94% { transform: rotate(0deg); }
}
/* Gloved fingertips tapping together. */
@keyframes wxk-scheme {
  0%, 40%, 100% { transform: rotate(0deg); }
  10%, 30% { transform: rotate(-7deg); }
  20% { transform: rotate(0deg); }
}
/* Arm swings out and up (anticlockwise, the long way round the outside),
   holds there while the glove waggles at the wrist, and comes back. */
@keyframes wxk-hello {
  0%, 100% { transform: rotate(0deg); }
  20% { transform: rotate(-192deg); }
  36% { transform: rotate(-186deg); }
  52% { transform: rotate(-192deg); }
  68% { transform: rotate(-186deg); }
  80% { transform: rotate(-190deg); }
}
@keyframes wxk-waggle {
  0%, 100% { transform: rotate(0deg); }
  25% { transform: rotate(-26deg); }
  75% { transform: rotate(26deg); }
}
@keyframes wxk-hop {
  0% { transform: translateY(0) scale(1, 1); }
  18% { transform: translateY(1px) scale(1.05, 0.93); }
  42% { transform: translateY(-5px) scale(0.97, 1.05); }
  66% { transform: translateY(0) scale(1.04, 0.95); }
  82% { transform: translateY(-1px) scale(1, 1); }
  100% { transform: translateY(0) scale(1, 1); }
}
@keyframes wxk-spark {
  0% { transform: scale(0.2); opacity: 0; }
  25% { transform: scale(1.1); opacity: 1; }
  100% { transform: scale(0.5); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-clock-mascot,
  .hero-clock-mascot * {
    animation: none;
    transition: none;
  }
}

/* Distance scene (assets/js/distance.js): "how far apart are you and me",
   drawn as a tiny map. A blue cursor arrow is YOU (the visitor, so it is
   your own pointer, in a way), an amber pin with a face is ME; a dashed
   route draws itself from one to the other, a dot rides its head, and the
   pin hops and ripples when the dot lands. (An earlier version labelled
   the two markers, but at dock size the labels are unreadable; the panel's
   last line says which is which, and the segment's "apart" says what the
   number measures.) One 4.8s loop for
   everything except the ping on YOU. Base styles are the finished picture
   (route drawn, rider and ripples hidden), so reduced motion shows a
   still map, not a blank one. The ride keyframes are sampled at equal arc
   lengths along the exact curve in distance.js (M28 41 Q60 2 89 31), so
   the dot stays at the head of the drawn line; drawing and riding are
   both linear for the same reason. It is drawn on a 116x76 grid and shown
   at 52x34 in the dock. */
.hero-distance-scene {
  width: 52px;
  height: 34px;
  margin: -9px -2px -9px -6px;
  overflow: visible;
}

.hero-distance-scene .wx {
  transform-box: fill-box;
  transform-origin: center;
}

.wxd-route {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
  animation: wxd-draw 4.8s linear infinite;
}
.wxd-ride { opacity: 0; animation: wxd-ride 4.8s linear infinite; }
.wxd-you { transform-origin: 23px 43px; animation: wxd-you 4.8s ease-in-out infinite; }
.wxd-pin { transform-origin: 92px 52px; animation: wxd-pin 4.8s ease-in-out infinite; }
.wxd-shadow { animation: wxd-shadow 4.8s ease-in-out infinite; }
.wxd-land { opacity: 0; animation: wxd-land 4.8s ease-out infinite; }
.wxd-ping { opacity: 0; animation: wxd-ping 2.4s ease-out infinite; }

@keyframes wxd-draw {
  0%, 8% { stroke-dashoffset: 1; opacity: 1; }
  52% { stroke-dashoffset: 0; }
  88% { stroke-dashoffset: 0; opacity: 1; }
  96% { stroke-dashoffset: 0; opacity: 0; }
  100% { stroke-dashoffset: 1; opacity: 0; }
}
@keyframes wxd-ride {
  0%, 7.9% { transform: translate(0, 0); opacity: 0; }
  8% { transform: translate(0, 0); opacity: 1; }
  13.5% { transform: translate(6.02px, -6.76px); }
  19% { transform: translate(12.68px, -12.88px); }
  24.5% { transform: translate(20.16px, -17.99px); }
  30% { transform: translate(28.5px, -21.41px); }
  35.5% { transform: translate(37.47px, -22.31px); }
  41% { transform: translate(46.25px, -20.27px); }
  46.5% { transform: translate(54.14px, -15.88px); }
  52% { transform: translate(61px, -10px); opacity: 1; }
  55%, 100% { transform: translate(61px, -10px); opacity: 0; }
}
/* YOU gets a small click as the signal leaves. */
@keyframes wxd-you {
  0%, 3%, 12%, 100% { transform: scale(1); }
  6% { transform: scale(0.82); }
  9% { transform: scale(1.1); }
}
@keyframes wxd-pin {
  0%, 51% { transform: translateY(0) scale(1, 1); }
  55% { transform: translateY(-5px) scale(0.94, 1.08); }
  61% { transform: translateY(0) scale(1.14, 0.8); }
  67% { transform: translateY(-1.5px) scale(0.98, 1.05); }
  73%, 100% { transform: translateY(0) scale(1, 1); }
}
@keyframes wxd-shadow {
  0%, 51%, 73%, 100% { transform: scale(1); }
  55% { transform: scale(0.6); }
  61% { transform: scale(1.2); }
}
@keyframes wxd-land {
  0%, 58% { transform: scale(0.4); opacity: 0; }
  60% { opacity: 0.9; }
  80%, 100% { transform: scale(2.6); opacity: 0; }
}
@keyframes wxd-ping {
  0% { transform: scale(1); opacity: 0.7; }
  100% { transform: scale(2.6); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-distance-scene,
  .hero-distance-scene * {
    animation: none;
  }
}

/* The dock on a phone: everything a size down so the three segments and
   their glyphs fit one row at 375px, with the labels left folded (there is
   no room to open them; the tint and the ring still go to the segment that
   has the spotlight). At 340px and under the little map goes too. These sit
   after the glyph rules above so they win on source order. */
@media (max-width: 480px) {
  .hero-status-chip {
    padding: 0 10px 0 8px;
    font-size: 0.85rem;
  }

  .hero-seg-val {
    margin-left: 0.4rem;
  }

  .hero-status-chip:hover .hero-seg-sub,
  .hero-status-chip.is-open .hero-seg-sub,
  .hero-status-chip.is-spot .hero-seg-sub,
  .hero-distance-chip .hero-seg-sub {
    max-width: 0;
    margin-left: 0;
    opacity: 0;
  }

  .hero-clock-mascot {
    width: 30px;
    height: 36px;
    margin: -10px -2px -10px -4px;
  }

  .hero-weather-orb {
    width: 26px;
    height: 26px;
  }

  .hero-distance-scene {
    width: 44px;
    height: 28px;
    margin: -8px -2px -8px -4px;
  }
}

@media (max-width: 340px) {
  .hero-distance-scene {
    display: none;
  }
}

/* .hero-status-chip-dynamic (assets/js/weather.js, assets/js/distance.js):
   shared fade-in for every .hero-status chip that isn't guaranteed to
   exist — unlike the clock (always-present markup, since Intl/Date can't
   fail), these only get appended once a real fetch succeeds, so they
   need a fade-in rather than being visible from first paint. One shared
   class now that a second chip needs the identical treatment, rather
   than repeating it per chip class. Skips the fade under reduced motion
   (added already-visible instead, in JS terms indistinguishable from a
   plain opacity:1 element the moment this class lands).
   The transition list repeats .hero-status-chip's own (the background
   tint) next to opacity: a bare `transition: opacity` here would replace
   it outright. */
.hero-status-chip-dynamic {
  opacity: 0;
  transition: opacity 0.4s ease, background-color 0.2s ease;
}

.hero-status-chip-dynamic.hero-status-chip-in {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .hero-status-chip-dynamic {
    transition: none;
  }
}

/* .hero-clock-time (assets/js/hero.js): the visitor's own local time, read
   straight off their browser's own clock/locale — no geolocation prompt,
   nothing sent anywhere, just Intl formatting the time zone the browser
   already knows. Empty until JS sets its text; a no-JS/error visitor
   still gets a correctly-shaped chip (icon + dot, no dangling text) since
   nothing about the chip's own layout depends on the span having content. */

@media (prefers-reduced-motion: reduce) {
  .blob {
    animation: none;
  }

  .scroll-cue svg {
    animation: none;
  }

  .hero-blobs {
    transition: none;
  }

  .portal-orb {
    animation: none;
  }

  .portal-orb:hover,
  .portal-orb:focus-visible {
    transform: none;
  }
}

/* The "Thanos snap" transition into /life/ (assets/js/dissolve.js) — the
   deferred fuller version of the page-corner/orb hint, built once the
   landing page itself felt done. JS adds .dissolving to <html> on click,
   fades the hero to black underneath a canvas of particles blown apart to
   the left, then navigates once the animation finishes. No JS or
   prefers-reduced-motion: the click just falls through to the plain
   <a href="/life/"> underneath, same as every other decorative effect on
   this page. */
.dissolve-canvas {
  position: fixed;
  inset: 0;
  z-index: 20;
  pointer-events: none;
}

html.dissolving,
html.dissolving body {
  background: #05070a;
}

html.dissolving .hero > * {
  transition: opacity 0.45s ease;
  opacity: 0;
}

/* .joke-bubble (assets/js/joke-easter-egg.js) — the hidden "press J"
   easter egg. Positioned in JS via inline left/top (spawns at the
   cursor, edge-clamped there), so this only sets appearance and the
   fade transition. pointer-events:none since it's pure feedback, never
   something to click or hover. */
.joke-bubble {
  position: fixed;
  z-index: 500;
  max-width: 260px;
  padding: 0.55rem 0.85rem;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.85);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(20, 23, 28, 0.1);
  box-shadow: 0 6px 20px rgba(20, 23, 28, 0.12);
  color: var(--text);
  font-size: 0.85rem;
  line-height: 1.4;
  pointer-events: none;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.25s ease, transform 0.25s ease;
}

.joke-bubble.joke-bubble-in {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .joke-bubble {
    transition: opacity 0.15s ease;
    transform: none;
  }
}

/* .joke-hint (assets/js/joke-easter-egg.js) — a one-time nudge toward
   the "press J" easter egg for desktop visitors who haven't found it,
   10s after page load. Bottom-right: the one corner nothing else on
   this page claims (.hero-copyright owns bottom-left, .scroll-cue owns
   bottom-center, .life-pull-btn sits at the right edge but vertically
   centered, not at the bottom). Same glass-pill language as
   .hero-status-chip/.joke-bubble for visual consistency. */
.joke-hint {
  position: fixed;
  z-index: 400;
  right: var(--space-3);
  bottom: var(--space-3);
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.55rem 1rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(29, 78, 216, 0.16);
  box-shadow: 0 6px 20px rgba(20, 23, 28, 0.12);
  color: var(--text);
  font-size: 0.85rem;
  pointer-events: none;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.4s ease, transform 0.4s ease;
}

.joke-hint.joke-hint-in {
  opacity: 1;
  transform: translateY(0);
}

.joke-hint-key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.4em;
  padding: 0.1em 0.5em;
  border-radius: 5px;
  background: var(--accent);
  color: #fff;
  font-family: inherit;
  font-weight: 700;
  font-size: 0.85em;
  line-height: 1.5;
}

@media (prefers-reduced-motion: reduce) {
  .joke-hint {
    transition: opacity 0.25s ease;
    transform: none;
  }
}
