/* THE WORLD — the illustration at the base of the home page.

   ⚠️ IT CHANGES NOTHING ELSE. The hero's copy, buttons, icons, eyes and boot
   sequence are untouched; this is a drawing added beneath them. It sits OUTSIDE
   .phosphor at z-index 0 — behind the content, above the field — and is
   aria-hidden, unlinked and unlabelled. Home page only.

   The brief was the Outer Wilds EFFECT (dark space with a small lit world in it)
   and explicitly not its art style. Drawn in CSS and the site's own 8x8 sprites
   rather than rendered: the README bars AI imagery as hero art, canvas is
   rejected, and this way it costs nothing to load on the page that has to be
   fastest. */

.world {
  /* ⚠️ THE WORLD IS GIVEN A BOX, AND THE BOX IS WHATEVER IS LEFT BELOW THE HERO.
     Every previous attempt tried to pick images whose terrain happened to sit
     low enough, or to crop them until it did. Both are the same mistake: they
     make "does this overlap the copy" a property of the PICTURE, so every new
     image re-opens the question and a bigger viewport breaks it again. Juan's
     screenshot was taken on a much larger display than the 1440x900 I had been
     measuring at, and at that width a 2.5:1 scene is 800px tall and swallows the
     page — the images had not changed, the viewport had.

     So the constraint moves into the layout, where it holds for any image at any
     size: the hero is centred and its block is about 195px tall below the middle
     line, so the world starts below that and runs to the bottom of the page.
     Nothing it contains can reach the copy, because it is not allowed to exist
     up there. */
  position: absolute; left: 0; right: 0; bottom: 0;
  top: calc(50vh + 205px);
  overflow: hidden;
  z-index: 0; pointer-events: none;
}

/* a distant body, the way the reference frames always have one */
.world__moon {
  position: absolute; right: 9%; top: 4%;
  width: clamp(80px, 12vw, 180px); aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(circle at 36% 32%,
    rgba(240, 168, 60, .17), rgba(240, 168, 60, .04) 55%, transparent 72%);
  box-shadow: 0 0 70px rgba(240, 168, 60, .08);
}
.world__moon::after {
  content: ""; position: absolute; inset: 0; border-radius: 50%;
  border: 1px solid rgba(240, 168, 60, .18);
}

/* the planet: one enormous circle with only its crest on screen, which is what
   makes it read as a world rather than as a hill */
.world__body {
  /* ⚠️ the horizon has to clear the CONTENT, not just the viewport. At the
     first placement the crest cut straight through the icon row — measured 111px
     above it — so the drawing was running behind the thing it is meant to sit
     under. It is pushed below the copy now. */
  position: absolute; left: 50%; top: 40%;
  width: 230vw; height: 230vw; translate: -50% 0; border-radius: 50%;
  /* ⚠️ the ground has to be LIT near the horizon or a silhouette has nothing to
     be a silhouette against — the pines were invisible until this band existed,
     and drawing them darker did nothing because they were already dark on dark.
     It falls away fast, so the foreground still reads as night. */
  background: linear-gradient(to bottom,
    rgba(240, 168, 60, .18) 0%, rgba(190, 120, 50, .10) 1.4%,
    rgba(90, 60, 30, .045) 3.4%, #0a0806 7%, #050706 16%);
  box-shadow: 0 -1px 0 rgba(240, 168, 60, .26), 0 -30px 80px rgba(240, 168, 60, .045);
}
/* the terminator — the grazing light that says sphere */
.world__rim {
  position: absolute; left: 50%; top: 40%;
  width: 230vw; height: 230vw; translate: -50% 0; border-radius: 50%;
  border-top: 1px solid rgba(255, 196, 104, .45);
  filter: blur(.5px);
}

/* --------------------------------------------------------- standing on it */

/* ⚠️ A FLAT top:% CANNOT TRACK A CIRCULAR HORIZON, and this was wrong in a way
   nothing reports. Every object was hand-placed at a percentage of the
   container, so its distance from the crest was a different number at every
   aspect ratio — and, measured against the circle .world__body is actually
   drawn with, four of them were simply hanging in the sky: the pines at 6%, 10%
   and 89% floated 32-54px above the ground they were standing on, and the two
   far sprites sat exactly ON the line rather than in front of it. The gaps
   across the row ran from -54px to +113px. It went unseen for the same reason
   the objects did — a drawing that is wrong does not throw.

   Everything is placed on the sphere now, the way a thing standing on a world
   actually is: an ANGLE around it (--a), and a DROP saying how far in front of
   the crest it stands (--d). Both are computed from the same circle the horizon
   is, so both hold at every viewport — the horizon solved exactly, the way the
   rig's crossbar was once it became a grid instead of a tuned percentage. */
.world .sit {
  position: absolute;
  left: 50%; top: calc(40% + 115vw);   /* dead centre of .world__body */
  width: 0; height: 0;
  rotate: var(--a, 0deg);
}
.world .sit > * {
  position: absolute; left: 0;
  /* ⚠️ --d MUST CARRY A UNIT. Two objects were authored `--d:0` and vanished:
     calc(115vw - 0) is invalid CSS — a plain number is not a length — so the
     whole `bottom` declaration was dropped, the element fell back to auto and
     landed on the circle's centre, 1400px below the fold. No error, no warning,
     and in source it reads as the most obviously correct value on the line. */
  bottom: calc(115vw - var(--d, 0px));   /* out to the rim, less the drop */
  translate: -50% 0;
}

/* ⚠️ BUT A PIXEL SPRITE DOES NOT TILT. A pine is a drawn silhouette, and a tree
   on a small world genuinely does stand radially — it keeps the rotation, and
   that lean is most of what makes the ground read as a sphere. A sprite is tier
   one: square, on the grid. Rotating it puts it off the grid and renders it
   soft, which is the rule the blink already follows — a pixel switches, it
   never deforms. So sprites ride the sphere's POSITION and cancel its
   ROTATION. */
.world .sit > .obj { rotate: calc(-1 * var(--a, 0deg)); }

/* ---------------------------------------------------------------- objects */

/* ⚠️ THE CELLS ARE DIRECT CHILDREN OF THE HOST. paintPlates() in os.js appends
   its 64 <i> elements straight onto the [data-sprite] element — that is the
   contract .plate and .page-mark are both written against. This file shipped
   styling a .spr wrapper that nothing ever creates, so the grid columns landed
   on an element that had none: grid-template-columns computed to 0px and every
   cell measured 0×0. The horizon, the pines and the fire all drew, so the scene
   looked finished while the six objects it is ABOUT were absent, with no error
   anywhere — the same silent-drift family as the missing chroma filter.
   width: max-content for the reason .page-mark documents: a block-level grid
   fills its container instead of hugging its columns. */
.world .obj {
  display: grid; width: max-content;
  grid-template-columns: repeat(8, var(--pp));
  grid-auto-rows: var(--pp); gap: calc(var(--pp) * .18); --pp: 3px;
}
.world .obj i { background: transparent; border-radius: .5px; }
.world .obj i.on {
  background: var(--amber);
  box-shadow: 0 0 6px rgba(240, 168, 60, .5);
}
/* depth: further away is smaller and dimmer, which is what turns a row of
   objects into a landscape */
.world .obj--far  { opacity: .40; --pp: 2px; }
.world .obj--mid  { opacity: .62; --pp: 2.6px; }
.world .obj--near { opacity: .9;  --pp: 3.6px; }

/* pines are drawn, not sprited — a tree is a silhouette, not a pixel glyph */
.world .pine {
  display: block;
  width: var(--w, 14px); height: var(--h, 34px);
  background: #040403; opacity: var(--o, .9);
  clip-path: polygon(50% 0, 74% 34%, 60% 34%, 84% 66%, 66% 66%,
                     92% 100%, 8% 100%, 34% 66%, 16% 66%, 40% 34%, 26% 34%);
}
.world .pine::after {
  content: ""; position: absolute; left: 50%; bottom: -3px;
  width: 1.5px; height: 5px; background: #040403; translate: -50% 0;
}

/* the fire: the one warm thing on the surface */
.world .fire {
  display: block;
  width: 8px; height: 8px; border-radius: 50%;
  background: radial-gradient(circle, #FFD9A0, var(--warm) 45%, transparent 72%);
  box-shadow: 0 0 26px 8px rgba(255, 107, 53, .26);
  animation: world-flicker 3.2s ease-in-out infinite alternate;
}
@keyframes world-flicker {
  from { opacity: .72; transform: scale(.94); }
  to   { opacity: 1;   transform: scale(1.08); }
}

/* the whole point of this is atmosphere, so it is the first thing to go quiet */
@media (prefers-reduced-motion: reduce) {
  .world .fire { animation: none; }
}
/* on a phone the hero needs its full height for the copy; the horizon stays,
   the scene on it does not */
@media (max-width: 700px) {
  .world { height: 34vh; }
  .world .obj, .world .pine, .world .fire { display: none; }
  .world__moon { width: 74px; top: 6%; right: 7%; }
}

/* ------------------------------------------------------- the rendered world

   It sits OVER the CSS world, which stays as the base: no-JS, slow connections
   and the moment before this decodes all still get a drawn horizon, exactly the
   way the turntable keeps its still <img> underneath.

   ⚠️ `mix-blend-mode: lighten` is the whole reason there is no visible cut. A
   rendered strip dropped on the page is a RECTANGLE — its black sits around
   #040404, the page's is #050706, and the page lays a dot field over that, so
   the two blacks can never match and the image's boundary reads as a faint box.
   `lighten` draws whichever of the two is brighter, so every pixel of the render
   darker than the page is simply never drawn. There is no seam because there is
   no edge. The gradient mask on top only softens the horizon's own glow. */
.world__art {
  /* ⚠️ object-fit: CONTAIN, not cover and not a width. Contain is the only one
     that satisfies both halves of what Juan asked for at once — the whole scene
     is visible because nothing is cropped, and it cannot overlap the copy
     because it cannot leave its box. "As much scenery as possible, never
     touching the hero" is precisely the definition of contain.
     Anchored to the bottom so the world sits on the floor of the page rather
     than floating in the middle of its box. */
  position: absolute; inset: 0;
  width: 100%; height: 100%; max-width: none;
  object-fit: contain;
  object-position: 50% 100%;
  translate: none;
}
/* the CSS scene underneath would show through the render's dark ground, so the
   drawn objects stand down when the render is present */
.world:has(.world__art) .sit { display: none; }



/* ---------------------------------------------------------- room for it ---

   The hero centres in the whole viewport, which leaves about 255px between the
   icon row and the bottom of the page — not enough for the whole scene at any
   useful size. Lifting the copy is the fourth option, and the only one that
   costs neither the picture nor the width: the band grows to roughly 330px and
   the world can be about 70% of the page instead of 55%.

   ⚠️ SCOPED WITH :has(.world), NOT JUST WRITTEN HERE. world.css is only linked
   from index.html, but the router CARRIES stylesheets across and never removes
   them — so a bare `.stage` rule in this file would start applying to /work and
   /code the moment somebody navigated there from the front door. That is the
   same both-directions trap the world itself was in. :has() ties the rule to
   the one page that actually has a world in it. */
/* ⚠️ REVERTED. Lifting the copy was me solving the wrong constraint. Juan's
   rule is that the hero stays centred in the viewport, full stop; the image is
   allowed to pass BEHIND it as long as only its sky does, because the sky is
   transparent and the stars show through it. Making room by moving his
   composition was never the trade — the trade is which images have a low enough
   middle to sit under centred copy. */
/* .phosphor:has(.world) .stage — deliberately not overridden */

/* ============================================================ HUD LAYOUTS ===
   Juan's idea: if the world is a contained frame rather than full bleed, the
   black either side of it stops being empty and becomes somewhere to put
   things — the way a helmet visor in a science-fiction film projects its
   readouts around the edge of what you are actually looking at.

   These are STUDIES, switched from the localhost-only bar. The site ships one.
   Everything here is scoped under a class on .world's parent so nothing applies
   unless a layout is actually chosen. */

/* ⚠️ 16:9 FOR CONTAINED, AND THE RATIO WAS MEASURED RATHER THAN CHOSEN. Juan
   asked which fits best. 4:3 throws away 24% of the width on five of these six
   pictures and 48% on the sixth; 16:9 throws away NOTHING on five of them,
   because they render natively at 1.75:1. The frame should cost the picture as
   little as possible, and one of these costs nearly nothing.

   The assets are also trimmed to their own content now. world-ve was 70% empty
   padding — 712px of transparent sky above a thin band of scenery — and
   object-fit: contain was dutifully fitting all that emptiness, so the picture
   drew at a third of the size it could. Every asset is its own content and
   nothing else, which is what makes the fit correct PER IMAGE rather than on
   average. */

/* ⚠️ THE ART'S GEOMETRY IS WRITTEN BY world-fit.js, NOT HERE, and that is not
   laziness. Its position depends on the hero's measured rect AND on where the
   picture itself stops being sky — two runtime facts. A `calc()` cannot see
   either, and the last attempt to express this rule in CSS was a guess at the
   hero's half-height that broke silently the moment the type wrapped.

   What stays here is the part that is genuinely static: no cropping, ever.
   `object-fit: contain` used to size the art, and it sized it from the LEFTOVER
   STRIP'S HEIGHT — a 16:9 picture in a 1440x280 strip fits by height and draws
   498px wide, so the viewport's width never entered the calculation and "full
   bleed" rendered as a ~640px stamp in the middle of the page. Width leads now,
   height follows from the picture's own ratio, and the two layouts differ in
   NOTHING but that width. */
/* ⚠️ AND `fill` IS ONLY SAFE ONCE THAT HAS ACTUALLY HAPPENED. The rule below
   states its own precondition — "the box is already the picture's own ratio" —
   and on a first visit that precondition was FALSE: the boot sequence keeps
   #stage hidden past every one of world-fit's triggers, so it bailed, no box was
   ever written, and `fill` stretched the art to the CSS box (34vh) for the whole
   session. 375x276 instead of 375x209 on a phone.
   world-fit.js now marks `.world` with `w-set` the moment it writes the geometry,
   so until then the art falls back to `contain`, which cannot distort. Written
   with four classes so it outranks the `fill` rule on specificity rather than on
   source order — order here has bitten this stylesheet before. */
.w-full .world:not(.w-set) .world__art,
.w-fit  .world:not(.w-set) .world__art { object-fit: contain; }

.w-full .world__art,
.w-fit  .world__art {
  position: absolute;
  right: auto; bottom: auto;
  max-width: none; max-height: none;
  object-fit: fill;        /* the box is already the picture's own ratio */
  translate: none;
  /* the sky fades out at the top rather than ending at a border — see the
     note in world-fit.js, which sets the distance from the measured lift.
     0px is a no-op, so an unstyled or unmeasured art is unchanged. */
  /* ⚠️ THE FADE MASK IS GONE, AND IT WAS THE SEAM ALL ALONG.
     It was added when the illustration's sky was OPAQUE: the art was a
     rectangle laid on the page, so its top edge needed softening. Keying the
     sky out made that job obsolete — the picture now ends wherever its own
     alpha says, which is along the actual silhouette of the trees and rock.

     But the mask stayed, and a mask that starts at the art's top and runs
     nearly half its height does not fade a SKY any more. It fades the top of
     everything in that band: the conifers, the rock, the glow. Juan kept
     reporting a transition that was not smooth, and every attempt to smooth it
     further — longer, eased, dithered — made the veil larger. The fix was to
     stop, because the asset had already solved it.

     Rendered side by side at 1440x900: with the mask the treetops carry a grey
     haze and dissolve mid-trunk; without it they are crisp and simply end. The
     transition is now the picture's own edge, which is the only kind that can
     look like nothing at all.

     ⚠️ IF AN UNKEYED ASSET IS EVER USED AGAIN, this has to come back — an opaque
     sky needs the softening. Everything shipped is keyed; check before assuming. */}
.w-full .stage, .w-fit .stage { padding-block: 10vh; }
/* ⚠️ THIS PAIR OUTRANKS `.stage` AND SILENTLY WON THE 320px FIX. os.css sets a
   narrow-viewport `padding-block` on `.stage`; these selectors carry two classes
   to its one, so the padding there never applied and the icon labels stayed
   under the HUD — the `align-content: start` half of the same rule DID apply,
   which is what made it look half-fixed rather than broken. Whatever os.css
   budgets for the HUD has to be repeated here or it does not happen.
   See os.css § "AT 320px THE BOTTOM ICON LABELS WENT UNDER THE HUD". */
@media (max-width: 360px) {
  .w-full .stage, .w-fit .stage { padding-block: 5vh calc(4vh + 50px); }
}

/* how much of the page width the contained layout is allowed — the ONLY thing
   that separates it from full bleed. It stops short of the edges so the rail of
   icons has real margin to stand in; nothing is cropped to achieve that. Read
   by world-fit.js and driven by the picker's width buttons. */
.w-fit { --fit-w: 72; }

/* --- the icon row in its default place ------------------------------------ */
.w-row #icons { /* unchanged — the shipped layout */ }

/* --- FLANK: three icons down each side, framing the world ------------------
   The closest thing to a visor: the readouts sit at the edge of vision and the
   thing you are looking at stays uncovered in the middle. */
.w-flank #icons {
  position: fixed; left: 0; right: 0; bottom: 0; top: 0;
  display: block; pointer-events: none;
  z-index: 3;
}
.w-flank #icons .icon {
  position: absolute; pointer-events: auto;
  width: 74px;
}
.w-flank #icons .icon:nth-child(-n+3) { left: clamp(18px, 4.2vw, 84px); }
.w-flank #icons .icon:nth-child(n+4)  { right: clamp(18px, 4.2vw, 84px); }
.w-flank #icons .icon:nth-child(1),
.w-flank #icons .icon:nth-child(4) { top: 44vh; }
.w-flank #icons .icon:nth-child(2),
.w-flank #icons .icon:nth-child(5) { top: 58vh; }
.w-flank #icons .icon:nth-child(3),
.w-flank #icons .icon:nth-child(6) { top: 72vh; }
/* a tick rule running out toward the frame, the way a callout does */
.w-flank #icons .icon::after {
  content: ""; position: absolute; top: 50%; width: clamp(14px, 3vw, 46px);
  height: 1px; background: linear-gradient(to right, rgba(240,168,60,.42), transparent);
}
.w-flank #icons .icon:nth-child(-n+3)::after { left: 100%; }
.w-flank #icons .icon:nth-child(n+4)::after  {
  right: 100%; background: linear-gradient(to left, rgba(240,168,60,.42), transparent);
}

/* --- RAIL: one column down the left, a targeting list ---------------------- */
.w-rail #icons {
  position: fixed; left: clamp(20px, 4.5vw, 92px); top: 50%;
  translate: 0 -50%;
  display: grid; gap: clamp(12px, 1.8vh, 20px);
  justify-items: start; z-index: 3;
  padding-left: 14px;
  border-left: 1px solid rgba(240, 168, 60, .22);
}
.w-rail #icons .icon { flex-direction: row; align-items: center; gap: 10px; }
.w-rail #icons .icon .spr { order: 0; }
.w-rail #icons .icon__l  { order: 1; }
/* corner ticks on the rail, so it reads as an instrument rather than a menu */
.w-rail #icons::before, .w-rail #icons::after {
  content: ""; position: absolute; left: -1px; width: 9px; height: 1px;
  background: rgba(240, 168, 60, .5);
}
.w-rail #icons::before { top: 0; }
.w-rail #icons::after  { bottom: 0; }

/* --- RING: removed Aug 30 2026, on Juan's call ----------------------------
   The lying-down composition — nature ringing all four edges, the copy in a
   transparent hole in the middle — was built, rendered in three scenes and
   judged. He does not want it. The layout, the three `world-r*` assets and the
   D/E/F ring scenes in every form came out together; the six remaining assets
   are the landscapes, A/B/C. Kept in git history rather than in the tree. */

/* ============================================================= THE GLOW ===
   Drawn, not loaded. Only the X option uses it: that illustration is a
   nature-only render with a genuinely black sky, so its key is a threshold
   rather than an inference, and the light it is missing gets put back here.
   Shape and colour are G's own, measured (see the README); the centre is moved
   to this render's horizon at 60.7% rather than G's 45.4%. */
.wglow { position: absolute; left: 0; top: 0; z-index: 0; pointer-events: none;
         mix-blend-mode: screen; display: none; }
/* ⚠️ THE GLOW IS TIED TO THE ILLUSTRATION, and :has() is what ties it.
   .wglow lives outside .phosphor so that `screen` is not isolated by the
   filter — but the router replaces .phosphor's CONTENTS and nothing else, so
   anything out here SURVIVES A NAVIGATION. The world does not: it is inside
   .phosphor and goes when the page changes. Without this rule the glow stayed
   lit on /work and /code, a horizon light for an illustration that is no longer
   on screen. Scoped to the presence of a .world, it appears and leaves with the
   picture it belongs to. */
body:has(.world) .wglow--on { display: block; background: radial-gradient(115.5% 22.4% at 50.7% 63.0%,
      rgb(255 202 82) 0.0%,
      rgb(255 179 72) 5.6%,
      rgb(255 144 58) 11.1%,
      rgb(211 108 44) 16.7%,
      rgb(150 77 31) 22.2%,
      rgb(102 52 21) 27.8%,
      rgb(67 34 14) 33.3%,
      rgb(42 21 9) 38.9%,
      rgb(26 13 5) 44.4%,
      rgb(15 8 3) 50.0%,
      rgb(9 4 2) 55.6%,
      rgb(5 2 1) 61.1%,
      rgb(3 1 1) 66.7%,
      rgb(1 1 0) 72.2%,
      rgb(1 0 0) 77.8%,
      rgb(0 0 0) 83.3%,
      rgb(0 0 0) 88.9%,
      rgb(0 0 0) 94.4%,
      rgb(0 0 0) 100%); }

/* ⚠️ THE HOME PAGE IS ONE VIEWPORT, AND IT WAS SCROLLING 166px.
   world-fit hangs the picture off the bottom on purpose when it is taller than
   the room below the copy — "losing the near foreground, never the peaks". That
   is right, but the overhang was creating SCROLLABLE OVERFLOW rather than being
   clipped: the art's box ran to 1519 in a 1353 viewport, and .wglow mirrors that
   box, so the document grew and the page scrolled to a strip of nothing.

   Clipped at the body, and scoped with :has(.world) for the reason the rest of
   this file is — .tube and .phosphor are on every page, and /work and /code
   have real content that MUST scroll. Only the page with a world in it is
   pinned to the viewport. */
/* 🔴 THE CLIP MOVED OFF THE ROOT, Aug 31 2026, AND THAT WAS A REAL BUG ON JUAN'S
   PHONE. This used to read `html:has(.world), body:has(.world) { overflow: hidden }`.
   `overflow: hidden` ON THE ROOT ELEMENT DOES NOT JUST STOP SCROLLING — IT STOPS
   PANNING WHILE ZOOMED. Zoom in on a phone (pinch, or a browser text-size /
   page-zoom setting, which plenty of people run permanently) and the layout
   viewport is now wider than what you can see. On any normal page you pan to the
   rest. Here there was nothing to pan: the headline was cut mid-word, the second
   button read "WHAT I'VE BUIL", the icon row ran off past RECORDS, and none of it
   could be reached. It looked like a broken site and the layout was never wrong —
   measured at 390px, the only element past the edge is `.field`, which is
   `position: fixed` and cannot grow the document.

   The clip is on .tube instead. It still does the job it was written for — the
   illustration's overhang and .wglow's mirrored box are absolutely positioned
   against .tube, so they are clipped exactly as before and the document never
   grows into a strip of nothing. But .tube is not the root, so the visual
   viewport can still be panned, and anything that ever does overflow stays
   reachable instead of being sealed off.

   ⚠️ `overflow: clip`, not `hidden`: clip does not create a scroll container, so
   this cannot become a nested scroller that swallows the page's own scrolling.
   .tube has no transform or filter, so it is NOT a containing block for fixed
   descendants — .hud, .index, .crt-glass and .crt-bulge are unaffected by it. */
html:has(.world), body:has(.world) { height: 100%; }
.tube:has(.world) { overflow: clip; }

/* 🔴 BUT A CLIP THAT CANNOT BE SCROLLED HIDES CONTENT, NOT JUST OVERHANG, AND IN
   LANDSCAPE IT HID THE WHOLE ICON ROW. On a phone turned sideways (844x390) the
   hero alone fills the viewport: measured, .icons ended at 431 in a 390px window,
   91px past a HUD that is itself pinned to the bottom. With `overflow: hidden`
   there is no way to reach it — KOVA, SIPFUL, FILM, CAMERA, GAMES, RECORDS and
   CODE were simply gone, which is the site's entire secondary navigation.

   Scrolling is restored only when the viewport is too short to hold the hero,
   and that costs the rule above NOTHING: the thing it was written to clip is
   `.world`, which starts at `top: calc(50vh + 205px)` — 400px down a 390px
   viewport — so at these heights the illustration is already entirely below the
   fold and there is no overhang to strip. The "one viewport, no scroll" decision
   still governs every height where the picture is actually on screen.

   ⚠️ RESTORING SCROLL ALONE IS NOT ENOUGH, and doing only that is worse than
   the bug. world-fit re-measures on scroll and writes .world's height, so a
   scrollable home page feeds back on itself: scrollHeight oscillated between
   470 and 969 depending on where you had already scrolled to, and the extra
   579px was empty — you could scroll clean past the icons into nothing. That is
   the same strip the rule above was written to kill, arrived at from the other
   direction. So the picture is taken OUT at these heights rather than left to
   grow: with no .world to measure, the document is exactly its content (470 in a
   390px viewport, 80px of scroll, ending on the icon row), and world-fit has
   nothing to fight over. Hiding the scene on small screens is what this file
   already does at `max-width: 700px`; this is the height-wise twin of it. */
@media (max-height: 560px) {
  html:has(.world), body:has(.world) { height: auto; }
  .tube:has(.world) { overflow: visible; }
  .world { display: none; }
  body:has(.world) .wglow--on { display: none; }
  /* and the scroll has to END clear of the HUD, not level with it: scrolling to
     the bottom put the icon labels 25px inside that fixed band, so the last
     thing you reach was still half-covered. Same reservation the 320px case
     makes, for the same 50px. Two classes, because .w-full/.w-fit outrank a
     bare .stage — see the note beside `.w-full .stage` above. */
  .w-full .stage, .w-fit .stage, .stage { padding-block: 4vh calc(3vh + 56px); }
}

/* ================================================== LEAVING AND ARRIVING ====
   Juan's call, Aug 31 2026: when you leave the home page the illustration
   should DROP OUT OF FRAME under its own power, rather than blink out.

   It used to blink. `.world` lives inside `.phosphor`, and the router replaces
   .phosphor's innerHTML — so the illustration was destroyed in the same frame
   the new page appeared. The tube's roll (a 7px vertical-hold slip plus static)
   sold that as a channel change, but nothing actually LEFT: the horizon was
   simply not there any more.

   Ease-IN, not ease-out, and that is the whole character of it: the world
   accelerates away rather than easing to a stop, so it reads as falling out of
   frame instead of being slid out by an animator. `translateY(100%)` is its own
   height, so it clears whatever box world-fit gave it at any viewport.

   ⚠️ THE GLOW HAS TO GO WITH IT. `.wglow` lives OUTSIDE .phosphor so it
   survives navigation, and it is switched on by `body:has(.world)` — which
   still matches while the world is mid-exit. Without this the horizon light
   stayed lit over an illustration that had already left, which is the same
   class of bug as the glow that outlived its picture on /work. */
.world { transition: transform .30s cubic-bezier(.4, 0, .75, .2), opacity .30s linear; }
.world--exit { transform: translateY(100%); opacity: .35; }
/* 🔴 THE GLOW TRAVELS WITH THE PICTURE — it does not fade out and it does not
   move at its own speed. It IS part of the illustration (the horizon light the
   scene casts), so a glow that stayed put, or dimmed in place, reads as two
   objects rather than one leaving.

   ⚠️ AND `translateY(100%)` WOULD BE WRONG HERE, which is the whole reason
   --exit-dy exists. Percentage translation resolves against the ELEMENT'S OWN
   height: the world's box is ~1002px tall and the glow's is ~218px, so the same
   `100%` moves them 1002px and 218px — the glow would crawl while the picture
   dropped, and detach from it in the first frame. nav.js measures the world and
   writes its height here, so both travel the identical number of pixels and stay
   locked together. Same curve, same duration, for the same reason. */
body:has(.world--exit) .wglow--on {
  transform: translateY(var(--exit-dy, 100%));
  opacity: .35;
  transition: transform .30s cubic-bezier(.4, 0, .75, .2), opacity .30s linear;
}
/* and back up with it on the way in, from the same measured distance */
body:has(.world--enter) .wglow--on {
  animation: glowrise .34s cubic-bezier(.2, .8, .25, 1) both;
}
@keyframes glowrise {
  from { transform: translateY(var(--exit-dy, 100%)); opacity: .35; }
  to   { transform: none; opacity: 1; }
}

/* Arriving at home, it comes back UP into frame — the same move, reversed, so
   the two directions are one gesture rather than two effects.

   🔴 AN ANIMATION, NOT A TRANSITION, AND THE FIRST VERSION STRANDED THE WORLD
   OFF SCREEN. A transition needs a start state painted and then removed, which
   means waiting a frame — and the double `requestAnimationFrame` that does the
   waiting NEVER RUNS IN A BACKGROUND TAB. Open the site, switch tabs, come back
   and the illustration was still sitting at translateY(100%) with the enter
   class never cleared: gone, permanently, with nothing thrown. Caught by
   navigating /sipful -> / and reading the rect.

   A keyframe animation has its start state built in, so there is no class to
   remove and nothing to clean up. If the class is left on the element forever
   it is harmless — the animation has ended and the element rests at its normal
   style, which is exactly where it belongs. */
.world--enter { animation: worldrise .34s cubic-bezier(.2, .8, .25, 1) both; }
@keyframes worldrise {
  from { transform: translateY(100%); opacity: .35; }
  to   { transform: none; opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  /* no travel at all: the world is simply there or not. The router already
     skips its roll under this query, so a moving illustration would be the one
     thing still animating on a page that had agreed to hold still. */
  .world, .world--exit, .world--enter,
  body:has(.world--exit) .wglow--on, body:has(.world--enter) .wglow--on {
    transition: none; animation: none; transform: none; opacity: 1;
  }
}
