/* ==========================================================================
   tinyorbit3d.com — the scene
   ==========================================================================

   Two pages share this file: the front door (index.html) and the 404. Both are
   the same thing — a starfield, a moon, and a handful of printed objects
   floating over it, each one a link. Nothing here is a component library and
   nothing is generated; it is one stylesheet for two hand-written documents.

   WHAT THIS PAGE COSTS A VISITOR. One HTML file, this file, ~145 KB of scene
   artwork, ~61 KB of two self-hosted faces, and one inline script that points
   the pupils. No framework, no analytics, no cookies, no third-party request of
   any kind — the fonts are served from this origin precisely so that stays
   true. Every asset filename carries a content hash and is cached for a year
   (see public/_headers), so a repeat visit fetches the HTML and nothing else.

   THE PAGE IS DARK-ONLY, and that is a change. It used to be a paper-and-ink
   card that swapped to a dark theme with prefers-color-scheme; there is no
   longer a light half to swap to, because the artwork is a photograph of space.
   `color-scheme: dark` is declared once, and the meta tag in both documents
   says the same thing so the scrollbar and the browser chrome agree with it.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Faces
   --------------------------------------------------------------------------

   Self-hosted rather than linked from fonts.googleapis.com. Three reasons, in
   order: it is a render-blocking cross-origin round trip on a page whose whole
   first paint is one background image; it would put a third party in front of
   every visitor to this site's front door, which nothing else here does; and
   build-site.mjs copies site/ wholesale, so it costs no build step.

   BOTH ARE VARIABLE FONTS — Google serves one file per family covering the
   whole weight axis, which is why there are two files here and not four. The
   `font-weight` RANGE is what tells the browser that; declaring a single weight
   would make it synthesise the other one instead of interpolating the real
   axis. Syne is OFL (Bonjour Monde), Jost is OFL (indestructible type); see
   site/assets/fonts/LICENSE.txt.
   -------------------------------------------------------------------------- */

@font-face {
  font-family: Syne;
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
  src: url("/assets/fonts/Syne.cfff4120.woff2") format("woff2");
}

@font-face {
  font-family: Jost;
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  src: url("/assets/fonts/Jost.235d8f89.woff2") format("woff2");
}

/* --------------------------------------------------------------------------
   Tokens
   -------------------------------------------------------------------------- */

:root {
  color-scheme: dark;

  --bg: #05060c;
  --ink: #eef1ff;
  --ink-2: #c3c9e6;
  /* The eyebrow, the label kickers and the accent. 9.9:1 on --bg. */
  --ink-blue: #9fb2ff;
  /*
   * The footer. The demo's own footer colour was #bec8eb at 50% opacity, which
   * measures 3.6:1 at 0.68rem — under AA for body text. Solid, it is 8.6:1.
   */
  --ink-3: #a9b3d6;
  --accent: #ff6a33;

  --f-display: Syne, ui-sans-serif, system-ui, -apple-system, sans-serif;
  --f-body: Jost, ui-sans-serif, system-ui, -apple-system, sans-serif;

  --edge: clamp(1.4rem, 5vw, 5rem);

  /*
   * THE MASTHEAD'S TYPE SIZE, HOISTED, because two things need it: the H1 sets
   * its font-size from it, and the envelope aligns itself under the wordmark's
   * "3D" from it. Left in the H1 rule they would drift apart on the first edit.
   *
   * The second term is the phone guard — see the note in .hero h1. It is inert
   * above ~400px: at 768 the clamp gives 46px against the division's 71.5.
   */
  --h1: min(clamp(2.4rem, 6vw, 4.2rem), calc((100vw - 2 * var(--edge)) / 9.7));

  /*
   * How far `.field` is inset from each side of the viewport. It is a token
   * because TWO coordinate systems meet on this page: the hero is placed against
   * the VIEWPORT, while every .obj is placed against the FIELD, which is capped
   * at 1680px and centred. Below 1680 they agree and the difference is invisible;
   * above it, anything that tries to line an object up with the copy is wrong by
   * exactly this much — measured as a 120px error at 1920 before it was here.
   */
  --field-inset: max(0px, calc((100vw - 1680px) / 2));

  /*
   * THE SWITCH'S BOX, HOISTED, so the envelope can be placed against it rather
   * than beside a number that happens to match today. The height is derived from
   * the shipped artwork's own 430x372, which is also what the <img> declares —
   * if the asset is ever re-cropped, this and the markup have to change together
   * or the two objects stop lining up.
   */
  --switch-w: min(15.5vw, 224px);
  --switch-h: calc(var(--switch-w) * 372 / 430);

  /*
   * How far the envelope floats off the switch's left edge.
   *
   * THE GAP IS SIZED FOR THE CHIP, NOT FOR THE ARTWORK. The address opens
   * upward and is wider than the envelope, so it overhangs by about 16px a side
   * — at a 30px gap the chip's corner was already 3px inside the switch's
   * artwork at 1200x832. Clamped rather than a flat vw because a proportional
   * gap closes too far at 820 and opens wider than the envelope is tall at 1920.
   */
  --pair-gap: clamp(2.25rem, 3.2vw, 3.5rem);
}

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

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

html {
  -webkit-text-size-adjust: 100%;
}

h1,
h2,
p {
  margin: 0;
}

body {
  margin: 0;
  min-height: 100svh;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--f-body);
  /*
   * `clip`, not `hidden`. A rotated object's bounding box can poke a fraction
   * of a pixel past the viewport and summon a horizontal scrollbar; `clip`
   * stops that without creating a scroll container, which `hidden` would — and
   * a scroll container here would clip the focus ring's halo. See .obj:focus-visible.
   */
  overflow-x: clip;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------------------
   Ground
   -------------------------------------------------------------------------- */

/*
 * An <img> rather than a background-image, so it can carry fetchpriority and
 * its own intrinsic size. It is the largest paint on the page and everything
 * else sits on top of it, so it is the one thing worth telling the browser to
 * fetch first.
 */
.scene-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 30%;
  pointer-events: none;
  /*
   * A TRANSFORM THAT PROMOTES BUT DOES NOT RESIZE.
   *
   * This used to carry `transform: scale(1.04)` — "a hair of overscan, so the
   * edges of the photograph never show" — and it was doing two jobs, only one of
   * them on purpose.
   *
   * The overscan was redundant: object-fit: cover already scales the image to
   * the smallest size that covers this box, so an edge cannot show. What the
   * transform was worth keeping for is COMPOSITING — this is the largest paint
   * on the page and it holds still while everything else scrolls past it, which
   * is exactly the case a promoted layer exists for. translateZ(0) buys that and
   * changes the geometry by nothing. Do not put a scale back.
   *
   * Why the scale had to go:
   *
   * The element is `position: fixed`, so its containing block is the initial
   * containing block — the viewport — NOT body. `body { overflow-x: clip }`
   * therefore does not clip it, and 4% of overscan on a fixed box is 2% of the
   * viewport hanging off each side with nothing to catch it. Mobile Safari
   * answers horizontal overflow by shrinking to fit: the layout viewport stays
   * at device-width while the VISUAL viewport opens to about 1.45x it, so the
   * whole page was drawn at ~69% scale hard against the left edge, with dead
   * space down the right and a screen and a half of nothing below the footer.
   * All three of those read as separate bugs and were one line.
   *
   * Anything added here that paints outside this box must be given a parent
   * that can clip it, or it will do the same thing again.
   */
  transform: translateZ(0);
}

/*
 * Three stacked radial washes: one down from the top so the hero copy has
 * something to sit on, one up from the bottom for the footer, and one around
 * the whole frame so the corners fall away. Without these the headline lands on
 * whatever star happens to be behind it.
 */
.scene-vignette {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(
      120% 80% at 50% 8%,
      rgba(5, 6, 12, 0.72) 0,
      rgba(5, 6, 12, 0) 45%
    ),
    radial-gradient(
      120% 90% at 50% 118%,
      rgba(5, 6, 12, 0.55) 0,
      rgba(5, 6, 12, 0) 40%
    ),
    radial-gradient(
      140% 120% at 50% 50%,
      rgba(5, 6, 12, 0) 55%,
      rgba(5, 6, 12, 0.6) 100%
    );
}

.scene {
  position: relative;
  z-index: 2;
  min-height: 100svh;
}

/* --------------------------------------------------------------------------
   Hero
   -------------------------------------------------------------------------- */

.hero {
  position: absolute;
  z-index: 6;
  top: clamp(2.2rem, 6vh, 5rem);
  left: var(--edge);
  max-width: 32rem;
  pointer-events: none;
}

/*
 * A scrim behind the hero, and it is not decoration.
 *
 * The objects are placed in percentages against a viewport whose height is not
 * known in advance, so which of them ends up behind the headline is a function
 * of the window. Measured at 1280x720: the olive creature puts FULLY OPAQUE
 * pixels behind two lines of the sub-paragraph. The hero paints above it and
 * the type carries a shadow, so it stays readable — but "readable because of
 * what happened to be behind it" is not a property, it is a coincidence, and it
 * is the same argument the label plates are built on.
 *
 * A soft ellipse anchored past the top-left corner: dense where the type is,
 * gone before it reaches the field, so it never reads as a panel.
 */
.hero::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -60% -45% -75% -120%;
  pointer-events: none;
  background: radial-gradient(
    60% 55% at 32% 42%,
    rgba(5, 6, 12, 0.78) 0,
    rgba(5, 6, 12, 0.55) 45%,
    rgba(5, 6, 12, 0) 78%
  );
}

/*
 * The mark — the real TinyOrbit3D logo, not a drawing of one.
 *
 * Painted as a MASK over a colour rather than dropped in as an <img>: the ink
 * is a token, so one 5.7 kB file serves any ground. On this page the ground is
 * a photograph, so it also takes a drop-shadow — a flat white glyph on a
 * starfield loses its edges wherever a bright star sits behind it.
 *
 * KEEPING THIS IS NOT DECORATION. scripts/build-logo-assets.mjs allowlists
 * mark.svg with the justification "site.css .mark::before"; that check only
 * fails on files NOT in the list, so it cannot notice a listed file that
 * stopped being referenced. Drop this rule and the site ships 5.7 kB of dead
 * artwork at a live URL forever, silently.
 */
.mark {
  display: block;
  width: 44px;
  aspect-ratio: 536 / 612;
  margin-bottom: 1.1rem;
}

.mark::before {
  content: "";
  display: block;
  width: 100%;
  height: 100%;
  background-color: var(--ink);
  -webkit-mask-image: url("/assets/brand/mark.svg");
  mask-image: url("/assets/brand/mark.svg");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
  filter: drop-shadow(0 2px 12px rgba(3, 5, 20, 0.7));
}

.eyebrow {
  font-family: var(--f-body);
  font-weight: 500;
  font-size: 0.72rem;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: var(--ink-blue);
  margin-bottom: 0.9rem;
}

/*
 * --h1 rather than a literal clamp, and the token carries the phone guard: below
 * ~400px the clamp's 6vw term is dead and every viewport would get the 2.4rem
 * FLOOR, at which "TinyOrbit3D" measures 365px inside a 330px content box. Above
 * 768px that overflow is invisible — the hero is absolutely placed with room to
 * spare — but on a phone it is real, and mobile Safari answers horizontal
 * overflow by opening the visual viewport and drawing the whole page scaled down
 * against the left edge. 9.7 is the wordmark's own measured width in ems at this
 * weight and tracking (9.50, rounded up).
 */
.hero h1 {
  font-family: var(--f-display);
  font-weight: 800;
  font-size: var(--h1);
  line-height: 0.98;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 40px rgba(3, 5, 20, 0.65);
}

/* The accented "3D", the one piece of orange on the page — it is what ties this
   masthead to the app's own accent. */
.wordmark-3d {
  color: var(--accent);
}

.hero .sub {
  margin-top: 1.15rem;
  max-width: 26rem;
  font-family: var(--f-body);
  font-size: clamp(0.92rem, 1.4vw, 1.05rem);
  line-height: 1.55;
  color: var(--ink-2);
  text-shadow: 0 1px 18px rgba(3, 5, 20, 0.8);
}

/* --------------------------------------------------------------------------
   The field of objects
   --------------------------------------------------------------------------

   `.field-nav` covers the whole viewport so its children can be placed against
   it in percentages. It MUST be pointer-events: none — otherwise this
   transparent sheet sits over everything and swallows every press.
   -------------------------------------------------------------------------- */

.field {
  position: absolute;
  /* Capped, so the objects do not sprawl into the corners of a 27-inch display.
     The cap lives in --field-inset so that anything aligning an object to the
     hero can subtract the same number. */
  inset: 0 var(--field-inset);
}

.field-nav {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/*
 * FOUR NESTED ELEMENTS, EACH OWNING EXACTLY ONE TRANSFORM. This is the whole
 * answer to "the rotation and the float already occupy transform":
 *
 *   .obj        position, entrance
 *   .obj-art    rotate(--rot)      static
 *   .obj-float  translateY         infinite animation
 *   .obj-scale  scale()            hover / focus
 *
 * Collapse any two of them and one effect silently cancels another.
 */
.obj {
  position: absolute;
  width: var(--w);
  display: block;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-decoration: none;
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  -webkit-tap-highlight-color: transparent;
}

.obj-art {
  display: block;
  transform: rotate(var(--rot));
  filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.45));
}

.obj-float {
  display: block;
  position: relative;
}

.obj-scale {
  display: block;
  position: relative;
  z-index: 0;
  transform: scale(1);
  /* Grows from near the feet, so the gap down to the label stays constant. */
  transform-origin: 50% 78%;
  transition: transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

.obj img,
.obj .switch-art {
  display: block;
  width: 100%;
  height: auto;
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
}

/*
 * .o-emerald is a BOX now; this is the control inside it. Everything about
 * PLACEMENT stays on the box, everything about BEHAVIOUR — hover-grow, halo,
 * label states, focus ring — reaches this through the selector lists above or
 * through :hover propagation. The mobile grid needs no new rule at all: the box
 * is still the grid item and this fills it.
 *
 * Why the split exists at all: two of his five replies carry a real <a href>,
 * and a link may not live inside a button. See the comment on .o-emerald in
 * index.html.
 */
.obj-press {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* His plate holds one line now that the kicker is gone. */
.o-emerald .label .n {
  margin-top: 0;
}

/*
 * Raised over .hero and .foot, which both sit at 6 — and, in the phone grid,
 * over the purple creature, which comes after him in DOM order and would
 * otherwise paint on top of his open balloon.
 */
.obj.is-talking {
  z-index: 7;
}

/*
 * Restating the UA default. Not belt-and-braces: it is the only thing between
 * an engine too old to run this page's script and five paragraphs of raw shout
 * text painted at the foot of the document.
 */
template {
  display: none;
}

/* The halo behind a hovered object. Opacity only — animating a filter would
   cost a repaint of the artwork on every frame. */
.obj-scale::before {
  content: "";
  position: absolute;
  inset: -14%;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(
    50% 50% at 50% 55%,
    rgba(var(--glow), 0.22),
    transparent 70%
  );
  opacity: 0;
  transition: opacity 220ms ease;
}

.obj:hover .obj-scale::before,
.obj:focus-visible .obj-scale::before,
.obj-press:focus-visible .obj-scale::before {
  opacity: 1;
}

/* --------------------------------------------------------------------------
   The switch is lit
   --------------------------------------------------------------------------

   It is the only object on the page that glows without being touched, and that
   is the point: the four creatures are where TinyOrbit3D is, the switch is what
   the site DOES, and in the top corner — away from the row the eye reads along
   — it needs something other than position to say so.

   TWO LAYERS, BECAUSE ONE CANNOT DO BOTH JOBS. drop-shadow follows the alpha of
   the artwork, so it hugs the housing and the stem and reads as the object
   emitting light; a radial gradient behind it cannot do that at any radius. The
   gradient in turn fills the space the silhouette leaves — the aura the shadow
   is far too tight to give. Together they read as one source.

   ::after, NOT ::before. ::before is the shared hover halo and its opacity is
   transitioned on :hover; an animation on the same property would win over that
   transition permanently and the switch would stop answering the cursor.
   -------------------------------------------------------------------------- */

.o-switch .obj-art {
  filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.45))
    drop-shadow(0 0 10px rgba(120, 176, 245, 0.55))
    drop-shadow(0 0 30px rgba(91, 155, 232, 0.4));
}

.o-switch .obj-scale::after {
  content: "";
  position: absolute;
  inset: -26%;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(
    50% 50% at 50% 52%,
    rgba(var(--glow), 0.4) 0,
    rgba(var(--glow), 0.14) 42%,
    transparent 72%
  );
  opacity: 0.85;
}

/* The lit dust under the two objects that sit lowest, so they read as hovering
   over the surface rather than pasted onto the sky. */
.dust {
  position: absolute;
  z-index: -1;
  left: 50%;
  bottom: -4%;
  width: 78%;
  height: 26%;
  transform: translateX(-50%);
  background: radial-gradient(
    ellipse at center,
    rgba(196, 190, 178, 0.3) 0,
    rgba(196, 190, 178, 0) 70%
  );
  filter: blur(7px);
  pointer-events: none;
}

/* --------------------------------------------------------------------------
   Eyes
   -------------------------------------------------------------------------- */

.eye {
  position: absolute;
  left: var(--ex);
  top: var(--ey);
  width: var(--ew);
  aspect-ratio: 1 / 1;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  overflow: hidden;
  pointer-events: none;
}

.pupil {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 40%;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  will-change: transform;
  background: radial-gradient(
    circle at 36% 30%,
    #55555f 0,
    #1b1b24 38%,
    #050508 100%
  );
  box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.6);
}

/* The one pale iris in the set. */
.pupil.silver {
  width: 46%;
  background: radial-gradient(circle at 38% 32%, #33333a 0, #0a0a0d 60%);
  box-shadow:
    inset 0 0 0 12% #c7ccd6,
    inset 0 -2px 4px rgba(0, 0, 0, 0.6);
}

.pupil::after {
  content: "";
  position: absolute;
  left: 26%;
  top: 20%;
  width: 30%;
  height: 30%;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    rgba(255, 255, 255, 0.95) 0,
    rgba(255, 255, 255, 0) 70%
  );
}

/* --------------------------------------------------------------------------
   Labels
   --------------------------------------------------------------------------

   A PLATE, NOT A SOFT SCRIM, and the reason is measured. The starfield includes
   the moon's lit limb, where a label can land on roughly #b5aca0; the kicker
   colour on that is 1.1:1, and it is 10px type. A radial scrim that fades to
   zero cannot guarantee coverage of a variable-width string. Over this plate
   the kicker measures 8.6:1 and the name 11:1 against any backdrop.

   It is a direct child of .obj, NOT of .obj-scale — inside the scaler it would
   grow on hover and the gap under the object would breathe.
   -------------------------------------------------------------------------- */

.label {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 0.6rem;
  width: max-content;
  max-width: min(17ch, 42vw);
  padding: 0.32rem 0.6rem;
  border: 1px solid transparent;
  border-radius: 10px;
  background: rgba(5, 6, 12, 0.72);
  text-align: center;
  text-wrap: balance;
  transition:
    background-color 200ms ease,
    border-color 200ms ease;
}

@supports (backdrop-filter: blur(6px)) or (-webkit-backdrop-filter: blur(6px)) {
  .label {
    background: rgba(5, 6, 12, 0.46);
    -webkit-backdrop-filter: blur(6px) saturate(0.8);
    backdrop-filter: blur(6px) saturate(0.8);
  }
}

/* Where it goes. */
.label .k {
  display: block;
  font-family: var(--f-body);
  font-weight: 500;
  font-size: 0.625rem;
  line-height: 1.25;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-blue);
  text-shadow: 0 1px 2px rgba(5, 6, 12, 0.95);
}

/* What it is. */
.label .n {
  display: block;
  margin-top: 0.18rem;
  font-family: var(--f-display);
  font-weight: 600;
  font-size: 0.8125rem;
  line-height: 1.28;
  color: var(--ink);
  text-shadow: 0 1px 2px rgba(5, 6, 12, 0.95);
}

.obj:hover .label,
.obj:focus-visible .label,
.obj-press:focus-visible .label {
  background: rgba(5, 6, 12, 0.84);
  border-color: rgba(159, 178, 255, 0.28);
}

.obj:hover .label .n,
.obj:focus-visible .label .n,
.obj-press:focus-visible .label .n {
  color: #ffffff;
}

.obj:hover .label .k,
.obj:focus-visible .label .k,
.obj-press:focus-visible .label .k {
  color: #c3cbff;
}

/* The label is the smallest interactive surface on the page; on touch it has to
   clear 44px on its own. */
@media (pointer: coarse) {
  .label {
    padding-block: 0.5rem;
  }
}

/* --------------------------------------------------------------------------
   Hover and focus
   -------------------------------------------------------------------------- */

@media (hover: hover) {
  .obj:hover .obj-scale {
    transform: scale(1.07);
  }

  .obj:active .obj-scale {
    transform: scale(1.02);
    transition-duration: 90ms;
  }
}

/*
 * OUTSIDE the hover gate, deliberately. A keyboard user has no cursor, so no
 * eye-tracking feedback and no hover growth — on a page whose entire affordance
 * vocabulary is hover, folding these two rules together would leave keyboard
 * focus with nothing but the ring.
 */
.obj:focus-visible .obj-scale,
.obj-press:focus-visible .obj-scale {
  transform: scale(1.07);
}

/*
 * TWO-TONE, because a single-colour ring drawn over photographic artwork has no
 * guaranteed contrast anywhere along its perimeter. The outer box-shadow paints
 * a dark halo BEHIND the outline, so the pair is legible on star, moon or sky.
 * It surrounds art and label together, which is right — that whole box is the
 * control.
 */
.obj:focus-visible,
.obj-press:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
  border-radius: 12px;
  box-shadow: 0 0 0 7px rgba(5, 6, 12, 0.9);
}

/* --------------------------------------------------------------------------
   The emerald's reply
   -------------------------------------------------------------------------- */

/*
 * A child of the <button>, not of the float or the scale: it must not bob and
 * it must not grow. It stays put above the creature's head while the creature
 * drifts underneath it.
 */
.say {
  position: absolute;
  bottom: 100%;
  left: 50%;
  margin-bottom: 0.6rem;
  width: max-content;
  /*
   * --say-room is the distance from his CENTRE to the nearer viewport edge; the
   * chip is centre-anchored, so it may be twice that less a little air. Getting
   * this wrong fails SILENTLY — body is overflow-x: clip, so an over-wide chip
   * does not summon a scrollbar, it amputates the last word.
   */
  max-width: min(
    var(--say-cap, 20ch),
    calc(2 * (var(--say-room, 45vw) - 0.4rem))
  );
  /* Last-resort guard so no single long token can escape the box. */
  overflow-wrap: break-word;
  /*
   * The balloon opens UPWARD, so when he is pressed near the top of the scroll
   * position the script's `scrollIntoView({block:'nearest'})` has to bring it
   * back — and `nearest` scrolls the MINIMUM distance, which lands it flush
   * against the viewport edge, with no air and partly under iOS's URL bar.
   * A scroll margin is the supported way to ask that inset for; measured at
   * 375px, without it the chip settles at exactly y=0.
   *
   * 1.5rem rather than 1rem because the shouted voices carry `rotate: -1.4deg`,
   * and a scroll margin is applied to the BORDER BOX while what you see is the
   * rotated bounding box — about 9px taller at the corner. 1rem measured as a
   * 7px visual gap; this measures as 15px.
   */
  scroll-margin-block: 1.5rem;
  /* Above the neighbours it can cross once it grows to four lines. */
  z-index: 3;
  padding: 0.45rem 0.8rem;
  border: 1px solid rgba(159, 178, 255, 0.3);
  border-radius: 999px;
  background: rgba(9, 12, 22, 0.94);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
  font-family: var(--f-body);
  font-size: 0.8125rem;
  line-height: 1.35;
  color: var(--ink);
  text-align: center;
  transform: translate(-50%, 6px) scale(0.96);
  opacity: 0;
  visibility: hidden;
  /*
   * Closed, it cannot swallow a press. OPEN, IT MUST TAKE ONE — the links inside
   * it are otherwise unclickable, and on a phone the balloon lands over the
   * purple creature, where a fall-through press would navigate to Instagram
   * instead of putting the balloon away. See .say.is-on.
   */
  pointer-events: none;
  transition:
    opacity 160ms ease,
    transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
    visibility 0s 180ms;
}

.say.is-on {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0) scale(1);
  transition-delay: 0s, 0s, 0s;
  pointer-events: auto;
}

/* --------------------------------------------------------------------------
   The escalation
   --------------------------------------------------------------------------
   Three voices, because the reply escalates in three steps and the type has to
   escalate with it. A PILL CANNOT HOLD FOUR LINES — a 999px radius on a 120px
   box is two useless lozenge ends — so the shouted states take a rectangle.

   Stages 1 and 2 are the chip exactly as it was: unstyled, untilted, the
   deadpan baseline the other three escalate away from. That is why there is no
   fourth "calm" class.

   NO ORANGE. --accent is the wordmark's "3D" and nothing else on this page, and
   it only measures 6.4:1 on this ground anyway. The escalation is carried
   entirely by SIZE, WEIGHT and MOTION — same voice, louder.
   -------------------------------------------------------------------------- */

/* The tail: a square rotated 45° showing two faces, so the balloon points at
   the head it came from. `inherit` keeps it in step with whichever voice the
   chip is wearing. */
.say::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  width: 13px;
  height: 13px;
  margin-top: -7px;
  transform: translateX(-50%) rotate(45deg);
  background: inherit;
  border: 1px solid;
  border-color: inherit;
  border-top-width: 0;
  border-left-width: 0;
  border-bottom-right-radius: 3px;
}

.say--loud,
.say--yell {
  --say-cap: 22rem;
  font-family: var(--f-display);
  font-weight: 800;
  border-color: rgba(159, 178, 255, 0.42);
  text-shadow: 0 2px 18px rgba(3, 5, 20, 0.9);
}

.say--loud {
  font-size: clamp(1rem, 2.4vw, 1.3rem);
  line-height: 1.18;
  letter-spacing: -0.005em;
  padding: 0.6rem 0.95rem;
  border-radius: 16px;
  rotate: -0.8deg;
}

.say--yell {
  /*
   * THE ONE KNOB. Every size in the composition is a multiple of it, so the
   * whole shout retunes from this line. Measured against the widest thing in it
   * — "booooooost", TEN glyphs — inside the narrowest chip each breakpoint
   * allows.
   *
   * THE FLOOR CAME DOWN WHEN THE CEILING WENT UP, which looks backwards and is
   * not. `.arc` is white-space: nowrap, so the chip's true minimum width is that
   * one word — 8.78em of it, condense included — and a floor is the one part of
   * a clamp that can hold a word WIDER than the room it has. The old 1.2rem did
   * exactly that: at 320px it put the last two letters 22px past the right edge
   * of the viewport, where `overflow-x: clip` ate them silently. 0.92rem is the
   * largest floor the 320px chip can actually hold; every viewport wide enough
   * to matter is on the 4.4vw ramp and never sees it.
   */
  --yell: clamp(0.92rem, 4.4vw, 2.75rem);
  /* Floor for the small lines: at 320px a 0.44 multiple of --yell is 6.5px,
     which is not type, it is texture. */
  --yell-min: 0.78rem;

  /*
   * THE CAP IS DERIVED, NOT PICKED. A flat --say-cap is a limit this stage
   * quietly overruns: `.arc` cannot wrap, min-content beats max-width, and the
   * box grows past the cap with the shout hanging out over the rounded corner.
   * A number that reads like a guarantee stops being one exactly where it
   * matters, so it tracks the type instead — 8.93em is the measured max-content
   * of the whole composition per em of --yell (8.78 of it the shouted word),
   * and 2.2rem is the padding and the two borders. Rounded UP to 8.95, because
   * the failure is one-sided.
   */
  --say-cap: calc(var(--yell) * 8.95 + 2.2rem);

  /*
   * CONDENSED AND EXTENDED, because BIGGER ALONE JUST MAKES IT FATTER.
   *
   * Syne ExtraBold averages ~0.8em of advance per glyph, so "booooooost" — ten
   * glyphs — is eight ems wide however large it is set. At the old 2.4rem the
   * shout measured 342x197: half again as wide as it was tall, which is the
   * proportion of a sign, not of shouting. Raising --yell on its own grows the
   * word sideways into --say-cap, and the arc is `white-space: nowrap`, so what
   * waits past the cap is AMPUTATION rather than a second line.
   *
   * So the type is squeezed on X and pulled on Y. 0.9/1.26 is far enough to
   * read as poster lettering and near enough that the counters of the o's stay
   * open at 320px.
   *
   * WHERE THE SCALE IS APPLIED IS NOT ARBITRARY. Individual transform
   * properties compose as translate · rotate · scale · transform, so a
   * `transform` lands INNERMOST: the box is stretched first and tilted after,
   * as a rigid body. Leave it on .yl-peak and the arc's per-letter `rotate`
   * happens inside an already-stretched parent, which is a SHEAR — every letter
   * of the shouted word leans as if it had been italicised by accident. The
   * lines carry it; the peak hands it down to its own letters instead.
   */
  --sx: 0.9;
  --sy: 1.26;

  font-size: var(--yell);
  /*
   * THE BLOCK'S HEIGHT IS THE PARENT'S STRUT, not the children's. Every .yl is
   * an inline-block, so each line box is at least var(--yell) x this number
   * whatever the line inside it is set at — four lines at the old 1.08 came to
   * 166px of the measured 197. This number is the line spacing of the whole
   * composition, and it is what actually makes the shout tall.
   */
  line-height: 1.34;
  padding: 0.9rem 1rem 1rem;
  border-radius: 20px;
  rotate: -1.4deg;

  /* Do NOT add overflow: hidden. The arc's risen letters and the lines' rotated
     corners live inside the padding by design; clipping amputates them. */
}

/* --------------------------------------------------------------------------
   The shout, line by line
   --------------------------------------------------------------------------
   ORGANIC HERE MEANS HAND-SET, NOT RANDOMISED, and that is structural rather
   than taste. `transform` does not apply to a non-replaced inline box, so a
   split letter must be inline-block, which BLOCKIFIES it — and some engines
   separate blockified boxes with whitespace when flattening a subtree into an
   accessible name or a live-region string. A per-letter phrase can therefore be
   announced spelled out. The repair is aria-hidden plus a hidden twin, and a
   focusable link may never sit inside one. So the split is scoped to ONE
   non-focusable word per stage, and the link is always a plain, unsplit anchor
   on its own line.

   The breaks are <br>. Never make .yl display: block and never make .say a flex
   or grid container: both blockify the children and reintroduce exactly the
   boundary this avoids.

   THREE EFFECTS, THREE SEPARATE PROPERTIES, NO EXTRA WRAPPER: the nudge is
   `left` (position, not transform), the tilt is `rotate:`, and the entrance
   owns `translate:`/`scale:`. Where the individual properties are unsupported
   the type simply sits straight — the fallback is plain text, not broken text.
   -------------------------------------------------------------------------- */

.yl {
  display: inline-block;
  position: relative;
  left: var(--dx, 0);
  rotate: var(--r, 0deg);
}

.say--yell .yl {
  font-size: max(var(--yell-min), calc(var(--yell) * var(--s, 1)));
  line-height: 1.06;
  transform: scale(var(--sx), var(--sy));
}

/* The run-up. Small, tracked out, leaning back into the sentence. The tracking
   is POSITIVE and the condense is not: at 0.44 of --yell this line is nine
   parts texture to one part word, and letting it close up would lose the
   contrast the peak is measured against. */
.say--yell .yl-pre {
  --s: 0.44;
  --r: -4.5deg;
  --dx: -0.45em;
  letter-spacing: 0.055em;
}

/* The wind-up. */
.say--yell .yl-rise {
  --s: 0.72;
  --r: 2deg;
  --dx: -0.1em;
}

/* The peak. `left` does not grow max-content, so --dx stays small on the widest
   line or it leans into the padding.

   NO TRANSFORM HERE — see the note in .say--yell. Its letters take the stretch
   themselves so that each one is stretched and THEN fanned, rather than fanned
   inside a stretched box, which shears them. */
.say--yell .yl-peak {
  --s: 1;
  --r: 1.5deg;
  --dx: 0.05em;
  letter-spacing: -0.01em;
  transform: none;
}

/* The resolve, and the line that always carries the link. Held at a normal
   reading size on purpose, with its own higher floor: an underline under 38px
   of Syne 800 is not a link cue, it is a slab, and a 9px link is not a target.

   IT IS ALSO THE ONE LINE THAT IS NOT CONDENSED. Everything above it is a
   drawn word; this is the only one anybody has to actually read, and it holds
   the anchor whose underline offset and hit box are both set in em. Squeezing
   type nobody reads is a joke; squeezing the link is a legibility bill. */
.say--yell .yl-tail {
  --s: 0.48;
  --r: -2.5deg;
  --dx: 0.3em;
  margin-top: 0.14em;
  font-size: max(0.9375rem, calc(var(--yell) * 0.48));
  transform: none;
}

/* The one word that gets emphasis without getting bent. */
.shout {
  font-size: 1.2em;
  font-weight: 800;
}

/* --------------------------------------------------------------------------
   The arc
   --------------------------------------------------------------------------
   THE CURVE IS COMPUTED, NOT TABULATED, and the reason is concrete: three
   separate reviews of this feature counted "booooooost" as six or nine letters.
   It is TEN. A hard-coded nth-child(1..9) table leaves the final `t` flat and
   peaks the bow on the wrong glyph, and nothing tells you.

   The script writes two numbers on each letter — --i, its index, and --n, the
   last index. --t runs 0..1 across the word; --bow is a parabola that is 0 at
   both ends and 1 in the middle, which makes a held run read as a word being
   STRETCHED UPWARD rather than fanned. One rule serves any word of any length,
   so "NOW!!" gets its own correct curve instead of the first five values of
   somebody else's.

   --ar/--ay rather than --r/--dy: --r is already inherited from .yl-peak, and a
   name collision would silently rotate every letter by the line's angle.
   -------------------------------------------------------------------------- */

.arc {
  display: inline-block;
  /* On the ARC, not the line: its letters are separate inline-blocks and a line
     break can land BETWEEN TWO O's. Scoping it here leaves the rest of the line
     free to wrap. */
  white-space: nowrap;
}

.arc > span {
  --t: calc(var(--i) / var(--n));
  --bow: calc(4 * var(--t) * (1 - var(--t)));
  display: inline-block;
  translate: 0 calc(var(--lift, -0.11em) * var(--bow));
  rotate: calc((var(--t) - 0.5) * var(--fan, 19deg));
  transform-origin: 50% 100%;
}

/*
 * The shout's condense, applied one letter at a time. It has to live down here
 * rather than on .yl-peak or the fan above shears every glyph — the long note
 * in .say--yell has the composition order.
 *
 * The origin is already 50% 100% for the fan, which is the right pivot for this
 * too: the letters grow UP off the baseline rather than opening a gap under it.
 */
.say--yell .arc > span {
  transform: scale(var(--sx), var(--sy));
}

/*
 * scaleX narrows the GLYPH and not its ADVANCE, so without this the word is
 * drawn thin inside its original width and reads as loosely tracked rather than
 * condensed. 0.8em is Syne ExtraBold's average advance, so `(sx - 1) * 0.8em`
 * is exactly the width each letter stopped using; the extra -0.01em is the
 * peak's own tracking, which this declaration replaces.
 */
.say--yell .arc {
  letter-spacing: calc((var(--sx) - 1) * 0.8em - 0.01em);
}

/* --------------------------------------------------------------------------
   The link inside a reply
   --------------------------------------------------------------------------
   THE UNDERLINE IS REQUIRED, NOT DECORATIVE, and the number is why. Against the
   chip's real composited ground the blue measures 9.0:1, which is fine on its
   own — but the LINK AGAINST ITS OWN SENTENCE is 1.81:1, well under the 3:1
   that would let hue carry the distinction alone. A UA hairline under this type
   size looks like a rendering bug, so the stroke is set in em: clear of the
   descenders, and it scales with whichever line it lands on.
   -------------------------------------------------------------------------- */

.say-link {
  color: var(--ink-blue);
  text-decoration: underline;
  text-decoration-color: rgba(159, 178, 255, 0.7);
  text-decoration-thickness: 0.075em;
  text-underline-offset: 0.16em;
  text-decoration-skip-ink: auto;
  border-radius: 6px;
  transition:
    color 160ms ease,
    text-decoration-color 160ms ease;
}

/* Hover AND focus-visible together, the same pairing .obj uses and for the same
   reason: a keyboard user has no cursor and would otherwise get the ring alone. */
.say-link:hover,
.say-link:focus-visible {
  color: #d6dcff;
  text-decoration-color: #d6dcff;
  text-decoration-thickness: 0.1em;
}

.say-link:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px rgba(5, 6, 12, 0.92);
}

/* Padding on an inline box does not move the line, but it IS hit-tested — tap
   height at zero layout cost. */
@media (pointer: coarse) {
  .say-link {
    padding-block: 0.5rem;
  }
}

/* --------------------------------------------------------------------------
   How hard he reacts
   -------------------------------------------------------------------------- */

.o-emerald {
  --amp: 1;
}
.o-emerald[data-stage="2"] {
  --amp: 1.15;
}
.o-emerald[data-stage="3"] {
  --amp: 1.4;
}
.o-emerald[data-stage="4"] {
  --amp: 1.25;
}
.o-emerald[data-stage="5"] {
  --amp: 1.7;
}

/* He lights up while he is talking, without being hovered. */
.o-emerald.is-talking .obj-scale::before {
  opacity: 1;
}

/* Wide-eyed by the end. His pupil's `transform` is rewritten every frame by the
   eye loop, so this uses the individual `scale` property, which composes rather
   than fights — and the loop re-measures the pupil each frame, so his travel
   limit corrects itself for free. */
.o-emerald .pupil {
  transition: scale 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.o-emerald[data-stage="4"] .pupil,
.o-emerald[data-stage="5"] .pupil {
  scale: 1.22;
}

/* --------------------------------------------------------------------------
   The envelope, and the address it copies
   --------------------------------------------------------------------------

   The one control on this page that neither navigates nor talks back: it puts a
   string on your clipboard. Everything below is built so that the string is
   READABLE whether or not that works — the reveal is pure CSS, so it survives
   JavaScript being off, slow, or refused permission by the clipboard.
   -------------------------------------------------------------------------- */

/* His plate holds one line, like the emerald's. */
.o-envelope .label .n {
  margin-top: 0;
}

/*
 * THE REVEAL IS CSS, NOT SCRIPT. Hover, focus and the copied state each show the
 * chip; none of them needs a class from JavaScript.
 *
 * `:focus-visible` is in the list for the same reason .obj's hover-grow keeps it
 * and OUTSIDE any `hover: hover` gate: on a page whose whole affordance
 * vocabulary is hover, a keyboard user would otherwise get the focus ring and no
 * address. The sibling combinator works because the chip FOLLOWS the button —
 * the same adjacency the emerald depends on. Do not reorder them.
 *
 * pointer-events come back with the reveal so the cursor can travel from the
 * envelope onto the chip without :hover flickering off underneath it.
 */
.o-envelope:hover .say,
.o-envelope .obj-press:focus-visible ~ .say,
.o-envelope[data-copy] .say {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0) scale(1);
  transition-delay: 0s, 0s, 0s;
  pointer-events: auto;
}

.say--mail {
  /*
   * 30ch, not the 20ch default: the address is 23 characters and the default cap
   * would wrap it. `.mail-addr` is nowrap as well, because `overflow-wrap:
   * break-word` on .say would otherwise break an email address across a line,
   * and half an address that looks like a whole one is worse than a wide chip.
   */
  --say-cap: 30ch;
  font-family: var(--f-body);
  letter-spacing: 0.01em;
}

.mail-addr {
  white-space: nowrap;
}

/* Hidden by default and swapped in by [data-copy] below. They live in the markup
   rather than being written by the script so the live region never has to be
   built mid-announcement. */
.mail-done,
.mail-fail {
  display: none;
}

.o-envelope[data-copy="done"] .mail-addr {
  display: none;
}

.o-envelope[data-copy="done"] .mail-done {
  display: inline-block;
  font-family: var(--f-display);
  font-weight: 800;
  font-size: 1.05rem;
  letter-spacing: -0.01em;
}

/* The failure state KEEPS the address on screen and adds a line under it. The
   whole point of the fallback is that you can still get the address; hiding it
   to show an apology would be exactly backwards. */
.o-envelope[data-copy="fail"] .mail-fail {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-blue);
}

/* Selected text inside the chip, for the fallback path — the UA default can land
   on a blue that vanishes against this ground. */
.say--mail ::selection {
  background: rgba(159, 178, 255, 0.85);
  color: #05060c;
}

/* --------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------- */

.foot {
  position: absolute;
  z-index: 6;
  left: var(--edge);
  bottom: clamp(1rem, 3vh, 2rem);
  /* Or it sits under the home indicator, given viewport-fit=cover. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  font-family: var(--f-body);
  font-size: 0.72rem;
  line-height: 1.7;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-3);
  pointer-events: none;
}

.foot a {
  color: inherit;
  pointer-events: auto;
}

/*
 * <small> is used for its MEANING — the spec names copyright notices as the
 * example of side comment it is for — and not for its size. The UA default
 * would take an already-small, tracked-out, uppercase line down to about 9px,
 * so the size is taken straight back and only the colour steps behind: 7.0:1
 * on --bg against --ink-3's 8.6:1, still clear of AA at this size.
 */
.foot small {
  display: block;
  font-size: inherit;
  color: #8e98bd;
  /* It is the one line here long enough to wrap on a phone, and uppercase at
     0.16em it wraps badly — "ALL RIGHTS / RESERVED." reads as a mistake.
     `balance` splits it evenly instead, and where it is unsupported the line
     simply wraps as it would have. `display: block` is what makes it a wrapping
     context at all, and is also why there is no <br> before it in the markup. */
  text-wrap: balance;
}

/* --------------------------------------------------------------------------
   Placement
   --------------------------------------------------------------------------

   Three modes, not four. 1280x720 and 1440x900 are both "wide", and only one of
   them can hold the deep composition — width alone does not tell you which you
   have, so the deep mode is gated on HEIGHT as well. Without that gate a 720px
   laptop pushes the purple creature's label through the footer.

   Widths use min(vw, px) so everything scales together below 1440px and holds
   its size above it, with the gaps growing instead.
   -------------------------------------------------------------------------- */

/*
 * DEFAULTS FIRST, TUNING SECOND. These four are the fallbacks each object may
 * override, and custom properties obey the cascade like any other property: at
 * equal specificity the LAST declaration wins. This block used to sit BELOW the
 * five per-object blocks, which silently voided every one of their --fdur, --fy
 * and --fdelay values and the switch's blue --glow — five objects tuned to
 * drift at different speeds all bobbed in lockstep at 7s/10px/0s, and every
 * halo was the same lavender. Measured in the browser before the move.
 */
.obj {
  --glow: 159, 178, 255;
  --fdur: 7s;
  --fy: 10px;
  --fdelay: 0s;
}

.o-switch {
  --w: var(--switch-w);
  --rot: -3deg;
  --glow: 91, 155, 232;
}
.o-olive {
  --w: min(12.8vw, 184px);
  --rot: 3deg;
  --fdur: 6.2s;
  --fy: 16px;
  --fdelay: 1.1s;
  --ex: 63%;
  --ey: 39%;
  --ew: 39%;
}
.o-purple {
  --w: min(15.7vw, 226px);
  --rot: -2deg;
  --fdur: 7.5s;
  --fy: 9px;
  --fdelay: 0s;
  --ex: 60%;
  --ey: 28%;
  --ew: 33%;
}
.o-yellow {
  --w: min(13.9vw, 200px);
  --rot: 4deg;
  --fdur: 6.8s;
  --fy: 8px;
  --fdelay: 1.7s;
  --ex: 66%;
  --ey: 33%;
  --ew: 34%;
}
.o-emerald {
  --w: min(11.7vw, 168px);
  --rot: -13deg;
  --fdur: 8.4s;
  --fy: 18px;
  --fdelay: 0.5s;
  --ex: 54%;
  --ey: 33%;
  --ew: 40%;
}
/*
 * The envelope. No --ex/--ey/--ew: it has no eye, so nothing to aim.
 *
 * --glow is warm rather than the default lavender, because the halo sits behind
 * a bone-coloured object and a cool glow on warm plastic reads as a colour cast
 * rather than as light.
 *
 * --amp is what the bonk keyframes multiply. It exists here ONLY because this
 * object also recoils when pressed; without it `calc(-5deg * var(--amp))`
 * resolves to nothing, the whole declaration is invalid, and the animation
 * silently does nothing at all. Gentler than the emerald's 1.0 — he is being
 * shouted at, this is just a click landing.
 */
.o-envelope {
  --w: min(10.6vw, 152px);
  /* From the shipped artwork's 430x394 — see --switch-h. Used to bottom-align it
     with the switch so the two plates land on one line. */
  --h: calc(var(--w) * 394 / 430);
  --rot: 4deg;
  --glow: 214, 206, 186;
  --fdur: 7.8s;
  --fy: 13px;
  --fdelay: 2.3s;
  --amp: 0.8;
}

/* MODE 2 — shallow landscape. The default for anything wide. */
@media (min-width: 48rem) {
  /*
   * `.home` ONLY. site/404.html shares this stylesheet and renders exactly one
   * object — the switch. Sending it to the corner unscoped would leave that
   * page's field empty and its copy orphaned beside a lone creature. left and
   * bottom are reset explicitly so the base rule cannot leak through.
   */
  /*
   * 7%, not the 12% it sat at while it was small. This mode has no height
   * floor, so the worst case is a 720px laptop, and there the enlarged switch
   * plus its two-line label left THREE PIXELS between its plate and the top of
   * the purple creature's head — measured, and a gap that small is a coincidence
   * rather than a layout. Taking it to 7% restores 33px there and costs nothing
   * anywhere taller.
   */
  .home .o-switch {
    left: auto;
    bottom: auto;
    right: 6%;
    top: 7%;
  }
  .o-switch {
    left: 5%;
    bottom: 30%;
  }

  /*
   * FOUR QUARTERS, MEASURED TO THE CENTRES.
   *
   * The switch used to stand at left: 5% and hold the left quarter of the
   * composition; moving it to the corner left a hole there that read as a
   * cropped page. So the creatures now divide the field evenly: centres at
   * 12.5 / 37.5 / 62.5 / 87.5%, one per quarter, with equal air at both ends.
   *
   * `left` is an EDGE and the four are not the same width — olive 184px,
   * emerald 168, yellow 200, purple 226 — so an even set of `left` values would
   * put the centres 20px out of step. Each number below is
   * `centre − 50% of that object's own --w`, evaluated where the px caps bite
   * (about 1440px wide); the vw halves of --w keep it within a percent of true
   * all the way down to 768.
   */
  .o-olive {
    left: 6.2%;
    bottom: 32%;
  }
  .o-emerald {
    left: 31.7%;
    bottom: 12%;
  }
  .o-yellow {
    left: 55.5%;
    bottom: 32%;
  }
  .o-purple {
    left: 79.7%;
    bottom: 13%;
  }

  /*
   * BESIDE THE SWITCH HERE TOO, built exactly like the deep mode below — the same
   * three terms, with only the switch's own `right`/`top` differing between the
   * two modes.
   *
   * THIS WAS REFUSED ONCE ON A BAD MEASUREMENT, and the correction is the part
   * worth keeping. The free band left of the switch at 1280x720 is bounded below
   * by the yellow creature's bounding BOX at y 231, which leaves 188px against
   * the 214 the envelope, its plate and its tallest chip need — so it looked
   * impossible. But the plate only reaches that box's top-RIGHT CORNER, and the
   * creature is a round head that never gets there: 2,929 alpha samples over the
   * exact region the plate lands on found ZERO opaque pixels. A bounding box is
   * not ink, and it was not ink here either.
   *
   * With that out of the way the row fits with room to spare — the chip clears
   * the top of the viewport, the artwork clears the switch, and the two plates
   * land on one line as they do above.
   */
  .o-envelope {
    left: auto;
    right: calc(6% + var(--switch-w) + var(--pair-gap));
    top: calc(7% + var(--switch-h) - var(--h));
  }

  /*
   * The plate is capped because it does not scale with the artwork — "Contact
   * me" is ~101px of type at any viewport, while the envelope is only 87px wide
   * at 820. Uncapped it reaches back to within 12px of the wordmark there;
   * capped it wraps to two lines and keeps 24px, which is what the yellow's own
   * plate does anyway. It never binds above ~1020px.
   */
  .o-envelope .label {
    max-width: 9.5vw;
  }
}

/* MODE 1 — deep. Only when there is genuinely the height for it. */
@media (min-width: 75rem) and (min-height: 52rem) {
  .home .o-switch {
    left: auto;
    bottom: auto;
    right: 7%;
    top: 13%;
  }
  /* Same four quarters as the shallow mode — see the note there. Only the
     depths differ here. */
  .o-olive {
    left: 6.2%;
    bottom: 26%;
  }
  .o-emerald {
    left: 31.7%;
    bottom: 9%;
  }
  .o-yellow {
    left: 55.5%;
    bottom: 27%;
  }
  /*
   * 10%, NOT the 8% the emerald used to sit at. The two did not just trade
   * places, they traded SIZES: purple is the widest object on the page (226px,
   * and ~382px tall once its label is counted) and emerald the narrowest (168px).
   * Dropping purple into a slot tuned for emerald put it 4px past the bottom of
   * a 1440x900 viewport — measured, not guessed.
   */
  .o-purple {
    left: 79.7%;
    bottom: 10%;
  }

  /*
   * UNDER THE "3D" OF THE WORDMARK, which the user marked on a screenshot: the
   * circle they drew spans 37.6-53.5% across and 27.3-45.0% down, and the
   * envelope's rotated box lands at 39.7-50.9% / 27.4-44.0% inside it.
   *
   * It looks as though this must collide with the hero, and it does — with the
   * hero's BOX, not with a pixel of its ink. The box is `max-width: 32rem`, so it
   * ends at x 584, while the sub-paragraph's text stops at 488 and the H1
   * deliberately OVERFLOWS the box out to about x 710. The envelope sits at x 571
   * and below y 252: right of the paragraph, under the overflowing wordmark, in
   * the hole that leaves. A rect-vs-rect check will flag it forever; sample the
   * alpha instead.
   *
   * IT TRACKS THE WORDMARK, IT IS NOT A PERCENTAGE. The H1 is anchored to
   * --edge and sized from --h1, so "3D" centres on `--edge + 8.34 x --h1` at
   * every width in this mode — measured as 8.33 at 1440, 8.34 at 1280 and 8.35
   * at 820, i.e. constant. A percentage would have been right at exactly one
   * window size: the wordmark is left-anchored and stops growing at 4.2rem, so
   * its centre slides from 52% of the viewport at 1200 to 33% at 1920 while a
   * percentage sits still.
   *
   * Subtracting half of --w converts that centre into the EDGE that `left`
   * wants.
   *
   * `top`, not `bottom`, because what it is anchored to is the copy above it. If
   * the hero ever grows a line, this wants to move with it.
   */
  /*
   * BESIDE THE SWITCH, ON ITS LEFT, SHARING ITS ROW.
   *
   * Placed against the switch rather than against the viewport: both offsets are
   * the switch's own `right`/`top` plus its own size, so the pair cannot drift
   * apart when either object is resized. `left: auto` is REQUIRED — the shallow
   * block above also matches at these widths and would otherwise leave a `left`
   * standing beside this `right`, which sets the width instead of the position.
   *
   * The vertical is BOTTOM-aligned, not top-aligned, and that is what makes them
   * read as a row: the two objects are different heights, their plates hang from
   * their own bottoms, and aligning the bottoms puts both plates on one line.
   * `13% + --switch-h - --h` is exactly that.
   */
  .o-envelope {
    left: auto;
    right: calc(7% + var(--switch-w) + var(--pair-gap));
    top: calc(13% + var(--switch-h) - var(--h));
  }
}

/*
 * MODE 3 — scatter. Under 768px the hero owns the top and the five objects tile
 * two-up beneath it, in DOM order, in normal flow. Absolute placement cannot
 * survive here: five objects that each need a label do not fit in a phone's
 * viewport without overlapping, and an overlapping label is an unreadable one.
 */
@media (max-width: 47.999rem) {
  /* The footer is meant to be the last line on the page, so the tail here is
     just enough to keep it off the bezel — .foot carries the home-indicator
     inset itself. The screen and a half of nothing that used to sit under it
     was the fixed background's overscan, not this. */
  .scene {
    display: flex;
    flex-direction: column;
    padding-bottom: 1.25rem;
  }

  .hero {
    position: static;
    max-width: none;
    padding: clamp(1.6rem, 7vw, 2.4rem) var(--edge) 0;
  }

  /*
   * THE SCRIM IS A DESKTOP FIXTURE AND COMES OFF HERE.
   *
   * Its geometry — `inset: -60% -45% -75% -120%` — is a percentage of the hero,
   * and the hero is a different object in this mode: 32rem wide and absolutely
   * placed above, full-width and static below. At 375px that turns a soft
   * ellipse tucked behind the copy into a ~990px wash whose dense centre sits
   * off the left edge and whose bottom reaches 300px down into the field —
   * a black fade over the top-left of the scene, exactly where nothing needs
   * covering.
   *
   * Nothing is lost. The scrim was added because at 1280x720 the olive creature
   * puts opaque pixels behind two lines of the sub; in this mode the hero is a
   * band across the top with the field entirely BELOW it, so the only thing
   * behind the copy is the starfield and the vignette's own top wash, which is
   * densest over exactly these rows.
   */
  .hero::before {
    display: none;
  }

  .field {
    position: static;
    inset: auto;
    margin-top: clamp(1.6rem, 6vh, 3rem);
  }

  .field-nav {
    position: static;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: clamp(1.8rem, 6vw, 2.6rem) 1rem;
    align-items: end;
    padding: 0 var(--edge);
    pointer-events: auto;
    /*
     * DECORATIVE BLEED MAY NOT WIDEN THE PAGE.
     *
     * `.obj-scale::before` — the hover halo — is `inset: -14%` on a box that is
     * far taller than it is wide, and `.obj-art` rotates it. On the emerald that
     * compounds: 192 x 300px of halo turned -13deg has a bounding width of
     * 192·cos13 + 300·sin13 = 255px, which from his column centre reaches 401px
     * on a 375px phone. Invisible (it sits at opacity 0), harmless-looking, and
     * enough to make mobile Safari shrink the whole page to fit — the same
     * failure mode as the background's old overscan, from a different direction.
     *
     * `clip` and not `hidden`: hidden on one axis forces the other to `auto`,
     * which would make this a scroll container and cut the emerald's reply where
     * it hangs into the row below. `overflow-x: clip` leaves Y visible. Engines
     * without `clip` simply drop the declaration and are no worse off than
     * before.
     *
     * Nothing that matters is lost: it clips at the PADDING box, which is the
     * viewport edge, and the only things that reach it are the outer few percent
     * of a radial gradient that is already near-transparent there.
     */
    overflow-x: clip;
  }

  /*
   * IN FLOW, NOT ABSOLUTE, and this is the whole reason the mobile layout is a
   * separate mode rather than the same one at smaller numbers. Above 768px the
   * label hangs off `top: 100%` and costs nothing, because its object is
   * absolutely placed against the viewport and there is nothing underneath to
   * push. In a grid there IS: an out-of-flow label does not contribute to its
   * row's height, so every label lands on the row below it. Measured before the
   * fix: the switch's two-line label sat across both the olive and the purple.
   */
  .obj {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    max-width: 150px;
    justify-self: center;
  }

  .obj-art {
    width: 100%;
  }

  /*
   * `display: block` IS LOAD-BEARING, not tidiness. `.label` is a <span>, and
   * the only thing blockifying it in the base rule is `position: absolute` —
   * which the line below removes. Three of the four creatures survive that,
   * because .obj is a flex container here and a flex item is blockified anyway;
   * the EMERALD does not, because his label sits inside `.obj-press`, a plain
   * block. Left inline it ignored `width` and `margin-top`, painted its plate as
   * a ~10px inline fragment beside the text instead of behind it, and put a
   * block `.n` inside an inline box. On a phone that read as "his label lost its
   * box and grew a bar".
   */
  .label {
    display: block;
    position: static;
    transform: none;
    width: 100%;
    margin-top: 0.55rem;
  }

  /* The switch leads, alone and centred, because it is the one link that goes
     to the thing this site is for — and it is the widest thing in the grid for
     the same reason. 168px against the creatures' 150 looks like less than it
     is: the artwork is landscape, so at 168 wide it stands 145 tall against
     their ~230. */
  .o-switch {
    grid-column: 1 / -1;
    max-width: 168px;
  }

  /*
   * Full width, and LAST — the bottom of the field, directly above the footer,
   * which is where the user asked for it on a phone.
   *
   * IT IS LAST IN THE MARKUP TOO, and that is the whole reason it is last here:
   * `order` or an explicit `grid-row` would have moved it visually while leaving
   * it second in the tab sequence, which is the WCAG 2.4.3 problem this file
   * avoids everywhere else by moving nodes instead of numbers.
   *
   * The cost is paid on the DESKTOP side, where it is the last thing tabbed to
   * while sitting high on the screen. That is the better half of an unavoidable
   * trade: the two breakpoints genuinely want different orders, and here the page
   * is a single scrolling column where a focus jump goes off-screen and back,
   * whereas up there it is one hop across open space at the end of the sequence.
   * "Contact" last is also simply how a page is normally ordered.
   *
   * Its chip is centred on the VIEWPORT here rather than on a column, so the
   * column-derived --say-room in the block below would understate its room by
   * half. Given explicitly.
   */
  .o-envelope {
    grid-column: 1 / -1;
    max-width: 138px;
  }

  .o-envelope .say {
    --say-room: calc(50vw - var(--edge));
  }

  /*
   * THE CHIP OPENS UPWARD AT EVERY BREAKPOINT, and that reverses a decision that
   * used to live here.
   *
   * The old argument was a layout one: in the two-column grid he is the fourth
   * cell — row 2, column 2 — with a row directly above him, so upward lands the
   * reply across the switch's label plate, one control's transient message over
   * another control's NAME. Downward cleared it.
   *
   * That reasoning was sound and still lost, because it only considered what is
   * on the screen and not what is in front of it. THIS IS A TOUCH TARGET, AND A
   * TOUCH TARGET IS PRESSED WITH A THUMB. Anything drawn below the point of
   * contact is behind the hand that just caused it, so a reply that opens
   * downward on a phone is one a single-handed user cannot read without lifting
   * off the thing they are still pressing — and his whole point is that you keep
   * pressing him. Occlusion by the input device outranks occlusion by a
   * neighbour; the user made that call explicitly, accepting the cost.
   *
   * The cost is then paid down as far as it can be: `.obj.is-talking` raises him
   * to z-index 7, so the balloon paints cleanly OVER the plate above rather than
   * interleaving with it, and that plate is legible again the moment he is
   * dismissed — by tapping the balloon, tapping outside, or Escape.
   *
   * Nothing is declared here but the horizontal room: the geometry is now the
   * BASE rule at every width, which is the point. Two breakpoints that flip a
   * balloon in opposite directions are two sets of numbers to keep in step, and
   * the tail is the part that silently ends up pointing the wrong way.
   */
  .say {
    /*
     * Room from a column centre to the nearer edge; both columns give the same
     * number, so one value serves. Derived from --edge and the grid above
     * rather than measured, so changing the grid brings this with it.
     *   room = --edge + colWidth/2,  colWidth = (100vw - 2*--edge - 1rem) / 2
     */
    --say-room: calc(25vw + (var(--edge) / 2) - 0.25rem);
  }

  /* Tighter sides is what buys 320px its air. */
  .say--yell {
    padding: 0.7rem 0.6rem 0.8rem;
  }

  /*
   * The tail's nudge comes off here, and for the reason the note on .yl-peak
   * gives: `left` SHIFTS a box without growing the one around it. Above 768px
   * the tail is comfortably narrower than the chip and 0.3em is free. Down here
   * it wraps to fill the width, and a filled line shifted 0.3em to the right
   * walks straight out through the border — measured at 320px, where the last
   * glyph of the link landed a pixel past the viewport and `overflow-x: clip`
   * took it.
   */
  .say--yell .yl-tail {
    --dx: 0.05em;
  }

  .foot {
    position: static;
    margin-top: clamp(2.4rem, 8vh, 3.4rem);
    padding: 0 var(--edge);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }

  .scene-bg {
    /* On a tall narrow viewport the moon's limb wants to sit lower, or the
       objects land on empty sky. */
    object-position: center 40%;
  }
}

/* --------------------------------------------------------------------------
   Motion
   --------------------------------------------------------------------------

   Declared INSIDE no-preference rather than cancelled by reduce, which is the
   pattern this file has always used: with reduced motion the elements are
   simply painted where they belong, with no animation to interrupt.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  .obj-float {
    animation: float var(--fdur) ease-in-out var(--fdelay) infinite;
  }

  .dust {
    animation: dustPulse 5s ease-in-out infinite;
  }

  /* Breathing, not blinking. Opacity only — the drop-shadows on .obj-art are
     deliberately left static, because animating a filter repaints the artwork
     every frame and this one runs forever. Deliberately out of step with
     --fdur, so the light does not appear to be caused by the bob. */
  .o-switch .obj-scale::after {
    animation: switchAura 4.4s ease-in-out infinite;
  }

  .rise {
    animation: rise 900ms cubic-bezier(0.2, 0.8, 0.2, 1) var(--rd, 0s) both;
  }

  /*
   * Everything he does when pressed is declared HERE rather than cancelled
   * under `reduce` — the pattern this file has always used. With reduced motion
   * the reply simply appears, fully composed, and he simply stands there. The
   * script never checks the media query: it toggles classes, and this block
   * decides whether a class means anything.
   */
  .is-bonk .obj-art {
    animation: bonk 480ms cubic-bezier(0.25, 0.9, 0.3, 1);
  }

  /* Lines land in sequence. `both` supplies the from-state so there is no flash
     of un-animated type. --li is written by the script by DOM INDEX, not keyed
     to the role classes: stage 5 uses them in the order pre, tail, peak, and a
     class-keyed delay would stagger it backwards. */
  .is-talking .yl {
    animation: yellIn 420ms cubic-bezier(0.2, 0.85, 0.25, 1)
      calc(var(--li, 0) * 70ms) both;
  }

  /* The shouted word inflates letter by letter on top of its line, keyed to the
     same --i the arc is built from. `scale` is the one individual transform
     property the arc leaves free. */
  .is-talking .arc > span {
    animation: pop 380ms cubic-bezier(0.2, 1.6, 0.35, 1)
      calc(var(--li, 0) * 70ms + var(--i) * 26ms) both;
  }

  /* Six shakes, then it stops. An indefinite tremble is a migraine, not a joke.
     `translate:`, not `transform:`, which is spoken for by the chip's centring. */
  .o-emerald[data-stage="5"] .say.is-on {
    animation: tremble 90ms ease-in-out 6;
  }

  /* "Copied!" arrives the way the shouted letters do — the same overshooting
     curve as `pop`, so the page has one vocabulary for "something just landed"
     rather than two. Declared in here, so under reduced motion the word simply
     appears and the script never has to ask. */
  .o-envelope[data-copy="done"] .mail-done {
    animation: copyPop 420ms cubic-bezier(0.2, 1.6, 0.35, 1) both;
  }
}

@keyframes float {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(calc(var(--fy) * -1));
  }
}

@keyframes switchAura {
  0%,
  100% {
    opacity: 0.62;
  }
  50% {
    opacity: 1;
  }
}

@keyframes dustPulse {
  0%,
  100% {
    opacity: 0.55;
  }
  50% {
    opacity: 0.9;
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(22px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* He recoils, and harder as he gets more exasperated: --amp scales every value.
   These go on .obj-art via the INDIVIDUAL rotate/scale properties, which compose
   with its static `transform: rotate(--rot)` instead of cancelling it, and leave
   .obj-float's bob and .obj-scale's hover growth untouched. */
@keyframes bonk {
  0% {
    rotate: 0deg;
    scale: 1 1;
  }
  22% {
    rotate: calc(-5deg * var(--amp));
    scale: calc(1 + 0.05 * var(--amp)) calc(1 - 0.06 * var(--amp));
  }
  46% {
    rotate: calc(3.4deg * var(--amp));
    scale: calc(1 - 0.03 * var(--amp)) calc(1 + 0.04 * var(--amp));
  }
  72% {
    rotate: calc(-1.4deg * var(--amp));
    scale: 1 1;
  }
  100% {
    rotate: 0deg;
    scale: 1 1;
  }
}

/* opacity is animated inside the keyframes, so on an engine without individual
   transform properties the lines still fade in rather than staying invisible. */
@keyframes yellIn {
  from {
    opacity: 0;
    translate: 0 0.35em;
    scale: 0.88;
  }
  to {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }
}

@keyframes pop {
  from {
    opacity: 0;
    scale: 0.55;
  }
  to {
    opacity: 1;
    scale: 1;
  }
}

/* `scale:` and `rotate:` rather than a transform, for the same reason the arc
   uses them: the chip's own `transform` is spoken for by its centring, and these
   compose with it instead of cancelling it. */
@keyframes copyPop {
  0% {
    opacity: 0;
    scale: 0.5;
    rotate: -8deg;
  }
  55% {
    opacity: 1;
    scale: 1.12;
    rotate: 3deg;
  }
  100% {
    opacity: 1;
    scale: 1;
    rotate: 0deg;
  }
}

@keyframes tremble {
  0%,
  100% {
    translate: 0 0;
  }
  25% {
    translate: -1.5px 0.5px;
  }
  75% {
    translate: 1.5px -0.5px;
  }
}

/*
 * The hover-grow and the chip survive reduced motion — they are direct
 * responses to a deliberate act, not ambient movement — but they stop being
 * animations.
 */
@media (prefers-reduced-motion: reduce) {
  .obj-scale {
    transition-duration: 1ms;
  }

  .say {
    transform: translate(-50%, 0);
    transition:
      opacity 1ms,
      visibility 0s;
  }
}

/* ==========================================================================
   SWITCH TEMPORARILY REMOVED — DELETE THIS WHOLE BLOCK WHEN IT COMES BACK
   ==========================================================================

   Taken out 2026-08-24 at the owner's request. See the big comment at the top of
   the <nav> in site/index.html for the full restore checklist; this file's part
   of it is "delete everything below this banner".

   WHY ANYTHING IS NEEDED HERE AT ALL. The envelope is not placed against the
   viewport — it is placed against the SWITCH, by construction:

       right: calc(7% + var(--switch-w) + var(--pair-gap));
       top:   calc(13% + var(--switch-h) - var(--h));

   That is what keeps the two plates on one line. With the switch gone those
   terms still resolve — the tokens are still defined, and the 404 still uses
   them — so the envelope would sit exactly where it always sat, one gap to the
   left of a hole. The corner would read as a missing image rather than as a
   composition.

   So while the switch is out, the envelope takes its slot: the same `right` and
   `top` the switch itself uses in each mode, which puts it where the eye already
   expects something to be. Nothing else moves.

   The mobile grid needs no rule. The switch is simply one fewer item, and the
   remaining five tile 2 + 2 + 1 with the envelope still full-width and last.
   ========================================================================== */

@media (min-width: 48rem) {
  /*
   * 11%, not the switch's own 7%. The switch never says anything; the envelope
   * opens a chip UPWARD out of its own top, and at 7% the tallest of those
   * states (the failure line, 51px) started 3px above the top of a 720px
   * viewport that cannot scroll to bring it back. 11% leaves 19px at 1280x720
   * and 10px at 820x640.
   */
  .o-envelope {
    right: 6%;
    top: 11%;
  }
}

@media (min-width: 75rem) and (min-height: 52rem) {
  .o-envelope {
    right: 7%;
    top: 13%;
  }
}
