/* Twote public site: homepage + What / How / Why / Next explainers.
   TEMPLATES, not deployed. See README.md in this folder before shipping any of it.

   The visual language is the SHIPPED FEED's, ported from web/feed/styles.css rather than reinvented:
   the same glass recipe, the same hairlines and tints, the same concentric radii, the same wordmark.
   `docs/design/web-feed-glass-redesign.md` records why each value is what it is.

   Rules carried over from the feed, and they outlive this file too:
   - tokens.css is a vendored copy of the handoff tokens. NEVER hand-edit it. Everything here is
     DERIVED from those tokens.
   - The wordmark is the LIVE handoff lockup (CANON 11). Its markup and CSS below are the handoff's
     own code as shipped on the feed, NOT a recreation from spec, memory or a screenshot. Do not
     substitute an SVG, a font trick, or the raster twote-wordmark.png (whose background is opaque
     --canopy and reads as a dark plate over a photograph).
   - Concentric radii at PANEL level: outer = inner + padding, 28 = 12 + 16.

   Mobile-first: the base styles are the small-viewport styles and the media queries add width back. */

@import url('tokens.css');

* { box-sizing: border-box; }

:root {
  /* ---- The glass recipe, one definition, from web/feed/styles.css ---- */
  --glass-fill: rgba(28, 40, 33, 0.18);
  --glass-blur: blur(20px) saturate(1.2);
  --glass-sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0) 40%);
  --glass-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.11),
    inset 0 0 0 1px rgba(255, 255, 255, 0.05),
    0 1px 2px rgba(0, 0, 0, 0.28),
    0 10px 28px rgba(0, 0, 0, 0.30),
    0 28px 64px rgba(0, 0, 0, 0.34);

  /* Sticky chrome takes the DEEP fill, never the panel fill (CANON 14). Anything content scrolls
     beneath is opaque enough to read against, or you get two translucent layers stacked and body
     copy legible straight through the nav. */
  --glass-fill-deep: rgba(15, 24, 20, 0.97);

  /* ---- Structure: the LIGHT scale, all five named steps (CANON 14). ----
     This file carried three of them until 2026-07-26 and did the other two by hand. Nothing between
     these values: if a surface wants "a bit more than rest", it wants `strong`, not 0.10. */
  --hairline: rgba(255, 255, 255, 0.065);
  --tint-recess: rgba(255, 255, 255, 0.03);  /* a well: inset readouts, code, quiet lists */
  --tint-hover: rgba(255, 255, 255, 0.055);  /* pointer hover only */
  --tint-rest: rgba(255, 255, 255, 0.07);    /* the default raised surface: tiles, chips */
  --tint-press: rgba(255, 255, 255, 0.09);   /* active press only */
  --tint-strong: rgba(255, 255, 255, 0.14);  /* the one emphasis step */
  /* ---- The DARK scale. For legibility over photograph, never for decoration. ---- */
  --scrim-soft: rgba(0, 0, 0, 0.30);
  --scrim-hard: rgba(0, 0, 0, 0.55);
  --imgEdge: rgba(255, 255, 255, 0.10);
  /* --moss is the handoff's muted-label colour and works on the solid canopy it was designed for,
     but over glass it lands near 2.8:1, under the 4.5:1 floor for text this small. */
  --textQuiet: rgba(250, 250, 245, 0.60);
  --textBody: rgba(250, 250, 245, 0.86);

  --radius-panel: 28px;
  --pad-panel: 16px;
  --radius-inner: 12px;

  --ease-out: cubic-bezier(0.2, 0, 0, 1);
  --dur-tint: 150ms;

  /* --wmSize is NOT set here. It comes from the lockup block's role variables (CANON 16), which
     default it to `--wm-chrome`. A page-level size of its own is exactly how this file ended up
     with four different marks. */

  /* NO READING MEASURE OF ITS OWN (2026-08-09). This file carried `--measure: 42ch`, measured by
     eye on these pages before the shell rollout. The content template now defines `--ct-measure`
     on `.ct-page` and puts every running-text block on it, and two measures on one page is the
     "randomly left aligned content text" Paul called out: the standfirst and each section intro
     sat at 660px while the prose inside a sheet sat at 455px. Every column here takes
     `--ct-measure`. Change it in docs/content-pages/content.css, never here. */
}

html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

body {
  margin: 0;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--fontUI);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  line-height: 1.55;
}

/* ---------- Forest backdrop ---------- */
/* A fixed layer, not background-attachment: fixed, which repaints on every scroll frame. */
.backdrop-photo {
  position: fixed;
  inset: 0;
  z-index: -1;
  background-color: var(--canopy);
}
.backdrop-photo::before {
  content: '';
  position: absolute;
  inset: 0;
  background-image: url('assets/glade-1280.webp');
  background-size: cover;
  background-position: center 72%;
  background-repeat: no-repeat;
  /* glade-2 is a genuinely dark image (median luminance 19). Lifting it is what makes the glass read
     AS glass, because there has to be foliage detail behind the blur to see. */
  filter: brightness(1.24) saturate(1.06);
}
@media (min-width: 1281px) {
  .backdrop-photo::before { background-image: url('assets/glade-1920.webp'); }
}
/* These pages SCROLL, unlike the feed's single bounded screen, so the scrim holds legibility the whole
   way down instead of in one band, and it is a little heavier than the feed's for that reason.
   CALIBRATED BY EYE, and the first attempt was wrong in an instructive way: at 0.55 / 0.72 / 0.86 the
   photograph was crushed to near-black, which left the glass nothing to frost and made every panel
   read as a flat grey card. The foliage has to stay visible for the material to work at all. */
.backdrop-photo::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg,
      rgba(15, 24, 20, 0.30) 0%,
      rgba(15, 24, 20, 0.48) 45%,
      rgba(15, 24, 20, 0.66) 100%);
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--surface);
  color: var(--text);
  padding: 12px 20px;
  border-radius: 8px;
  z-index: 100;
  border: 1px solid var(--outline);
}
.skip-link:focus { left: 12px; top: 12px; }

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

:focus-visible {
  outline: 2px solid var(--swiftGreenBright);
  outline-offset: 2px;
}

/* ---------- Glass ---------- */
.glass {
  background-color: var(--glass-fill);
  background-image: var(--glass-sheen);
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border-radius: var(--radius-panel);
  box-shadow: var(--glass-shadow);
}
/* Browsers without backdrop-filter get an opaque-enough fill so text never sits on bare photograph.
   This raises the VARIABLE, not the background-color: on the feed, a background-color override here
   lost the cascade to a later equal-specificity rule and the fallback was silently dead for a week.
   A custom property is resolved at use time and cannot be beaten by rule order. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass { --glass-fill: rgba(19, 29, 24, 0.93); }
}

/* ---------- Shell ----------
   The page shell is the shared content template's (CANON 23, 2026-08-09 amendment): `.ct-page`,
   `.ct-chrome` and `.ct-masthead` live in content.css, which is a verbatim copy of
   docs/content-pages/content.css. What used to be here (`.page`, `.site-head`, `.head-inner`,
   `.head-brand`) is gone with the markup that used it. The section nav below is kept: it is now
   the chrome bar's right-hand slot (Paul's direction, 2026-08-09), which is the one per-page slot
   the copied chrome block leaves to the page. */

/* STICKY CHROME, the rule from the CHROME contract block (docs/CANON.md decision 14, the copy in
   web/feed/oddities/oddities.css). The two values it uses, `--glass-fill-deep` and `--hairline`,
   are already defined at the top of this file at the contract's own numbers, so only the rule
   itself is copied: a second definition of a material token is what layers.test.js exists to
   catch. `.ct-chrome` in content.css overrides `position` to fixed and keeps everything else. */
.sticky-chrome {
  position: sticky;
  z-index: 40;
  background: var(--glass-fill-deep);
  border-bottom: 1px solid var(--hairline);
}

/* ---------- The section nav, in the chrome bar's right-hand slot ---------- */
.site-nav {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 4px;
  flex-wrap: wrap;
}
.site-nav a {
  display: inline-flex;
  align-items: center;
  /* 44px minimum touch target. The visible label is smaller than that, so the padding carries it. */
  min-height: 44px;
  padding: 10px 14px;
  border-radius: 999px;
  color: var(--textQuiet);
  text-decoration: none;
  /* THE FALLBACK IS LOAD-BEARING. The ladder is declared on `.ct-page`, and the chrome bar is a
     sibling of it, so `--ct-t-body` does not inherit down here. The fallback is that step's own
     value, so the bar stays on the ladder either way. */
  font-size: var(--ct-t-body, 14.5px);
  font-weight: 600;
  letter-spacing: 0.01em;
  transition-property: background-color, color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.site-nav a:hover {
  background-color: var(--tint-hover);
  color: var(--text);
  text-decoration: none;
}
.site-nav a[aria-current='page'] {
  color: var(--text);
  /* CANON 14 names `strong` as the ONE emphasis step and gives "a selected tab" as its example.
     This is that. It sat on `rest` and was indistinguishable from an ordinary hover.
     This rule comes after the :hover rule at equal specificity, so hovering the current page keeps
     the emphasis instead of dropping to the fainter hover tint. */
  background-color: var(--tint-strong);
}
/* On a narrow screen the four labels wrapped to a second row and made the sticky bar 105px tall,
   which is a lot of a 812px phone viewport to spend on chrome. Tighter horizontal padding and a
   slightly smaller label keep them on one row beside the mark; the 44px touch height is untouched,
   because that is the floor and not the thing costing the space. */
@media (max-width: 560px) {
  .site-nav { gap: 0; }
  .site-nav a { padding: 10px 8px; font-size: var(--ct-t-small, 13px); }
}
@media (prefers-reduced-motion: reduce) {
  .site-nav a { transition-duration: 0ms; }
}

/* ==LOCKUP-BEGIN== THE LOCKUP CONTRACT, docs/CANON.md decision 16.
   Everything between the LOCKUP-BEGIN and LOCKUP-END sentinels is BYTE-IDENTICAL in
   web/feed/styles.css and web/site/site.css, and `node web/lockup.test.js` fails if it ever stops
   being. Its numbers are the same table as shared/.../design/LockupSpec.kt, asserted on that side by
   LockupSpecTest. To change a value: edit CANON 16 first, then all four places. Never edit one copy.

   THE MARK IS DEFINED ONCE, in docs/design/handoff-t2/Twote Handoff.dc.html, the "Splash / cold
   open" card. Never recreate it from the written spec, from memory, or from a screenshot. Never
   substitute an SVG file, a font trick, or the raster twote-wordmark.png, whose background is opaque
   --canopy and reads as a dark plate over the photograph. Never let a text letter "O" stand in for
   the drawn ring, which is what the mascot card's fallback wordmark was doing until T5.7.

   AUTHORITATIVE RULE, carried verbatim from CANON 11: the pulse echo rings are CONCENTRIC with the
   green O and share its EXACT centre, computed by construction, NEVER a separate centred box and
   NEVER an eyeballed offset. Here that is satisfied by the SVG viewBox being `0 0 100 100` over the
   O's own square box, so cx/cy = 50,50 IS the measured centre. */

/* OUTFIT IS SELF-HOSTED (CANON 16, Paul 2026-07-26). The lockup's typeface ships with the page,
   because a substituted typeface is a different mark and a webfont that arrives late, or not at all,
   substitutes. Both surfaces used to pull Outfit from a third-party font CDN, which is a request on
   the critical path for the brand. `font-display: block` is deliberate and is the opposite
   of the usual advice: a brief blank mark is better than the mark rendering in a system sans and then
   jumping. (Fraunces was removed 2026-08-20 and came back on 2026-09-07 for species and scientific
   names only, self-hosted the same way. The lockup itself stays Outfit.)
   The four files are the fontsource latin subsets, 14 KB each. Paths are relative to this stylesheet,
   so each surface serves its own copy from its own assets/fonts/. */
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 400;
  font-display: block;
  src: url('assets/fonts/outfit-latin-400-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 500;
  font-display: block;
  src: url('assets/fonts/outfit-latin-500-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 600;
  font-display: block;
  src: url('assets/fonts/outfit-latin-600-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 700;
  font-display: block;
  src: url('assets/fonts/outfit-latin-700-normal.woff2') format('woff2');
}

/* THE THREE ROLES. A placement names a role; it never writes a px value. ONE breakpoint, 1024px,
   shared by both web surfaces: the site's old 700px step and its 30px nav mark are gone. The app has
   no breakpoint, so it always takes the base values, which is why MARK's base is the splash's 58. */
:root {
  --wm-mark: 58px;         /* splash, the app's cold-open stage, the site hero */
  --wm-chrome: 44px;       /* feed header, the app's Listen header, the site nav */
  --wmSize: var(--wm-chrome);
}
@media (min-width: 1024px) {
  :root {
    --wm-mark: 84px;
    --wm-chrome: 56px;
  }
}

/* ONE NUMBER, S = --wmSize. Every value below is a ratio of it, so the lockup is the same shape at
   any size on any surface. Three independent scale factors are what made the relationship invert
   across the old breakpoint: the tagline read oversized beside the small mark and undersized beside
   the large one. */
.wordmark-lockup {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  /* A flat 0, not a ratio: the wordmark's own line box already carries slack that scales with its
     font-size (measured 4.8px at 40px, 7.1px at 56px), so 0 still reads as a proportional gap.
     DO NOT write a negative value here. This was `calc(var(--wmSize) * -0.02)` until 2026-07-26, and
     `gap` accepts only non-negative lengths, so the declaration was invalid, dropped, and the
     computed row-gap was 0px the whole time. To go tighter than 0, pull the tagline up with a
     negative margin-top, which is legal, then re-check that the rings still clear the letterforms. */
  gap: 0;
}
.wordmark {
  font-family: var(--fontUI);
  font-weight: 700;
  font-size: var(--wmSize);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--text);
  display: inline-flex;
  align-items: baseline;
  position: relative;
  isolation: isolate;
  /* There is no plate behind the mark, so the letterforms carry their own legibility. Invisible
     against the dark canopy; it only does work over a bright patch of foliage. */
  text-shadow: 0 2px 16px rgba(15, 24, 20, 0.85), 0 1px 3px rgba(15, 24, 20, 0.7);
}
.wordmark-part { position: relative; z-index: 1; }
.wordmark-o {
  position: relative;
  display: inline-block;
  width: 0.67em;
  height: 0.67em;
  /* THE SIDE MARGIN IS A RAMP, NOT A STEP. A drawn circle has no sidebearing, so an em-proportional
     margin over-opens as the mark grows: hence two anchors, 0.032em at S=44 (1.408px) and 0.018em at
     S=56 (1.008px). Both are preserved to the pixel. It used to STEP between them, at a 1024px
     VIEWPORT on the feed, at a 50sp MARK SIZE on Android, and never at all on the site, which is why
     the site hero was running 2.688px of margin where CANON already said 1.008. A viewport is not
     the mark, so it cannot be what the mark's geometry keys off. This clamp is exactly
     LockupSpec.oMargin(S), including the min/max ordering. */
  margin: 0 clamp(
    calc(var(--wmSize) * 0.018),
    calc(1.408px - (var(--wmSize) - 44px) * 0.033333),
    calc(var(--wmSize) * 0.032)
  );
  top: 0.02em;
}
/* THE RING-O IS AN SVG STROKE, NOT A CSS BORDER (2026-07-26).
   It was `border: 0.114em`, and Chrome FLOORS a computed border-width to a whole device pixel: at
   CHROME 44 on a 1.1-dpr display the 5.016px ring rendered 4.545px, about 9% thin, and every measured
   value came back an exact multiple of 1/1.1. The error is bounded by one device pixel, so it is
   worst at the small sizes the mark is used at most, and it moves with the viewer's zoom. Android
   draws this ring on a canvas and was always exact, so the two platforms disagreed by up to 9% on the
   one value the whole lockdown is about.

   An SVG stroke is rasterised with subpixel antialiasing instead of snapped, so it stays proportional
   at every size and dpr. This is the same change, for the same reason, that CANON 11 already made to
   the echoes: the technique changes, the geometry does not.

   The stroke is CENTRED on the path, so the radius is set to put its OUTER edge exactly on the box
   edge, which is what `box-sizing: border-box` was doing for the border:
     stroke-width = 0.114 / 0.67 x 100 = 17.0149 units of the 100-unit viewBox
     r            = (100 - 17.0149) / 2 = 41.4926          (an ATTRIBUTE in the markup, not CSS `r`,
                                                            so the mark still draws in a browser
                                                            without CSS geometry properties)
   The ring is the LAST child of the pulse SVG, so it paints over the echoes; the letterforms carry
   z-index 1 and the SVG does not, so they still paint over both. */
.wordmark-ring {
  fill: none;
  stroke: var(--swiftGreenBright);
  stroke-width: 17.0149;
}

/* ---- Sonar echoes ----
   The handoff draws these as bordered boxes animating `inset` from 0 to -0.448em. That reads as
   jerky: `inset` is a LAYOUT property, so every frame runs layout on a main thread already busy
   compositing backdrop-filter panels over a 1920px photo, and frames get dropped. It also cannot
   taper, because scaling a bordered box makes its border look THICKER as it grows, the opposite of a
   ping. So the echoes are SVG strokes instead. The handoff's GEOMETRY is preserved exactly, converted
   into the viewBox where 100 units = the O's outer diameter (0.67em): the ring starts flush with the
   O and ends 0.448em beyond it, which is 0.448 / 0.67 * 100 = 66.9 units, so the outer edge travels
   50 -> 116.9. Only the rendering technique and the stroke taper are new (Paul, 2026-07-25). */
.wordmark-pulse {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;       /* the rings expand well past the O's own box */
  pointer-events: none;
}
.wordmark-echo {
  stroke: var(--swiftGreenBright);
  opacity: 0;
  /* Start heavy, near the O's own weight, so the ring reads as the O shedding a pulse. */
  stroke-width: 11;
}
/* Three separate animations, because spread, taper and fade each want a DIFFERENT curve. Driving
   them from one set of keyframes is what made the first attempt read badly: the ring covered 92% of
   its travel in the first 900ms of a 3s cycle, then hung nearly motionless while it faded, and only
   reached full opacity after the motion was already over.
   r is the CENTRELINE radius, so each end value is the target outer edge minus half the stroke:
   start 50 - 11/2 = 44.5 (flush with the O, so the pulse reads as shed BY the O);
   end 116.9 - 0.9/2 = 116.5 (the handoff's 0.448em of travel). */
/* CADENCE (Paul, 2026-07-26): the time BETWEEN pings is doubled, the pings themselves travel at the
   same speed. So the cycle is 6s with all the motion packed into the first 50% (an unchanged 3s of
   travel) and the ring dormant for the rest; the second ring is delayed a full 3s instead of 1.5s.
   Emissions therefore land every 3s rather than every 1.5s. Keep the 50% marks and the 6s duration
   in step if either changes, or the pulses drift out of their rest. */
@keyframes tw-sonar-spread {
  0%   { r: 44.5; }
  50%  { r: 116.5; }
  100% { r: 116.5; }
}
/* The wavefront thins as it spreads, but more slowly than it travels: a stroke that tapers on the
   spread curve is a hairline a third of the way in and has nothing left to show. */
@keyframes tw-sonar-taper {
  0%   { stroke-width: 11; }
  50%  { stroke-width: 0.9; }
  100% { stroke-width: 0.9; }
}
/* Fast attack at emission, then a long decay held high enough that the ring is brightest WHILE it is
   travelling fastest, not after it has stopped. Peak lowered to 0.68 (Paul): the mark should breathe
   behind the wordmark, not compete with it. */
@keyframes tw-sonar-fade {
  0%    { opacity: 0; }
  2%    { opacity: 0.68; }
  27.5% { opacity: 0.30; }
  50%   { opacity: 0; }
  100%  { opacity: 0; }
}
/* ONE GATE, `.is-pulsing`, on both surfaces (CANON 16). At most one mark pulses in a view and it is
   the largest one present; every other mark shows the static echo. On the feed, app.js sets this on
   the header lockup only while the sensor heartbeat is genuinely live, because a mark is a heartbeat
   and should not beat for a feed that is offline. On the site there is no sensor, so the class is
   authored in the markup, on the hero and nowhere else: one motion moment per page. The two surfaces
   used to use two different class names for this, `.sensor-live` and `.is-pulsing`, which is why
   this rule could not be shared. */
@media (prefers-reduced-motion: no-preference) {
  .is-pulsing .wordmark-echo {
    /* A moderate ease-out, not an exponential one. The wavefront should still be visibly moving
       through the middle of the cycle; expo-out parks it. */
    animation:
      tw-sonar-spread 6s cubic-bezier(0.18, 0.58, 0.33, 1) infinite,
      tw-sonar-taper 6s cubic-bezier(0.35, 0.25, 0.5, 1) infinite,
      tw-sonar-fade 6s linear infinite;
  }
  .is-pulsing .wordmark-echo--b { animation-delay: 3s, 3s, 3s; }
  .is-pulsing .wordmark-echo--a { opacity: 0; }
}
/* Reduced motion, offline, and every non-pulsing mark: the handoff's own static fallback, one faint
   ring sitting just outside the O (inset -0.12em = 17.9 units, so outer edge 67.9). */
.wordmark-echo--a { r: 66.9; stroke-width: 2; opacity: 0.4; }
.wordmark-echo--b { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .is-pulsing .wordmark-echo { animation: none; }
  .is-pulsing .wordmark-echo--a { r: 66.9; stroke-width: 2; opacity: 0.4; }
}
.wordmark-tagline {
  /* The rings expand past the wordmark's own box and reach this line, so the tagline is explicitly
     lifted above the pulse. Every letterform sits in front of the ping; nothing is drawn over type. */
  position: relative;
  z-index: 2;
  font-size: calc(var(--wmSize) * 0.25);
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--textQuiet);
  text-shadow: 0 1px 10px rgba(15, 24, 20, 0.85);
}
/* ==LOCKUP-END== */

/* FRAUNCES IS SELF-HOSTED TOO (CANON 16, amended 2026-09-07: the 2026-08-20 ban is lifted for
   species and scientific names). Same principle as Outfit above, so no font CDN: the face ships
   with the page. This is the SIL OFL variable italic the app already bundles, subset to latin,
   optical size pinned at 14 and the weight axis kept, so a species name inside a heavier line still
   draws at that weight. `font-display: swap` rather than `block`, because this face is not the
   mark: a binomial in the fallback serif for a moment beats a hole in the sentence. */
@font-face {
  font-family: 'Fraunces';
  font-style: italic;
  font-weight: 100 900;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin-italic-variable.woff2') format('woff2');
}

/* ---------- Openings, surfaces and wells ----------
   All of it comes from the content template now: the opening is `.ct-title` + `.ct-standfirst`
   bare on the photograph, the surfaces are `.ct-sheet glass`, `.ct-card` and `.ct-well`, and the
   materials behind them are CANON 23's four. The hero (and its pulse), `.panel` and its three
   modifiers, `h1.page-title`, `.page-standfirst` and `.well` went with the markup that used them.
   THE PULSE IS GONE FROM THIS SURFACE ON PURPOSE: CANON 16 gives it to a live sensor, and the
   template carries no load-time motion. */

/* More space ABOVE a heading than below it, so a heading groups with the text it introduces rather
   than floating between two blocks. */
h2 {
  font-size: var(--ct-t-h2);
  line-height: 1.2;
  font-weight: 700;
  letter-spacing: -0.01em;
  margin: 40px 0 12px;
  text-wrap: balance;
}
h2:first-child { margin-top: 0; }
h3 {
  font-size: var(--ct-t-h3);
  font-weight: 600;
  margin: 28px 0 8px;
}
p, li {
  color: var(--textBody);
  font-size: var(--ct-t-lead);
  max-width: var(--ct-measure);
  margin-inline: auto;
  text-wrap: pretty;
}
/* THE MEASURE BELONGS TO PROSE, AND ONLY TO PROSE (2026-07-26).
   The blanket `p, li` rule above was also catching every STRUCTURED list on the site: detection
   rows, doorway cards, fact rows. A detection row is a data row, not a paragraph, and clamping one
   to 42ch left it 455px wide inside a 992px panel with the whole right half empty. That single
   inherited rule is most of why the pages read as big slabs with holes in them, and it looked like
   a layout problem when it was a typography rule applied one selector too wide.
   `.steps > li` deliberately keeps the measure: a numbered walk-through IS prose. */
.example > li,
.doorways > li,
.facts > div { max-width: none; }
p { margin: 0 0 14px; }
.prose ul { margin: 0 0 16px; padding-left: 20px; }
.prose li { margin: 0 0 8px; }
.prose strong { color: var(--text); font-weight: 600; }
.prose em { color: var(--text); }

a { color: var(--link); text-decoration: none; }
a:hover, a:focus-visible { text-decoration: underline; }

/* Scientific names are set in Fraunces italic (CANON 16, amended 2026-09-07). */
.species-name { font-family: var(--fontSpecies); font-style: italic; }

/* ---------- The doorway cards on the homepage ---------- */
.doorways {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
  margin: 28px 0 0;
  padding: 0;
  list-style: none;
}
@media (min-width: 760px) {
  .doorways { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.doorway {
  display: block;
  padding: 20px;
  /* CONCENTRIC WITH ITS CARD (P1 polish): the .ct-card's 28px radius less its 20px inline padding. */
  border-radius: 8px;
  color: inherit;
  text-decoration: none;
  background-color: transparent;
  transition-property: background-color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.doorway:hover { background-color: var(--tint-hover); text-decoration: none; }
.doorway:active { background-color: var(--tint-press); }
@media (prefers-reduced-motion: reduce) {
  .doorway { transition-duration: 0ms; }
}
.doorway-q {
  display: block;
  font-size: var(--ct-t-h2);
  font-weight: 700;
  margin: 0 0 6px;
  color: var(--text);
  letter-spacing: -0.01em;
}
.doorway-a {
  display: block;
  color: var(--textQuiet);
  font-size: var(--ct-t-body);
  margin: 0;
  max-width: none;
}
/* Hairlines between doorways, drawn only where two of them meet. Rows are content inside the panel,
   so they get a divider and a tint rather than a border and a background of their own. */
@media (min-width: 760px) {
  .doorways > li:nth-child(n + 3) { border-top: 1px solid var(--hairline); }
  .doorways > li:nth-child(even) { border-left: 1px solid var(--hairline); }
}
@media (max-width: 759.98px) {
  .doorways > li + li { border-top: 1px solid var(--hairline); }
}

/* ---------- An illustrative feed row, the shape of what the app actually shows ---------- */
.example {
  margin: 20px 0 0;
  padding: 0;
  list-style: none;
}
.example > li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px;
  border-radius: var(--radius-inner);
}
.example > li + li { border-top: 1px solid var(--hairline); }
.example-art {
  width: 52px;
  height: 52px;
  flex: none;
  border-radius: 8px;
  background: rgba(15, 24, 20, 0.5);
  outline: 1px solid var(--imgEdge);
  outline-offset: -1px;
  display: grid;
  place-items: center;
  font-size: var(--ct-t-micro);
  color: var(--textQuiet);
  text-align: center;
  line-height: 1.1;
}
.example-body { flex: 1; min-width: 0; }
.example-name {
  display: block;
  font-weight: 600;
  font-size: var(--ct-t-body);
  color: var(--text);
}
.example-sci {
  display: block;
  font-size: var(--ct-t-small);
  color: var(--textQuiet);
}
.example-when {
  color: var(--textQuiet);
  font-size: var(--ct-t-small);
  flex: none;
  /* Tabular figures on anything that reads as a measurement, so a column of them lines up. */
  font-variant-numeric: tabular-nums;
}
.tier-chip {
  display: inline-flex;
  align-items: center;
  border-radius: 999px;
  font-weight: 600;
  font-size: var(--ct-t-micro);
  padding: 3px 9px;
  white-space: nowrap;
  flex: none;
  font-variant-numeric: tabular-nums;
}
/* CONFIDENCE CHIPS, sampled from the ramp (CANON 15). These pages are static, with no script and no
   real scores behind them, so the colours are BAKED from the ramp at a representative value for each
   word rather than computed. The values are the ramp's own output at 0.57, 0.81 and 0.93, which are
   the same three examples the live feed's sample data shows, so the site and the feed agree.

   Do not hand-pick a colour here. If one of these needs to change, change the confidence it is
   sampled at and take whatever `TwoteRamp.hexFor()` returns. The banded --tierXBg tokens these
   replaced are dead; the ramp is continuous and the old tokens named tiers that no longer exist. */
.tier-chip--medium  { color: #F3B241; background: #F3B24129; }  /* ramp at 0.57 */
.tier-chip--high    { color: #89EC8A; background: #89EC8A29; }  /* ramp at 0.81 */
.tier-chip--highest { color: #60FAAD; background: #60FAAD29; }  /* ramp at 0.93 */

.caption {
  color: var(--textQuiet);
  font-size: var(--ct-t-small);
  margin: 10px 0 0;
  max-width: var(--ct-measure);
}

/* ---------- A numbered walk-through ---------- */
.steps { counter-reset: step; margin: 20px 0 0; padding: 0; list-style: none; }
.steps > li {
  counter-increment: step;
  position: relative;
  padding: 14px 0 14px 46px;
  max-width: var(--ct-measure);
}
.steps > li + li { border-top: 1px solid var(--hairline); }
.steps > li::before {
  content: counter(step);
  position: absolute;
  left: 0;
  top: 14px;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: var(--tint-rest);
  color: var(--text);
  font-size: var(--ct-t-body);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.steps h3 { margin: 0 0 4px; }
.steps p { margin: 0; }

/* ---------- Site-local shell helpers for the content template (2026-08-09) ----------
   The template's `.ct-band` sets its own heading and first-paragraph sizes, which is the right
   rhythm for a heading with a lede under it and the wrong one for a compartment of body copy.
   A card holding prose takes the band's padding and nothing else. Layout only: no colour, no
   radius, no material of its own, all of which the card already carries. */
/* A sheet holding nothing but PROSE shrinks to the template's measure instead of being a 1140px
   sheet with a text column in one corner, which is the defect `.panel--prose` was written to fix
   and which came straight back when the sheet replaced the panel. */
.ct-sheet--prose { max-width: calc(var(--ct-measure) + 2 * var(--pad-panel)); }

.ct-card--pad { padding: 24px 20px; }
.ct-card--pad > :first-child { margin-top: 0; }
.ct-card--pad > :last-child { margin-bottom: 0; }

/* The pull quote is the template's `.ct-quote`, bare between the opening and the first surface. */

/* ---------- A plain two-column fact list ---------- */
/* The list is its own query container (2026-08-09). It used to measure the `.panel` it sat in, and
   the shell rollout onto the content template took `.panel` away; the thing that actually
   constrains these two columns is this list's own width, which is what it now asks. */
.facts { margin: 16px 0 0; padding: 0; container-type: inline-size; }
.facts > div {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2px 20px;
  padding: 12px 0;
}
.facts > div + div { border-top: 1px solid var(--hairline); }
/* A CONTAINER query, not a viewport one (2026-07-26). This was `@media (min-width: 640px)`, which
   asks how wide the WINDOW is when the thing that actually constrains these two columns is the
   LIST. Inside a 511px prose column on a 1440px display the media query said "plenty of room"
   and gave a 15ch term column 155px of space. Same mistake the O's side margin made before CANON 16
   (it keyed off the viewport), and the same correction: measure the thing that constrains you.
   `.facts` carries `container-type: inline-size` for this. */
@container (min-width: 560px) {
  .facts > div { grid-template-columns: minmax(0, 15ch) minmax(0, 1fr); }
}
.facts dt { color: var(--text); font-weight: 600; font-size: var(--ct-t-body); }
.facts dd { margin: 0; color: var(--textBody); font-size: var(--ct-t-lead); max-width: var(--ct-measure); }

/* ---------- Call to action ---------- */
.cta {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 48px;
  padding: 13px 22px;
  border-radius: 999px;
  background: var(--swiftGreen);
  color: var(--paper);
  font-weight: 700;
  font-size: var(--ct-t-lead);
  text-decoration: none;
  transition-property: background-color, transform;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.cta:hover { background: #0EA152; text-decoration: none; }
/* 0.96 exactly. Anything below 0.95 reads as exaggerated. */
.cta:active { transform: scale(0.96); }
.cta--quiet {
  /* The secondary button now sits on a BARE section, straight on the photograph, where a bare
     `rest` tint (white at 0.07) disappeared completely and it read as underlined text rather than
     a control. Same two-layer treatment as .panel--tint: the dark step does the legibility work,
     the light step does the raised work. Verified by eye against the forest floor, which is the
     brightest thing on the page. */
  background-color: var(--scrim-soft);
  background-image: linear-gradient(var(--tint-rest), var(--tint-rest));
  /* The hairline is drawn inside the box, not added to it (P1 polish): a 1px border made this
     button 2px taller than the solid .cta beside it. */
  border: 0;
  box-shadow: inset 0 0 0 1px var(--hairline);
  color: var(--text);
}
.cta--quiet:hover {
  background-image: linear-gradient(var(--tint-press), var(--tint-press));
}
@media (prefers-reduced-motion: reduce) {
  .cta { transition-duration: 0ms; }
  .cta:active { transform: none; }
}
/* On the rail with the heading and prose above them (2026-08-10 composition system). */
.cta-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  margin: 24px auto 0;
  max-width: var(--ct-measure);
}
.cta-note { color: var(--textQuiet); font-size: var(--ct-t-body); margin: 0 auto; max-width: var(--ct-measure); }

/* ---------- Pager between explainer pages ---------- */
.pager {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  justify-content: space-between;
  margin: 8px 0 0;
}
.pager a {
  display: inline-flex;
  flex-direction: column;
  gap: 2px;
  min-height: 44px;
  padding: 12px 16px;
  border-radius: var(--radius-inner);
  text-decoration: none;
  color: var(--text);
  background-color: var(--tint-rest);
  transition-property: background-color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.pager a:hover { background-color: var(--tint-press); text-decoration: none; }
/* No text-transform. "Back" and "Next" name a direction, which is a job; shouting it is not.
   Every tracked uppercase mini-label on this site went the same way (Paul, 2026-07-26). */
.pager small { color: var(--textQuiet); font-size: var(--ct-t-small); font-weight: 600; letter-spacing: 0.04em; }
@media (prefers-reduced-motion: reduce) {
  .pager a { transition-duration: 0ms; }
}


/* The staged load-in (`.lift`, `.lift--2`) is gone: the content template has no load-time motion,
   which is a decision recorded in docs/content-pages/content.css and its README. */

/* ---------- Composition rail repairs (2026-08-10) ----------
   The `p` shorthand above (`margin: 0 0 14px`) silently resets the template's margin-inline
   centring, which is what put three different text rails on one page. These overrides put every
   flow text block back on the rail. The mobile padding rule exists because this site's sheets
   hold bare headings and paragraphs, not ct-bands, so the template's full-bleed rule (which
   zeroes sheet padding on the theory that bands re-supply it) left the text against the
   viewport edge below 640px. */
p { margin-inline: auto; }
h2, h3 { max-width: var(--ct-measure); margin-inline: auto; }
.steps, .caption, .prose ul, .prose ol { max-width: var(--ct-measure); margin-inline: auto; }
@media (max-width: 640px) {
  /* .ct-page prefix for specificity: content.css loads AFTER this file on the site pages, so an
     equal-specificity override here would lose by document order to its `padding: 0`. */
  .ct-page .ct-sheet, .ct-page .ct-well { padding: 20px 16px; }
}


/* ==HERO-BEGIN== THE EGG-WORLD HERO, ticket P1 (session 316, 2026-09-25), rebuilt around the
   egg2.js engine. ONE BLOCK, and every hero rule lives inside it. Nothing outside it was changed.

   AUTHORITY, in order: Paul's rulings of 25 Sep (no stars: the forest under the whole page, dark
   to black at the top and lightening as the reader scrolls past the egg; G2, below); his brief of
   9 Sep (an earth.ethanplus.ai layout with the carved egg world, the nav bar kept at the top, the
   left quarter to third holding the logo, two paragraphs and the store badges); ticket P1;
   docs/design/hero/SURFACE-BRIEF.md (input, not authority).

   WHAT CHANGED FROM T2.2, and why:
   - The egg is NEVER CLIPPED. T2.2 cropped the poles on Paul's 10 Sep ruling; ticket P1 asks for
     the whole egg, large, on the right. The engine fits the egg (plus a 4% margin) into whatever
     box its canvas has, so the frame is sized to the stage and the egg cannot be cut.
   - The canvas is transparent (egg2.js clears to alpha 0), so the T2.2 radial mask and its black
     pool are gone: over the forest the egg sits on the trees.
   - The copy is the brief's three things: the lockup, two paragraphs, the badges. No statement
     line and no caption.
   - (Retired by G2, below.) The track is 250dvh, so the sky's departure (25% to 100% of the range) takes 112dvh of
     scroll: at least one full viewport, as the ticket asks.

   G2 (session 316, Paul, 2026-09-25: "consider not having the stars at all, just have the forest
   background be there ALL the time, just darkened to the point that it's black which lightens as
   the user scrolls down past the planet"). Measured before this pass, at 1280 x 800: the stage
   pinned for 1200px of scroll, the copy gone by 460px, then 740px of scroll with nothing moving
   but the stars going out, then the egg snapping from a standstill to full scroll speed (400px
   between the 150% and 200% frames). At 390 x 844 the egg left with the sky still opening, and a
   24dvh band of bare forest followed the badges. So:
   - NO STARS. The forest is under the whole page from the first frame (`.glade` no longer fades
     in), and one black layer over it (`.sky-black`) is the darkness.
   - NO PIN, NO TRACK. The hero is one viewport and scrolls like the rest of the page, so the egg
     moves at exactly the scroll's speed from the first pixel: nothing to jump. The copy scrolls
     away beside the egg, fading only as it reaches the bar (F5, below).
   - THE LIGHT IS KEYED TO THE EGG (`--hero-egg`, on the frame) at every width: the black holds
     while the egg is whole on screen and lifts as the egg passes the top of the viewport, gone
     when the egg is. The egg recedes slightly (scale only) over the same range.
   - THE BAR'S MARK IS KEYED TO THE HERO LOCKUP (`--hero-mark`) at every width, arriving as the
     lockup slides under the bar; THE BAR'S GLASS IS KEYED TO THE NIGHT (F4): clear while the
     night is above one half, frosted once the forest shows.

   SCOPE. `.hero-home` is on the <html> element of web/site/index.html and nowhere else, so every
   rule here is inert on What, How, Why and Next. Deliberately unscoped: `timeline-scope` (a
   timeline name nobody else uses costs nothing) and the keyframes. */

/* MANDATORY: the sky and glade layers are `position: fixed` siblings of the track, not
   descendants, so without a scope the `--hero-egg` view timeline never reaches them. */
:root { timeline-scope: --hero-egg, --hero-mark; }

:root {
  /* THE ONE COLOUR VALUE THE HERO INTRODUCES. Black means black: `--canopy` is a green-tinted
     near-black that reads as forest and would pre-empt the transition. tokens.css is not edited. */
  --hero-sky: #000000;

  /* THE GUTTER IS ETHANPLUS'S, 4.5% (its header inset), floored at the chrome bar's own 20px.
     NAMED DIVERGENCE from surface brief point 3: the chrome bar's inner edge centres in a 1080px
     container, 440px in at 1920; a copy column starting there would leave a black slab on the
     left and push the egg against the right edge. The bar's mark only fades in after the hero's
     copy has left (motion rows below), so the two are never compared on screen. */
  --hero-gutter: max(20px, 4.5vw);

  /* The stage's top inset clears the fixed chrome bar (measured 65px at 1280 with the tagline
     hidden) with air to spare; the bottom inset keeps the egg's south pole off the fold. */
  --hero-top: 84px;
  --hero-bottom: 28px;

  /* THE CHROME BAR'S HEIGHT as the hero's ranges need it (G2), measured: 65px side by side at
     1280 x 800, 54px stacked at 390 x 844 (the stacked query below resets it). */
  --hero-bar: 65px;

  /* THE COPY'S SOFT EDGE (F5, F6): the width of the band under the bar in which the copy fades. A
     row of copy is at full strength until it is this far below the bar's bottom, and fully
     transparent at the bar's bottom. hero/page.js reads it too. */
  --hero-copy-fade: 40px;

  /* THE COPY COLUMN'S RIGHT EDGE, as a share of the viewport. The column itself is this minus the
     gutter, so "never wider than a third" holds for the whole left band, gutter included. */
  --hero-edge: 33vw;

  /* THE BADGE PAIR'S WIDTH, the copy column's floor (ticket P2, defect D2): Apple 119.66px +
     the 12px gap + Google's 40px lozenge at 564 / 168 = 134.29px, rounded up. From 768 to about
     900 a third of the viewport is narrower than this, and the badges wrapped. */
  --hero-pair: 270px;

  /* THE SPACE BETWEEN THE COPY AND THE EGG in the centred pair. */
  --hero-pair-gap: clamp(32px, 5vw, 88px);

  /* The poster's own aspect (553 x 648), which is also the engine's framing of the egg plus its
     4% margin. Used to size the frame so the canvas is no bigger than the egg needs. */
  --hero-egg-aspect: 553 / 648;
}

/* ---------- The forest and the night over it ---------- */
/* `.glade` is the EXISTING `.backdrop-photo` element with a second class: one forest image, one
   decode, one composited layer, at its normal DESIGN.md treatment (brightness lift and scrim) from
   the first frame. The night is one black layer above it and below all content; only its opacity
   moves, so the lightening composites. At the top it is 0.82 (Paul, 2026-10-28: "not ... pure black, just VERY darkened forest"; was 0.92, every stop scaled by the same factor so the lift keeps its shape), the egg floating
   in darkness, with the canopy's brightest leaves only just there on a good screen. */

.hero-home .sky {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
.hero-home .sky-black {
  position: absolute;
  inset: 0;
  background-color: var(--hero-sky);
  opacity: 0.82;
}

/* ---------- The hero: one viewport, in the page's own flow ---------- */
/* svh, not dvh: a phone's collapsing address bar changes dvh mid-scroll, and a centred stage sized
   in dvh moved the egg by half that change (about 28px on a 390 x 844 phone) while the reader
   scrolled. svh is the viewport with the bar showing, and it never changes under a scroll. */

.hero-home .hero-track {
  position: relative;
}
.hero-home .hero-stage {
  height: 100vh;
  height: 100svh;
  box-sizing: border-box;
  /* `clip`, not `hidden`: it cuts paint at the stage edge without making the stage a scroll
     container, so nothing here can become a second scroller. */
  overflow: clip;
  display: grid;
  /* THE COPY AND THE EGG ARE ONE CENTRED PAIR (Paul, 2026-10-28: "The text and the egg should be
     coupled, centred"). The copy column takes up to 420px, the egg column the egg's own width at the
     stage's inner height (capped so a short wide window keeps room for the copy), and the pair sits
     in the middle of the stage at every desktop width instead of spreading to its edges. */
  grid-template-columns:
    minmax(var(--hero-pair), 420px)
    min(calc((100svh - var(--hero-top) - var(--hero-bottom)) * 553 / 648), calc(100vw - 2 * var(--hero-gutter) - var(--hero-pair) - var(--hero-pair-gap)));
  column-gap: var(--hero-pair-gap);
  justify-content: center;
  align-items: center;
  padding: var(--hero-top) var(--hero-gutter) var(--hero-bottom);
}

/* ---------- The copy column ---------- */
/* NO GLASS IN THE HERO: nothing behind a panel here except black. No fill, border, blur or card.
   A 480px cap inside the column so it stays a column of text at 1920. */

.hero-home .hero-copy { min-width: 0; }
.hero-home .hero-copy > * { max-width: 420px; }

/* The MARK role, named and never written as a number (CANON 16). */
.hero-home .hero-mark {
  display: block;
  --wmSize: var(--wm-mark);
  /* The chrome bar's glass and its mark run on this (G2: at every width, not only stacked). */
  view-timeline-name: --hero-mark;
  view-timeline-axis: block;
}

/* THE PULSE IS THE SHARED SONAR (Paul, 2026-10-28: "The twote logo lockup should pulse using
   existing animation rules"). The markup puts `.is-pulsing` on the hero mark, and the lockup block
   above (the one gate, CANON 16's 6s cadence, reduced motion's static ring) does the rest. Nothing
   in this block animates the mark. */

/* Paragraph 1 is the claim in full ink; paragraph 2 the support in muted ink (#9CA3AF, 8.0:1 on
   black). `--textFaint` is about 3.5:1 on black and is banned in the hero. */
.hero-home .hero-say {
  margin: 28px 0 0;
  font-size: clamp(15px, 1.25vw, 18px);
  line-height: 1.6;
  color: var(--text);
  text-wrap: pretty;
}
.hero-home .hero-support {
  margin: 14px 0 0;
  font-size: clamp(14px, 1.05vw, 15.5px);
  line-height: 1.7;
  color: var(--textMuted);
  text-wrap: pretty;
}
/* The copy scrolls away over a night that is starting to lift, at every width now (G2), so it
   carries the on-photo text shadow the stacked layout already gave it. */
.hero-home .hero-say,
.hero-home .hero-support { text-shadow: 0 1px 12px rgba(15, 24, 20, 0.9); }

/* ---------- The badges ---------- */
/* NOT LINKS: Twote is on neither store, and a link that goes nowhere is a broken control. The
   alt text carries the state. EQUAL LOZENGES: Apple's artwork is its lozenge, so it
   renders at 40px; Google's PNG carries its clear space (a 168px lozenge in a 250px image), so it
   renders at 40 x 250 / 168. Measured, recorded in assets/badges/PROVENANCE.md.
   ONE BOX PER LOZENGE (ticket P2, defect D2). Google's clear space is 41 px of 250 on every side
   (lozenge 564 x 168 in 646 x 250), so its image takes a negative margin of 40 x 41 / 168 =
   9.76px all round: the transparent clear space still renders, but the badge's box is its 40px
   lozenge, the same as Apple's. The two boxes then share a top edge, a left edge when stacked,
   and one 12px gap in both directions.
   SIDE BY SIDE WHEREVER THE PAIR FITS (Paul's brief: "side-by-side"). The pair is
   119.66 + 12 + 134.29 = 265.95px (`--hero-pair`), and the copy column never drops below it
   (the stage grid, below). The row only stacks when its container is narrower than the pair,
   which is a viewport under about 306px in the one-column layout; it then stacks as two 40px
   boxes 12px apart, left edges aligned. */
.hero-home .hero-badges {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  margin-top: 32px;
}
/* GREYED AND INERT (Paul's card answer, 2026-10-28: "Greyed out as asked"): both stores' current
   official artwork at an equal 40px height (Google's rule: its badge no smaller than the others),
   desaturated and dimmed so it reads as not yet available. Neither is a link. */
.hero-home .hero-badge {
  display: block;
  pointer-events: none;
  cursor: default;
  filter: grayscale(1);
  opacity: 0.42;
}
.hero-home .hero-badge img { display: block; width: auto; height: 40px; }

/* ---------- The egg ---------- */

.hero-home .hero-globe {
  /* FLEX, not grid: in an auto grid track the frame's max-width: 100% is cyclic and is dropped,
     which let a tall viewport push the frame (and the egg) past the column. Measured at 1280 by
     cdp-overflow before this fix: frame 928px wide, right edge 1312. */
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  min-width: 0;
  min-height: 0;
}
/* The frame takes the whole column height and the egg's own aspect, capped at the column width.
   The engine fits the egg into its canvas with a 4% margin and centres it; the poster is the same
   framing at `object-fit: contain`, so the two register whatever the box, and neither can clip. */
.hero-home .hero-frame {
  position: relative;
  height: 100%;
  max-width: 100%;
  aspect-ratio: var(--hero-egg-aspect);
  /* The one timeline the night, the egg's recession and the JS driver all read (G2). */
  view-timeline-name: --hero-egg;
  view-timeline-axis: block;
}
.hero-home .hero-frame img.hero-poster,
.hero-home .hero-frame canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
/* THE POSTER IS THE LCP ELEMENT AND THE NO-WEBGL HERO. It is one of 24 RGBA frames rendered by
   the engine itself, one per UTC hour at that hour's clock yaw and the default light
   (tools/hero-posters.mjs; the head script in index.html picks the nearest hour): transparent
   outside the silhouette, so it sits on the night and on the forest alike with no blend and no
   mask. Once the canvas is live the poster is hidden outright. */
.hero-home .hero-frame img.hero-poster {
  object-fit: contain;
}
.hero-home .hero-frame.is-live img.hero-poster {
  visibility: hidden;
  transition: visibility 0s linear 600ms;
}
/* The canvas is transparent until its first frame, then cross-dissolves over the poster in
   300ms: two frames of the same object at the same yaw, so it is a load artefact rather than
   motion, and it runs under reduced motion too (where the engine then cuts to the clock-true yaw
   in one step instead of easing there). */
.hero-home .hero-frame canvas {
  opacity: 0;
  transition: opacity 600ms cubic-bezier(0.2, 0, 0, 1);
  cursor: grab;
}
.hero-home .hero-frame.is-live canvas { opacity: 1; }
/* A LOST CONTEXT IS HIDDEN AT ONCE (F4): fading it out showed the lost canvas as a grey box over
   the poster for the length of the fade. hero/page.js sets `is-lost` on webglcontextlost and
   clears it with the first frame after the restore, which then dissolves in as on load. */
.hero-home .hero-frame.is-lost canvas { visibility: hidden; transition: none; }
.hero-home .hero-frame canvas:active { cursor: grabbing; }

/* ---------- The nav bar over the sky ---------- */
/* THE SHARED BAR, UNCHANGED (ticket P2, defect D1). P1 dropped the bar's sheen gradient on this
   page with a `background-image: none` override, which left the homepage bar a flat band beside
   every other web/site page. That override is gone: the bar takes content.css's frosted recipe
   whole (fill, sheen, blur), matching What, How, Why and Next. Nothing here restyles it.
   IT NEVER TUCKS ON THIS PAGE (ticket P2, critique finding). The shell script tucks the bar while
   the masthead is in view, which on a content page keeps two marks apart. Here the masthead
   carries no mark (T2.2 deleted it) and sat below the hero (then a 250dvh track), so the script
   showed the bar over the sky, hid it for a viewport just as the forest arrived, then brought it
   back. Paul's brief keeps the bar at the top throughout; the one-lockup rule is kept by the
   mark's own fade (hero-chrome-in, below), not by tucking the bar.
   TRANSPARENT OVER THE DARK (ticket E3, F2 finding 9; keyed to the night in F4). Over the black
   the empty frosted bar read as a grey band (ethanplus runs a bare row there). The bar's fill,
   sheen, hairline and blur arrive as the night lifts below one half (hero-bar-in, below), so over
   the dark the bar is the nav and its mark alone, and over the forest it is content.css's frosted
   recipe, untouched. */
.hero-home .ct-chrome.is-tucked { opacity: 1; visibility: visible; translate: none; }

/* ---------- The scroll cue ---------- */
/* A hairline with a light travelling down it, at the bottom centre of the first screen. It belongs
   to the stage, so it scrolls away with the hero. Reduced motion keeps the line and drops the
   travel. */
.hero-home .hero-stage { position: relative; }
.hero-home .scroll-cue {
  position: absolute;
  left: 50%;
  bottom: max(16px, env(safe-area-inset-bottom));
  translate: -50% 0;
  width: 44px;
  height: 52px;
  display: grid;
  place-items: center;
  opacity: 0.55;
  transition: opacity 150ms var(--ease-out);
}
.hero-home .scroll-cue:hover,
.hero-home .scroll-cue:focus-visible { opacity: 0.9; }
.hero-home .scroll-cue span {
  position: relative;
  width: 1px;
  height: 36px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.22);
}
.hero-home .scroll-cue span::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 1px;
  height: 12px;
  background: rgba(255, 255, 255, 0.9);
  translate: 0 -12px;
}
@media (prefers-reduced-motion: no-preference) {
  .hero-home .scroll-cue span::after { animation: hero-cue-travel 2.4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
}
@keyframes hero-cue-travel {
  0% { translate: 0 -12px; }
  70%, 100% { translate: 0 36px; }
}

/* ---------- Below the hero: the app and its page ---------- */
/* Two real screens side by side on the forest, each with a short caption under it. No panel, no
   card, no glass: the screens are the content. Image outlines per the polish rules (white at 10%
   on a dark ground). */
.hero-home .show {
  position: relative;
  padding: clamp(72px, 12vh, 140px) 20px clamp(96px, 16vh, 180px);
}
.hero-home .show-pair {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
  gap: clamp(40px, 5vw, 72px);
  max-width: 1120px;
  margin-inline: auto;
}
/* The phone at its own aspect (225 x 500) and the browser at the page's desktop width (800 x 500):
   one height, so the two screens line up top and bottom. Under the pair's width the browser shot
   takes the column and keeps its aspect. */
.hero-home .show-item {
  margin: 0;
  width: 225px;
  max-width: 100%;
}
.hero-home .show-item--web { width: 800px; }
.hero-home .show-item img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 26px;
  outline: 1px solid oklch(1 0 0 / 0.1);
  outline-offset: -1px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.hero-home .show-item--web img { border-radius: 12px; }
.hero-home .show-item--web figcaption { max-width: 360px; }
.hero-home .show-item figcaption {
  margin-top: 24px;
  font-size: 15px;
  line-height: 1.6;
  color: var(--textMuted);
  text-wrap: pretty;
  text-shadow: 0 1px 12px rgba(15, 24, 20, 0.9);
}
.hero-home .show-item figcaption b {
  display: block;
  margin-bottom: 4px;
  font-size: 17px;
  font-weight: 600;
  color: var(--text);
}

/* ---------- Responsive ---------- */
/* 1280 and up: the column's right edge at 30vw. 768 to 1279: 33vw (the ticket's 32 to 33%).
   The lockup is MARK (58px under 1024, 84px from 1024), and the gap from its tagline to the first
   paragraph is 40px from 1024, 28px under it. */
@media (min-width: 1024px) {
  .hero-home .hero-say { margin-top: 40px; }
}
@media (min-width: 1280px) {
  :root { --hero-edge: 30vw; }
}

/* UNDER 768: ONE COLUMN, in the order lockup, egg, paragraphs, badges. The egg comes second
   because it is the reason to stay. `display: contents` lets the egg sit between two children of
   the copy column without a second copy of anything in the markup, and it is why the copy fade is
   written on the column's children: a `display: contents` box paints nothing, so a mask on the
   wrapper would silently stop working here.
   The stage takes at least a viewport and grows to hold the column (G2: no pin anywhere now, and
   no band of bare forest below the badges; the night lifts on the egg, see below).
   PORTRAIT AND SHORT VIEWPORTS STACK TOO (ticket P2, critique finding). Side by side on a portrait
   tablet (820 x 1180) the egg was capped by its column's width: a 560px egg centred in a 1068px
   stage, with about 230px of empty sky above and below it and the copy in a 234px column. On a
   landscape phone the pinned stage is shorter than the copy column and clips it. Stacked, the
   egg takes 58dvh and the copy its full measure. ethanplus bleeds its globe off the right edge
   instead; the egg is never clipped (ticket P1), so it stacks. From 640px wide the stacked column
   centres at 600px so the copy's left edge sits under the egg's frame rather than at the gutter.
   THE FOREST ARRIVES WHILE THE EGG IS IN VIEW (ticket E3): the transition runs on the egg's own
   view timeline at every width now (G2), so it plays against the egg wherever the copy above it
   wraps. */
@media (max-width: 767.98px), (max-aspect-ratio: 4/5), (max-height: 540px) {
  :root { --hero-edge: 100vw; --hero-bar: 54px; }
  .hero-home .hero-stage {
    height: auto;
    min-height: 100vh;
    min-height: 100svh;
    overflow: visible;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    padding: 88px 20px 40px;
  }
  .hero-home .hero-copy { display: contents; }
  .hero-home .hero-mark { order: 1; }
  .hero-home .hero-globe {
    order: 2;
    align-self: center;
    width: 100%;
    height: auto;
    margin-top: 20px;
  }
  .hero-home .hero-frame {
    height: auto;
    width: min(100%, calc(58vh * 553 / 648));
    width: min(100%, calc(58svh * 553 / 648));
  }
  .hero-home .hero-say { order: 3; margin-top: 20px; }
  .hero-home .hero-support { order: 4; }
  .hero-home .hero-badges { order: 5; }
}
@media (min-width: 640px) and (max-aspect-ratio: 4/5), (min-width: 640px) and (max-height: 540px) {
  .hero-home .hero-stage { padding-inline: max(20px, calc((100% - 600px) / 2)); }
}

/* ---------- The scroll transition ---------- */
/* ONE RANGE, THE EGG'S (G2). The night holds while the egg is whole on screen and lifts as the egg
   passes the top of the viewport: it starts when the frame's top is `--hero-top` below the
   viewport's top (side by side that is the first pixel of scroll) and is gone when the frame's
   bottom leaves (`exit 100%`). The departure keeps its three stops, 0.92 at the start, 0.82 at
   40%, 0 at the end: it holds, then opens, the shape of a dawn rather than a curtain. The egg
   recedes to 0.92 of its size over the same range, scale only, so it moves at the page's speed
   plus a slow shrink and never jumps. The copy scrolls away, and fades only at the bar, line by
   line (F5, F6). */

/* THE FIRST LEG EASES INTO THE SECOND (P1 polish): a linear 0.92 to 0.82 over 40% and then 0.82 to
   0 over 60% changed the fade's rate 5.4 times in one scroll pixel at the stop. The first leg now
   starts near rest and leaves at the second leg's rate (the bezier's end slope, 0.985 / 0.18 =
   5.47, times 0.10 / 0.4 is 1.368 per unit, against 0.82 / 0.6 = 1.367), so the night holds and
   then opens without a step. The control points were searched for the evenest rate: no 10px step
   of scroll changes it by more than 1.34 times at 390 or 1280, which is the floor for this stop
   (an exponential ramp, the evenest possible, gives 1.33). Both drivers read these per-keyframe
   curves, so the JS fallback needs nothing of its own. */
@keyframes hero-sky-out {
  0% { opacity: 0.82; animation-timing-function: cubic-bezier(0.55, 0.025, 0.82, 0.015); }
  40% { opacity: 0.73; animation-timing-function: linear; }
  100% { opacity: 0; }
}
@keyframes hero-globe-recede { from { scale: 1; } to { scale: 0.92; } }
/* `visibility` rides the fade so the bar's home link is not a keyboard stop while it is invisible
   (ticket P2, audit finding): hidden only before the range, visible from its first frame. */
@keyframes hero-chrome-in { from { opacity: 0; visibility: hidden; } to { opacity: 1; visibility: visible; } }
/* The bar's frosted recipe arrives as the night lifts (ticket E3, F4). Only the `from` frame is written: the
   `to` frame is the bar's own computed style, content.css's recipe, so nothing here restates it.
   `none` is the only backdrop-filter value in this block: it takes the glass away over the dark.
   THE BLUR SWITCHES ON IN ONE STEP, at the end of the range (hero-bar-glass, `steps(1, end)`): a
   blur radius interpolated on scroll re-rasterises the bar's backdrop every frame, and measured at
   1280 x 800 it took the scroll p99 from 7.2 ms to 14 to 21 ms (session 316 e3 bench, hero data archive). */
/* THE SHEEN SLIDES IN, IT DOES NOT POP (P1 polish). `background-image: none` does not interpolate
   with a gradient, so the sheen switched on in one frame halfway through the range. It now starts
   one bar height above the bar (`background-position`, with the layer unrepeated so nothing wraps
   in from below) and arrives at its own place: the same recipe at rest, continuous on the way. */
.hero-home .ct-chrome { background-repeat: no-repeat; }
@keyframes hero-bar-in {
  from { background-color: transparent; background-position: 0 calc(-1 * var(--hero-bar)); border-bottom-color: transparent; }
}
/* THE HERO LOCKUP LEAVES AS THE CHROME MARK ARRIVES (P1 polish). The lockup kept full opacity as it
   slid under the bar, so for about 50px of scroll both marks showed (at 390, the hero tagline at
   51 to 73px under a 54px bar with the chrome mark at 0.64). It now fades out over its own slide
   under the bar, opacity only: the lockup itself is untouched. */
@keyframes hero-mark-out { to { opacity: 0; } }
/* THE COPY LEAVES BEFORE IT REACHES THE LOGO (F5; Paul, 2026-09-25: "Fade copy under bar"). The bar
   is clear over the dark, so the paragraphs and badges scrolled up through the chrome mark: at 1280
   from about 300 to 620px of scroll. The fade ends at the bar's bottom, 5px below the logo's
   (measured: logo 4 to 60px under a 65px bar at 1280, 4 to 49px under 54px at 390).
   LINE BY LINE, NOT A PARAGRAPH AT ONCE (F6; Paul, 2026-09-25: "Line by line"). F5 faded each
   element's whole opacity as its top reached the bar, which dimmed lines still 60 to 100px below
   it. Each of the three now carries a mask with a soft edge that holds still in the viewport:
   transparent at the bar's bottom, full strength `--hero-copy-fade` below it, so a line fades only
   as it reaches the bar and the text still being read stays whole. `--hero-cut` is that edge
   measured from the element's own top (negative while the element is below the band). It is
   registered as a length so it interpolates; the element's own view timeline drives it (below),
   or hero/page.js writes it on the JS path; its resting value leaves the mask fully opaque.
   The mask image is 24px larger than the box all round, with `mask-clip: no-clip`, so the
   paragraphs' 12px text shadow is not cut off square at the box's edges. On the elements
   themselves, which the stacked layout's `display: contents` needs. */
@property --hero-cut {
  syntax: '<length>';
  inherits: false;
  initial-value: -40px;
}
.hero-home .hero-say,
.hero-home .hero-support,
.hero-home .hero-badges {
  --hero-cut: calc(-1 * var(--hero-copy-fade));
  mask-image: linear-gradient(180deg,
    transparent calc(var(--hero-cut) + 24px),
    #000 calc(var(--hero-cut) + 24px + var(--hero-copy-fade)));
  mask-position: -24px -24px;
  mask-size: calc(100% + 48px) calc(100% + 48px);
  mask-repeat: no-repeat;
  mask-clip: no-clip;
}
/* The edge runs 1000px down the element from where it first meets the band: longer than any of
   the three is tall plus the band, so each is wholly gone before the value comes to rest. */
@keyframes hero-copy-edge {
  from { --hero-cut: calc(-1 * var(--hero-copy-fade)); }
  to { --hero-cut: calc(1000px - var(--hero-copy-fade)); }
}
@keyframes hero-bar-glass {
  from { -webkit-backdrop-filter: none; backdrop-filter: none; }
}

@supports (animation-timeline: scroll()) {
  /* `html.no-scroll-timeline` is set by hero/page.js on `?nocss=1` (and where the timeline is
     unsupported), which is how the JS driver is captured against this one. */
  .hero-home:not(.no-scroll-timeline) .sky-black,
  .hero-home:not(.no-scroll-timeline) .hero-globe {
    animation-timeline: --hero-egg;
    animation-range: exit calc(-1 * var(--hero-top)) exit 100%;
  }
  .hero-home:not(.no-scroll-timeline) .sky-black,
  .hero-home:not(.no-scroll-timeline) .hero-globe,
  .hero-home:not(.no-scroll-timeline) .ct-chrome,
  .hero-home:not(.no-scroll-timeline) .ct-chrome .brand-home,
  .hero-home:not(.no-scroll-timeline) .hero-mark,
  .hero-home:not(.no-scroll-timeline) .hero-say,
  .hero-home:not(.no-scroll-timeline) .hero-support,
  .hero-home:not(.no-scroll-timeline) .hero-badges {
    animation-duration: auto;
    /* REQUIRED ON EVERY SCROLL-LINKED ROW, or the night snaps back past the end of its range. */
    animation-fill-mode: both;
    animation-timing-function: linear;
  }
  .hero-home:not(.no-scroll-timeline) .sky-black { animation-name: hero-sky-out; }
  /* THE EGG NEVER FADES. It is the subject and it leaves by receding, not by dissolving. */
  .hero-home:not(.no-scroll-timeline) .hero-globe { animation-name: hero-globe-recede; }
  /* THE BAR'S GLASS RUNS ON THE NIGHT (F4; Paul: transparent over the dark, frosted over the
     forest). Its fill and sheen fade in, and its blur steps on at the end, over 65% to 80% of the
     night's range on the egg's timeline, where the night falls from 0.48 to 0.27: the bar is clear
     while the night is above one half. A point f of that range is `exit f * 100% - (1 - f) * top`.
     THE CHROME MARK RUNS ON THE HERO LOCKUP'S OWN TIMELINE (`--hero-mark`) at every width: `exit 0%`
     is the lockup's top at the viewport's top, and the mark arrives as the lockup slides under the
     bar (its top from 0 to -26px), over a clear bar while the night holds. ONE LOCKUP ON SCREEN AT
     A TIME. The nav list stays where it is throughout. */
  .hero-home:not(.no-scroll-timeline) .ct-chrome {
    animation-name: hero-bar-in, hero-bar-glass;
    animation-timeline: --hero-egg;
    animation-range: exit calc(65% - 0.35 * var(--hero-top)) exit calc(80% - 0.2 * var(--hero-top));
    animation-timing-function: var(--ease-out), steps(1, end);
  }
  .hero-home:not(.no-scroll-timeline) .ct-chrome .brand-home {
    animation-timeline: --hero-mark;
    animation-name: hero-chrome-in;
    animation-range: exit 0% exit 26px;
    animation-timing-function: var(--ease-out);
  }
  /* The hero lockup fades out, linear, from the bar's bottom edge to just past the viewport's top.
     Its end, (--hero-bar + 33px) / 19 past `exit 0%`, puts its 5% point on the chrome mark's 5%
     point (1.65px into that ease-out), so the two marks cross at 5% each: never both showing, never
     a gap with neither. */
  .hero-home:not(.no-scroll-timeline) .hero-mark {
    animation-name: hero-mark-out;
    animation-timeline: --hero-mark;
    animation-range: exit calc(-1 * var(--hero-bar)) exit calc((var(--hero-bar) + 33px) / 19);
  }
  /* `exit 0%` is the element's top at the viewport's top, so the edge starts to move as the
     element's top reaches the bar's bottom plus `--hero-copy-fade`, and then moves down the element
     one pixel per pixel of scroll: in the viewport it stays on the bar's bottom. */
  .hero-home:not(.no-scroll-timeline) .hero-say,
  .hero-home:not(.no-scroll-timeline) .hero-support,
  .hero-home:not(.no-scroll-timeline) .hero-badges {
    animation-name: hero-copy-edge;
    animation-timeline: view(block);
    animation-range: exit calc(-1 * (var(--hero-bar) + var(--hero-copy-fade))) exit calc(1000px - var(--hero-bar) - var(--hero-copy-fade));
  }
}

/* THE JS DRIVER plays THE SAME KEYFRAMES. Each layer declares its animation PAUSED, with a delay
   and duration that are the ranges above as fractions of one second; hero/page.js seeks them all
   with `animation.currentTime = progress * 1000` in one rAF per scroll event, and writes one
   custom property, `--hero-p`, as its readable state. No curve is written down twice. (Seeking
   a negative animation-delay does not work: a paused animation holds a hold-time.)
   The night, the egg and the bar's glass take the egg's progress (the frame's top travelling from
   `--hero-top` below the viewport's top until its bottom leaves): the night and the egg over the
   whole second, the glass over 0.65 to 0.80 of it, the CSS path's share. The chrome mark and the
   hero lockup's fade take one whole second each, and hero/page.js hands each its own progress over
   the CSS path's own range, read at the current `--hero-bar`. The copy's edge (F6) is no animation
   on this path: hero/page.js writes `--hero-cut` on each of the three from its own top. */
.hero-home.no-scroll-timeline .sky-black,
.hero-home.no-scroll-timeline .hero-globe,
.hero-home.no-scroll-timeline .ct-chrome,
.hero-home.no-scroll-timeline .ct-chrome .brand-home,
.hero-home.no-scroll-timeline .hero-mark {
  animation-timeline: auto;
  animation-play-state: paused;
  animation-fill-mode: both;
  animation-timing-function: linear;
}
.hero-home.no-scroll-timeline .sky-black {
  animation-name: hero-sky-out;
  animation-delay: 0s;
  animation-duration: 1s;
}
.hero-home.no-scroll-timeline .hero-globe {
  animation-name: hero-globe-recede;
  animation-delay: 0s;
  animation-duration: 1s;
  will-change: scale;
}
.hero-home.no-scroll-timeline .ct-chrome {
  animation-name: hero-bar-in, hero-bar-glass;
  animation-delay: 0.65s;
  animation-duration: 0.15s;
  animation-timing-function: var(--ease-out), steps(1, end);
}
.hero-home.no-scroll-timeline .ct-chrome .brand-home {
  animation-name: hero-chrome-in;
  animation-delay: 0s;
  animation-duration: 1s;
  animation-timing-function: var(--ease-out);
}
.hero-home.no-scroll-timeline .hero-mark {
  animation-name: hero-mark-out;
  animation-delay: 0s;
  animation-duration: 1s;
}

/* REDUCED MOTION: A STATIC ARRANGEMENT (G2). Nothing is animated by the scroll: no recession, no
   pulse, and no lightening either. The fixed night is taken away and the same darkness is painted
   onto the hero itself instead, a black that holds over the egg and fades to the forest over the
   half viewport below the hero, so it scrolls away with the page like any other part of it. The
   bar's mark still arrives with the lockup, and its glass on the egg's range (states, not
   motion); the lockup still fades at the bar and the copy's edge still holds there, which keeps
   them off the logo. Each rule names both driver selectors because `:not(.no-scroll-timeline)`
   carries a class's specificity, and a plain `.hero-home .hero-globe` here would lose to the
   driver rules above (a T2.2 bug). */
@media (prefers-reduced-motion: reduce) {
  .hero-home .sky { display: none; }
  .hero-home .hero-track::after {
    content: '';
    position: absolute;
    inset: 0 0 -50vh;
    inset: 0 0 -50svh;
    z-index: -1;
    pointer-events: none;
    background: linear-gradient(180deg,
      rgba(0, 0, 0, 0.82) 0%,
      rgba(0, 0, 0, 0.82) 45%,
      rgba(0, 0, 0, 0) 100%);
  }
  .hero-home:not(.no-scroll-timeline) .hero-globe,
  .hero-home.no-scroll-timeline .hero-globe { animation-name: none; scale: none; }
  /* egg2.js switches drag off under reduced motion (its `interactive && !reducedMotion` gate), so
     the canvas must not advertise it. */
  .hero-home .hero-frame canvas,
  .hero-home .hero-frame canvas:active { cursor: default; }
}
/* ==HERO-END== */
