/* ---------------------------------------------------------------------
   Claldred public page frame - public-page.js's stylesheet
   ---------------------------------------------------------------------
   Everything around the content of a public page: the colours and both
   themes, the sky, the banner, the frosted top bar, the corner buttons,
   the menu panel, the sign-in box, the glass cards, the headings, and the
   phone and home-screen adjustments. Loaded by every public page: /index,
   /request, /credits, /how-it-works, /about and /faq.

   WHERE IT CAME FROM. request.html's own <style> block, lifted on 8 Oct
   2026 in its original order, less the rules only /request's form and
   only /index's carousel, status line and steps use. No rule kept was
   rewritten.

   WHERE THE REASONS ARE. Here, with the rules. On 9 Oct 2026 (finding 7
   in docs/package-f.md) /request stopped carrying its own copy and links
   this file, and each rule's comments moved here with it, word for word.
   They were written on /request, so "this page" in them means /request.
   /request's own <style> now holds only its form and its splash.
   /index followed the same day, the second half of finding 7: its own
   <style> keeps its hero, carousel, status line, Getting Started and
   footer, and the few values where it differs from these (its wider
   content column, its card size, its heading's top margin). Linked first,
   this file loses to a page's own rules wherever both set the same thing.

   The last section, after the copy, is what the new pages add. */

/* flip easter egg / cloud-pass extension — registers --light-glow-
   alpha as an animatable <number>, which plain CSS custom properties
   are NOT by default (they don't interpolate smoothly in animations/
   transitions without this — they'd just jump between values). Lets
   the cloud-pass effect (see body.cloud-pass-active further down)
   dim the actual light source itself, not just the glints it casts,
   while keeping this as part of body's own background (rather than
   a separate positioned element) — background always paints behind
   all page content unambiguously, avoiding any stacking-order risk
   a new element would introduce. */
@property --light-glow-alpha {
  syntax: '<number>';
  inherits: true;
  initial-value: 0.26;
}
:root {
  /* Tells the browser which native form-control theme to use for
     things it renders itself — checkboxes, scrollbars, etc. —
     independent of accent-color (which only tints the CHECKED fill,
     not the unchecked box's own background/border). "light dark"
     here means "follow the OS," which is right as the default; the
     two explicit overrides below take over once the toggle sets an
     explicit theme, so a manual dark-mode toggle actually darkens
     native controls too, not just this page's own CSS. */
  color-scheme: light dark;
  /* iOS Safari's own status bar/toolbar area is translucent and
     shows live page content through it — a fixed header that
     doesn't extend its own background/blur into that region (and
     whose content doesn't shift below it) shows raw, unblurred
     content peeking through at the very top on scroll, and/or sits
     partly under the notch. Requires viewport-fit=cover on the
     <meta name="viewport"> tag (see <head>) to be non-zero at all —
     without it this always resolves to 0px, silently. Falls back to
     0px explicitly (not left bare) so every consumer below works
     identically on non-notched devices and in browsers that don't
     support env() at all. */
  --safe-top: env(safe-area-inset-top, 0px);
  /* How far iOS's own status-bar treatment bleeds PAST the safe-area
     inset in standalone mode.

     Carson's report, with a measured screenshot: even with every
     floating button already moved clear of env(safe-area-inset-top)
     (see the PWA / STANDALONE block at the very end of this
     stylesheet, which is what did that), the top third or so of the
     Log in, "i" and theme-toggle buttons still read as washed out
     and slightly smeared, permanently, at every scroll position.

     Cause: iOS does not stop its status-bar glass at the inset
     boundary. The inset is where the OPAQUE part ends; below it the
     treatment feathers out over another stretch of the page, and
     anything sitting in that stretch is composited through it.
     Measured off the screenshot (a 375pt-wide device, 50px inset):
     content only came back to full strength ~34px past the inset —
     about 16px below where the buttons currently start.

     This value is that overhang minus the 18px the buttons already
     sit below the inset: the EXTRA room the top of the page needs so
     nothing lands in the feathered region at all. 24px rather than
     the bare 16px measured, for margin — the exact reach isn't
     documented by Apple, and it costs nothing but a few px of top
     padding to stay well clear of it.

     This is the only number to touch when tuning: the floating
     buttons, the frost bar's own height, the credits panel and
     body's own top padding all add it, so they move together and
     every gap between them is preserved exactly as designed.

     0 outside standalone, deliberately: a Safari tab has no OS glass
     over it at all (env(safe-area-inset-top) is 0 there too), so
     every rule below that adds this variable is provably a no-op in
     tab mode. */
  --os-glass-bleed: 0px;
  --bg: #FAFAF9;
  --bg-rgb: 250, 250, 249;
  --surface: #FFFFFF;
  --surface-rgb: 255, 255, 255;
  --border: #E7E5E2;
  --text: #1C1C1E;
  --text-dim: #6B6B6E;
  --accent: #3E6C7A;
  --accent-rgb: 62, 108, 122;
  --accent-soft: #E4EEF0;
  /* Brand-logo theming, driven per frame by the theme animation
     rather than by a data-theme selector — see COLOR_SPECS and the
     --logo-dim write in runTransition(). These resting values must
     match what the animation last wrote, since finish() clears the
     inline overrides and hands straight back to this cascade.
     --logo-dim: 0 = full-color logos, 1 = the muted dark treatment. */
  --logo-dim: 0;
  --abr-logo-fill: #000000;
  --light-rgb: 255, 205, 130;
  /* Base intensity for the light source glow in body{}'s own
     background — the cloud-pass effect animates this down slightly
     and back (see body.cloud-pass-active further down) to represent
     something briefly passing in front of the light itself, not
     just the glints it casts on glass elements elsewhere. */
  --light-glow-alpha: 0.26;
  --banner-danger-bg: #C1554B;
  --banner-danger-text: #FFFFFF;
  --banner-warning-bg: #D68A2E;
  --banner-warning-text: #FFFFFF;
  --banner-announce-bg: #3E9A6C;
  --banner-announce-text: #FFFFFF;
  --fog-rgb: 232, 221, 200;
  --fog-visible-mult: 0;
  --ok: #3E9A6C;
  --down: #C1554B;
  --maint: #7A6FC4;
  --pending: #C79A3E;
  --unknown: #B8B6B1;
  --radius: 16px;
  --shadow: rgba(0, 0, 0, 0.06);

  /* Responsive tokens — each clamp's minimum equals the original mobile
     value exactly, so phones render pixel-identical to before. Growth
     only becomes visible past roughly tablet width, capping at a
     comfortable desktop size rather than growing without bound. */
  --content-w: clamp(460px, 34vw + 320px, 900px);
  --card-w: clamp(168px, 12vw + 116px, 240px);
  --card-h: calc(var(--card-w) * 1.06);
  --carousel-h: calc(var(--card-h) + 34px);
  --h1-size: clamp(34px, 1.6vw + 27px, 44px);
  --tagline-size: clamp(16px, 0.4vw + 14.5px, 18px);
  --section-gap: clamp(44px, 3vw + 20px, 64px);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0C0D0E;
    --bg-rgb: 12, 13, 14;
    --surface: #17181A;
    --surface-rgb: 23, 24, 26;
    --border: #2A2B2C;
    --text: #F2F2F0;
    --text-dim: #92928F;
    --accent: #7FB2C0;
    --accent-rgb: 127, 178, 192;
    --accent-soft: #1B2A2E;
    --logo-dim: 1;
    --abr-logo-fill: #FFFFFF;
    --light-rgb: 205, 222, 236;
    --fog-rgb: 125, 138, 152;
    --fog-visible-mult: 1;
    --banner-danger-bg: #A8443C;
    --banner-danger-text: #FFF5F3;
    --banner-warning-bg: #B8722A;
    --banner-warning-text: #FFF8EE;
    --banner-announce-bg: #2E7A54;
    --banner-announce-text: #F0FFF5;
    --ok: #58C08A;
    --down: #E0847B;
    --maint: #A79EE0;
    --pending: #DDB466;
    --unknown: #4A4A48;
    --shadow: rgba(0, 0, 0, 0.35);
  }
}
/* explicit dark override (via the toggle), independent of system preference */
html[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0C0D0E;
  --bg-rgb: 12, 13, 14;
  --surface: #17181A;
  --surface-rgb: 23, 24, 26;
  --border: #2A2B2C;
  --text: #F2F2F0;
  --text-dim: #92928F;
  --accent: #7FB2C0;
  --accent-rgb: 127, 178, 192;
  --accent-soft: #1B2A2E;
  --logo-dim: 1;
  --abr-logo-fill: #FFFFFF;
  --light-rgb: 205, 222, 236;
  --fog-rgb: 125, 138, 152;
  --fog-visible-mult: 1;
  --banner-danger-bg: #A8443C;
  --banner-danger-text: #FFF5F3;
  --banner-warning-bg: #B8722A;
  --banner-warning-text: #FFF8EE;
  --banner-announce-bg: #2E7A54;
  --banner-announce-text: #F0FFF5;
  --ok: #58C08A;
  --down: #E0847B;
  --maint: #A79EE0;
  --pending: #DDB466;
  --unknown: #4A4A48;
  --shadow: rgba(0, 0, 0, 0.35);
}
/* Ported from account.html, in two stages — first for the instant-
   toggle path only (the per-device benchmark's fallback), then
   later extended to this page's own real, animated runTransition()
   sequence too, once it turned out that sequence had never gotten
   this same optimization applyColors() below already justifies
   (see runTransition()'s own comment for the full reasoning).
   #theme-icon excluded here specifically because — unlike
   account.html, where paintIcon()'s own fade was rewritten to be
   driven directly inside frame() instead of via a CSS transition —
   this page's paintIcon() still fades the icon via a real CSS
   transition (icon.style.opacity + a transitionend listener).
   Suppressing that transition here would mean transitionend never
   fires, and the icon would get stuck at opacity:0 permanently on
   every real toggle — the exact bug this exclusion exists to avoid,
   not a redundant leftover from account.html's own now-superseded
   approach. */
html.theme-animating,
html.theme-animating *:not(#theme-icon),
html.theme-animating *::before,
html.theme-animating *::after {
  transition: none !important;
}
/* explicit light override (via the toggle) — needed so switching
   back to light from an explicit dark choice also puts native
   controls back to light, rather than leaving them stuck dark. */
html[data-theme="light"] { color-scheme: light; }

* { box-sizing: border-box; }
html { -webkit-font-smoothing: antialiased; }
body {
  margin: 0;
  min-height: 100vh;
  /* The wave-band's full-bleed breakout (100vw + negative 50vw
     margins) can introduce a few px of horizontal overflow on
     browsers where 100vw includes the vertical scrollbar's width.
     Clipping it here is the standard safety net for that technique
     — it doesn't affect any other layout since nothing else on the
     page intentionally exceeds the viewport width. */
  overflow-x: hidden;
  /* Carson's report: this page specifically could be dragged
     left/right by hand on a real touch device — the whole page
     sliding sideways under the finger during what was meant to be an
     ordinary up/down scroll. account.html hit exactly this and
     already carries this same line; this is that fix ported over
     verbatim, comment and all, so the two pages stay identical
     rather than solving it two different ways.

     A different problem from the overflow-x fix just above, and the
     reason that one didn't already cover it: overflow-x: hidden
     stops a genuine horizontal SCROLLBAR from appearing, and there
     is no horizontal overflow on this page to begin with (checked —
     document scrollWidth equals clientWidth at phone widths). What's
     being felt is Mobile Safari's OWN native drag/rubber-band
     response to a sideways touch, which happens whether or not
     anything actually overflows.

     pan-y tells the browser to only ever recognize VERTICAL panning
     as a native touch gesture here. It does not touch JavaScript's
     own touchmove listeners at all — the pull-to-refresh gesture at
     the bottom of this file reads touch coordinates directly and is
     completely unaffected — so this only removes the browser's own
     default horizontal-drag behavior, which is exactly what was
     being felt as unwanted sideways movement. Nothing on this page
     scrolls horizontally, so nothing legitimate is lost; if a
     genuinely horizontally-scrollable element is ever added here,
     give that element its own touch-action rather than removing
     this line. */
  touch-action: pan-y;
  /* One continuous background for the whole page — not a separate
     boxed element sitting on top of it. A light glow near the
     top-right corner (by the theme toggle) and a fog band further
     down are just two more layers in this same gradient stack, so
     there's no edge anywhere for a seam to form at; it's the same
     paint operation as var(--bg) itself, just with two soft layers
     above it. --fog-start is set once on load/resize (see the script
     near the theme toggle) to the wave band's actual position in the
     page, so the fog lines up with the water regardless of how much
     content sits above it or how tall the viewport is. */
  background:
    radial-gradient(640px 440px at var(--light-x, calc(100% - 24px)) var(--light-y, -70px), rgba(var(--light-rgb), var(--light-glow-alpha)), transparent 65%),
    linear-gradient(to bottom,
      transparent 0%,
      transparent var(--fog-start, 78%),
      rgba(var(--fog-rgb), calc(0.4 * var(--fog-visible-mult, 1))) calc(var(--fog-start, 78%) + 7%),
      rgba(var(--fog-rgb), calc(0.1 * var(--fog-visible-mult, 1))) 100%),
    var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  display: flex;
  justify-content: center;
  /* Bottom padding used to be 48px, matching the top, back when the
     footer text was the literal last thing on the page — that gap sat
     naturally below it. Now that .wave-band lives after the footer as
     the new last thing, that same padding pushed empty white space
     in BETWEEN the footer and the animation, and left the animation
     floating above the true bottom of the page instead of ending
     flush with it. Zeroed out so the wave-band's own bottom edge is
     the actual end of the page — the .wave-band rule below still adds
     its own small margin-top, so the footer keeps its breathing room
     from above; this only removes the redundant padding stacking below. */
  /* --body-pad-top is the single source of truth for this page's own
     top padding, and --banner-h (set by the JS from the banner's
     measured height, 0px when there is no active message) is added on
     top of it — that sum is what makes room for the site-wide banner,
     which is fixed to the top of the viewport and so takes up no space
     of its own. Every fixed element up there offsets from the same
     --banner-h, so the banner, the frost bar, the corner buttons and
     ordinary page content all move together, at every breakpoint and
     whether the banner is one line or five. Keeping the padding in one
     variable is also what stopped it and the banner drifting apart the
     way they used to (account.html's 82px breakpoint and both
     standalone/safe-area blocks each restated the numbers by hand, and
     one of them was wrong): any future change belongs here only. */
  --body-pad-top: 72px;
  padding: calc(var(--body-pad-top) + var(--banner-h, 0px)) 0 0;
  transition: background-color 2400ms ease, color 0.3s ease;
}

/* ---------- Stars (dark mode only) ----------
   Lives in document space (absolute, sized to the full page height in
   JS — see the script below), not the viewport. It was originally
   `position: fixed` so it wouldn't need any height measurement, but
   that meant it stayed pinned to the screen regardless of where the
   wave actually was in the document — scroll far enough and stars
   would sit right on top of the water. Document space fixes that, and
   the mask-image below fades stars out before reaching --fog-start
   (the same measurement driving the fog band), so stars stop just
   above where the fog starts thickening rather than at a hard edge.
   Opacity fades in/out with the theme over the same 2.4s as the light
   glow, so dark mode reads as one dusk transition. Each star is a
   tiny div with its own CSS keyframe animation running on the
   compositor — cheap even at 100+ stars, since nothing here re-runs
   per frame in JS. */
.stars {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 2400ms ease;
  -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
  mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .stars { opacity: 1; }
}
html[data-theme="dark"] .stars { opacity: 1; }
/* Carson's report, and a genuinely free fix (site-wide — same
   pattern on account.html): .stars sits at opacity:0 by default
   (light mode) but every individual .star kept twinkling underneath
   regardless. No !important needed here specifically — .star's own
   inline style only ever sets animationDuration/animationDelay
   (longhand, via JS), never the full shorthand, so a plain rule is
   enough to both pause it here and un-pause it in the dark-mode
   overrides just below, at the same specificity the opacity rules
   themselves already use. */
.star { animation-play-state: paused; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .star { animation-play-state: running; }
}
html[data-theme="dark"] .star { animation-play-state: running; }

/* ---------- Drifting clouds (light mode's own atmosphere) ----------
   Carson's ask: dark mode has stars and shooting stars giving it a
   living, calm quality; light mode had nothing above the fog line
   but a static glow. These are the day-mode counterpart — same
   "complementary crossfade" relationship as .stars has with dark
   mode, just inverted (visible in light, fades out toward dark).
   Same masking technique as .stars too, reusing --fog-start as-is
   so clouds fade out before reaching the horizon rather than
   drifting into the wave band.
   Pure CSS transform animation per cloud, GPU-composited, no JS
   render loop — cheaper than the dot-wave or stars canvas/DOM
   animations already running on this page, not more expensive. */
.clouds {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  opacity: 1;
  transition: opacity 2400ms ease;
  -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
  mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .clouds { opacity: 0; }
}
html[data-theme="dark"] .clouds { opacity: 0; }
/* Carson's report, and a genuinely free fix — see account.html's own
   copy of this comment for the full reasoning: .clouds fading to
   opacity:0 in dark mode never stopped each individual .cloud's own
   animation:...infinite from running (and re-blurring) every frame
   regardless, just invisibly. !important needed here specifically
   since .cloud sets the full animation shorthand inline. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .cloud { animation-play-state: paused !important; }
}
html[data-theme="dark"] .cloud { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .clouds { opacity: 1; }
  html[data-theme="dark"] .clouds { opacity: 0; }
  .cloud { animation: none !important; }
}

/* Each cloud is a small CLUSTER of overlapping "puff" shapes, not
   one smooth ellipse — Carson's own diagnosis after seeing the
   first version: a single blob reads as a smear, not a cumulus
   cloud, which needs an irregular, lumpy silhouette. .cloud is just
   the cluster's positioning/timing/blur shell (unstyled otherwise);
   .puff is where the actual lit/shadow gradients live, one pair per
   puff, positioned by the .cloud--a/b/c/d template rules further
   down. filter:blur on .cloud (not on each .puff individually)
   blurs the WHOLE cluster as one flattened, cohesive soft shape —
   that's a real CSS behavior, not a shortcut: a filter renders its
   entire subtree to an offscreen buffer first and blurs that
   combined result, so overlapping puffs merge into one soft
   silhouette rather than reading as several separately-blurred
   circles. Also cheaper than blurring each puff on its own (one
   blur operation per cloud, not per puff).
   --cloud-light-x-frac (set in JS, defaulting near the resting
   light's own side) shifts the highlight/shadow layers' focus
   together — one shared approximation of "which way the light is,"
   not true per-puff angle tracking, same reasoning as before.
   .puff's background is two layers, not one: a neutral white/pale
   base (the cloud's own material color, centered, independent of
   light direction) UNDER a warm/cool tint straight from
   rgba(var(--light-rgb)...) (the same variable already warming/
   cooling through the day/night toggle, so cloud warmth still
   follows the toggle for free). Carson's own "too saturated, too in
   your face" feedback on an earlier version, where the tint WAS the
   entire visible color with nothing neutral underneath — real
   clouds read as mostly white/pale gray with only a SUBTLE colored
   cast where the light or shadow falls, not fully tinted throughout
   their whole body. --cloud-lit-alpha's default and per-instance
   values were also lowered accordingly, now that the white base
   carries most of the cloud's actual visibility. --cloud-base-alpha
   (new) makes that white base itself vary by depth tier too, not
   just the tint — Carson's ask for the far tier to genuinely read
   as distant rather than just small: real atmospheric haze doesn't
   only desaturate a far object's own color, it thins out its whole
   presence, so a convincingly distant cloud should look thin and
   ghostly overall, not just a smaller solid one. */
.cloud {
  position: absolute;
  filter: blur(var(--cloud-blur, 14px));
}
.puff {
  position: absolute;
  border-radius: 50%;
  background:
    radial-gradient(ellipse at calc(var(--cloud-light-x-frac, 0.7) * 100%) 30%,
      rgba(var(--light-rgb), var(--cloud-lit-alpha, 0.3)), transparent 58%),
    radial-gradient(ellipse at 48% 42%, rgba(255, 255, 255, var(--cloud-base-alpha, 0.52)), transparent 72%);
}
.puff::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  /* Fixed cool gray-blue rather than tracking --light-rgb itself —
     true to how shadows actually read outdoors: the lit side takes
     the sun/moon's own color, but a shaded side is mostly lit by
     scattered ambient sky light instead, which stays comparatively
     cool and neutral regardless of the direct light's own color.
     Alpha dropped from an earlier 0.24 to 0.12 — at the higher end
     of --cloud-blur (near clouds especially), each puff's own small
     highlight/shadow pair was reading as a distinct scattered dark
     spot rather than one soft shaded form — Carson's own "popcorn
     with cheese dust" description. Toned down rather than removed,
     since some shading is still what gives the cluster form at all. */
  background: radial-gradient(ellipse at calc(100% - var(--cloud-light-x-frac, 0.7) * 100%) 76%,
    rgba(118, 130, 146, 0.12), transparent 62%);
}

/* Four hand-placed puff arrangements — each .puff:nth-child(N) rule
   positions one puff as a percentage of its own .cloud's bounding
   box, so the same template can be reused at different overall
   sizes (see the HTML instances) and still keep its silhouette.
   Deliberately irregular/overlapping placements, not a neat grid —
   that's what reads as organic rather than a repeated stamp. Where
   two puffs' translucent gradients overlap, the combined opacity
   there is naturally higher than either alone — denser "core" areas
   and softer "edge" areas fall out of that layering for free,
   without needing to hand-tune per-puff opacity.
   Sizes scaled up ~35% from an earlier pass (offsets unchanged) —
   more overlap between adjacent puffs was the other half of the
   "popcorn" fix above: at the original, more separated sizing, each
   puff's own highlight/shadow pair stayed visually distinct even
   with the shadow toned down; heavier overlap merges them into
   fewer, larger unified bright/shaded regions instead of many small
   separate bumps. */
.cloud--a .puff:nth-child(1) { left: 4%; bottom: 0%; width: 68%; height: 84%; }
.cloud--a .puff:nth-child(2) { left: 42%; bottom: 4%; width: 76%; height: 89%; }
.cloud--a .puff:nth-child(3) { left: 20%; bottom: 30%; width: 62%; height: 78%; }
.cloud--a .puff:nth-child(4) { left: 50%; bottom: 46%; width: 46%; height: 59%; }

.cloud--b .puff:nth-child(1) { left: 0%; bottom: 5%; width: 46%; height: 70%; }
.cloud--b .puff:nth-child(2) { left: 22%; bottom: 0%; width: 57%; height: 76%; }
.cloud--b .puff:nth-child(3) { left: 50%; bottom: 8%; width: 51%; height: 68%; }
.cloud--b .puff:nth-child(4) { left: 68%; bottom: 2%; width: 46%; height: 62%; }
.cloud--b .puff:nth-child(5) { left: 38%; bottom: 36%; width: 40%; height: 54%; }

.cloud--c .puff:nth-child(1) { left: 6%; bottom: 0%; width: 73%; height: 86%; }
.cloud--c .puff:nth-child(2) { left: 40%; bottom: 4%; width: 78%; height: 92%; }
.cloud--c .puff:nth-child(3) { left: 32%; bottom: 34%; width: 54%; height: 65%; }

/* Taller/narrower than the other three — a "building" cumulus tower
   rather than a rolling horizontal cluster, stacked upward through
   five progressively smaller puffs. Directly answers Carson's ask
   for taller, not just bigger, silhouettes among the set. */
.cloud--d .puff:nth-child(1) { left: 18%; bottom: 0%; width: 78%; height: 62%; }
.cloud--d .puff:nth-child(2) { left: 4%; bottom: 24%; width: 68%; height: 59%; }
.cloud--d .puff:nth-child(3) { left: 44%; bottom: 28%; width: 65%; height: 57%; }
.cloud--d .puff:nth-child(4) { left: 24%; bottom: 54%; width: 59%; height: 51%; }
.cloud--d .puff:nth-child(5) { left: 36%; bottom: 74%; width: 40%; height: 36%; }

.star {
  position: absolute;
  border-radius: 50%;
  background: #fff;
  animation-name: star-twinkle;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
}
@keyframes star-twinkle {
  0%, 100% { opacity: 0.15; }
  50% { opacity: 0.85; }
}
@media (prefers-reduced-motion: reduce) {
  .star { animation: none; }
}

/* Drifts left-to-right across a bit more than the full viewport
   width, so a cloud is always fully off-screen at both ends of its
   cycle rather than popping in/out mid-frame. One shared keyframe —
   each cloud's own width/top/blur/opacity/timing (set inline below)
   is what makes four instances of it read as different clouds
   rather than four copies of the same one. */
@keyframes cloud-drift {
  from { transform: translateX(-32vw); }
  to { transform: translateX(132vw); }
}

/* ---------- Shooting star (dark mode only, occasional) ----------
   Lives inside #stars as a plain sibling of the regular .star dots —
   same container, so it automatically inherits the SAME dark-mode
   opacity gating and fog-line mask (fading out before the wave band)
   as the rest of the starfield, no separate rules needed for either.
   A single element does the whole streak: this div is the bright
   head, its ::before is the fading tail extending behind it. Angle
   is a static per-instance rotation (--star-angle, set inline in JS,
   one of two ranges for a downward-left or downward-right diagonal)
   — combined with an ANIMATED translateX in the SAME transform value
   is what makes it travel in a straight diagonal line: rotate first
   establishes the direction, then translateX moves along that
   already-rotated axis, rather than needing separately-animated X
   and Y offsets to hit a diagonal. */
.shooting-star {
  position: absolute;
  width: 2.5px;
  height: 2.5px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 5px 1px rgba(255, 255, 255, 0.65);
  opacity: 0;
  animation-name: shooting-star-move;
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}
.shooting-star::before {
  content: "";
  position: absolute;
  top: 50%;
  right: 100%;
  width: 90px;
  height: 1.5px;
  background: linear-gradient(to left, rgba(255, 255, 255, 0.8), transparent);
  transform: translateY(-50%);
}
@keyframes shooting-star-move {
  0% { transform: rotate(var(--star-angle, 35deg)) translateX(0); opacity: 0; }
  8% { opacity: 1; }
  75% { opacity: 0.85; }
  100% { transform: rotate(var(--star-angle, 35deg)) translateX(var(--star-travel, 380px)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .shooting-star { display: none; }
}

/* .wave-band, #bg-wave and their reduced-motion rule moved to
   /dot-wave.css on 4 Sep 2026. This page's comment was the abridged
   one of the three; index.html's fuller version is what the shared
   file kept. The declarations were identical everywhere. */

/* ---------- Scroll-linked top frosted-glass bar ----------
   A full-width bar spanning behind the whole button cluster, so
   content scrolling past the entire top of the page gets diffused —
   not just whatever's directly behind each button's own small shape.
   This replaces an earlier per-button attempt at the same idea, which
   turned out to only blur the sliver of content literally behind each
   circle/pill, leaving nearby scrolled text completely sharp — not
   what "frosted glass across the nav" means.

   z-index stack for the header area:
     main content = 1  (scrolling page)
     this bar     = 2  (above content — content blurs BEHIND this)
     buttons      = 10 (above the blur — buttons stay crisp, never blurred)

   Low fill opacity + a strong blur radius is what makes scrolled text
   underneath read as genuinely diffused rather than just tinted —
   the earlier version leaned on a much higher fill opacity with a
   weaker blur, which looked more like a flat translucent paint layer
   than actual glass. No mask-image here on purpose: a fading edge
   made the glass itself look like it was fading, which read as an
   effect wearing off rather than a physical pane. A crisp bottom
   edge is what makes it read as one solid sheet of glass — text is
   fully sharp right up until it passes behind it, then fully
   diffused, the way it would through actual glass. */

/* ---------- Site-wide announcement banner ----------
   See index.html for the full reasoning — same component, same
   mechanism, copied as-is. */
/* ---------- Banner layout fix ----------
   body on this page is `display: flex; justify-content: center` (that's
   what centres <main>), and #ptr-page copies it via `display: inherit`.
   That quietly made .site-banner a ROW flex item sitting BESIDE <main>
   rather than a bar above it: stretched to the full page height by the
   default align-items: stretch, and shoved sideways by its own
   full-bleed `left: 50%` — the whole page filling with the tier colour.
   account.html never showed this because its body is a plain block.
   Fix: put this wrapper back into block layout (same as account.html)
   and let <main> centre itself with auto inline margins instead of
   relying on the parent's justify-content. Selector is deliberately
   `body > #ptr-page`, not `#ptr-page`: the shared pull-to-refresh
   stylesheet further down sets `display: inherit` with equal
   specificity and would otherwise win on source order. */
body > #ptr-page { display: block; }

.site-banner {
  /* Pinned to the top of the viewport rather than sitting in normal
     flow. In flow it scrolled away with the page while .top-frost-bar
     — fixed, and pushed down by top: var(--banner-h) to make room for
     it — stayed exactly where it was, so from the first scrolled pixel
     onward that reserved space showed as a transparent strip above the
     nav bar (Carson's screenshot). Pinned, the space the nav bar
     reserves is always filled by the banner that asked for it.
     left/right: 0 replaces the old width: 100vw full-bleed breakout:
     a fixed element already spans the viewport exactly, without
     100vw's habit of counting the scrollbar's width as page width.
     Room for it is still made the same way as before, just from the
     other end — body's padding-top adds --banner-h (see that rule)
     instead of this element's own margins doing it. That is also what
     makes dismissal clean: the JS sets --banner-h back to 0px, so the
     padding collapses and the nav bar sits flush against the top of
     the viewport again, with no leftover gap. */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above .top-frost-bar (9) and the corner buttons (10) so nothing
     can paint over it, below .modal-overlay (50) / .credits-panel (60)
     so an open modal still covers the whole screen. */
  z-index: 40;
  /* No longer a flat background here — three tiers (danger/warning/
     announce), each with its own color, set via a class the JS
     applies based on the posted message's tier. */
  background: var(--banner-announce-bg);
  color: var(--banner-announce-text);
  transition: background-color 2400ms ease, color 2400ms ease;
}
.site-banner.tier-danger { background: var(--banner-danger-bg); color: var(--banner-danger-text); }
.site-banner.tier-warning { background: var(--banner-warning-bg); color: var(--banner-warning-text); }
.site-banner.tier-announce { background: var(--banner-announce-bg); color: var(--banner-announce-text); }
.site-banner[hidden] { display: none; }
.site-banner-inner {
  max-width: 940px;
  margin: 0 auto;
  padding: 10px 44px 10px 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.5;
  text-align: center;
}
.site-banner-text b, .site-banner-text strong { font-weight: 800; }
.site-banner-text u { text-decoration: underline; }
.site-banner-text a { color: inherit; text-decoration: underline; }
.site-banner-close {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: none;
  border: none;
  color: inherit;
  opacity: 0.85;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: none;
}
.site-banner-close:hover { opacity: 1; background: rgba(0, 0, 0, 0.12); }
.site-banner-close svg { width: 15px; height: 15px; }
.site-banner-close[hidden] { display: none; }

.top-frost-bar {
  position: fixed;
  top: var(--banner-h, 0px);
  left: 0;
  width: 100%;
  /* 74px + --safe-top — Carson's report: the bar's own blur/background
     stopped short of the true top of the screen, so Safari's own
     translucent status-bar/toolbar chrome (which shows live page
     content through it) revealed raw, unblurred content peeking
     through as things scrolled underneath instead of staying
     covered. top itself is untouched (still var(--banner-h, 0px),
     the true viewport top) — only height grows, so the bar's own
     background now reaches all the way up into that region too. The
     74px comment beneath still describes the base figure exactly;
     --safe-top is pure addition on top of it, not a replacement. */
  height: calc(74px + var(--safe-top) + var(--os-glass-bleed)); /* 18px top gap + 38px button height + 18px bottom gap — matches the buttons' own top offset exactly */
  /* Raised from 2 to 9 — same reasoning as the home page: the account
     page needed a much higher value (35) for the same class of
     stacking bug, but this page's Login button / account chip are
     only z-index: 10, so 9 is the highest safe value here without
     covering them in blur. */
  z-index: 9;
  pointer-events: none;
  /* Genuinely everything else (all the previous rounds of fixes —
     viewport-fit=cover, --safe-top, this bar's own height) never
     actually touched Carson's original report, because it was never
     the real cause. Safari 26's "Liquid Glass" redesign scans
     position:fixed/sticky elements near the viewport edges and
     reads background-color/backdrop-filter DIRECTLY OFF THAT
     ELEMENT to compute its own status-bar/toolbar tint — completely
     undocumented by Apple, confirmed via outside research once
     "no change at all" ruled out everything already tried. This
     element WAS exactly that broken shape: a fixed element with
     both those properties on itself. Safari was tinting its own
     status bar FROM this bar's own styles instead of actually
     letting the frosted effect extend up through it — which is a
     different failure than what it looks like from a screenshot,
     and explains why nothing about height/position ever mattered.
     The fix: this element now holds NO background/backdrop-filter/
     box-shadow of its own at all — purely structural (position,
     size, z-index) — with all of that moved onto the new
     .top-frost-bar-glass child instead. Safari's own tinting scan
     explicitly ignores position:absolute children, so it no longer
     has anything here to read, and falls through to actually
     compositing real page content behind its own chrome — which is
     what was wanted the whole time. */
  background: none;
  /* Carson's report: two distinct frosted phases appeared, one after
     the other, before the bar finally settled — on both iPhone and
     Mac. The cause was this element's own opacity fade, not anything
     about the blur itself. An ancestor with opacity < 1 is a
     BACKDROP ROOT, so for the whole time this parent sat mid-fade,
     .top-frost-bar-glass's backdrop-filter had a different backdrop
     to sample than it has at opacity:1 — and WebKit resolves that
     root change on its own schedule rather than per-frame, so the
     tint and the blur landed on separate frames. That is exactly
     what reads as the nav loading in stages rather than one pane
     fading up.
     So there is no opacity on this element AT ALL in tab mode any
     more: it stays fully opaque and purely structural, and the fade
     moved onto .top-frost-bar-glass itself (and onto ::after below).
     opacity and backdrop-filter on the SAME element is the one
     arrangement that's well-defined — the backdrop is filtered
     first, then the whole result composites at that opacity, a true
     cross-fade between the sharp and the blurred backdrop with no
     root change to resolve mid-flight.
     The standalone/PWA branch further down still carries opacity
     here, and correctly so: in that mode this element IS the blur
     carrier (the child isn't rendered at all), so the two
     properties already sit together there and that path never had
     this bug to begin with. */
  opacity: 1;
}
.top-frost-bar-glass {
  position: absolute;
  inset: 0;
  background: rgba(var(--bg-rgb), 0.4);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  /* Light-respecting shadow, small-button scale (7px reach) despite
     this being a full-width bar — same treatment as account.html's
     own copy of this. --frost-bar-shadow-x/-y set in
     setFrostBarShadow() (see the JS near the scroll-trigger IIFE
     below) directly on #top-frost-bar (the parent) — inherits down
     to this child via ordinary CSS custom-property inheritance, so
     moving the box-shadow itself onto this child didn't need any
     JS change alongside it. Already fades in/out for free with the
     parent's own opacity transition above — no separate visibility
     gating needed here either. */
  box-shadow: var(--frost-bar-shadow-x, 0px) var(--frost-bar-shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
  /* 0.3s -> 2400ms — Carson's site-wide theme-transition audit
     caught this: this element's own background-color IS the
     theme-dependent frost fill (rgba(var(--bg-rgb), 0.4) above), so
     it needs the same 2400ms every other theme-dependent fill uses
     (see .theme-toggle's own reference timing), not the 0.3s
     reserved specifically for TEXT color elsewhere on this site.
     Own mistake from when this child was first split out for the
     Safari fix — never re-checked against the theme-timing
     standard at the time. */
  /* Scroll-linked, not a threshold-triggered fade — Carson's ask:
     clear at the top of the page, ramping continuously to fully
     frosted as the page scrolls down. --frost-p (0..1) is written
     on <html> by the scroll handler (see the frost IIFE further
     down) and driving opacity straight off it means the glass
     tracks the finger instead of "triggering" — there is no
     threshold moment left for anything to step at, which is the
     other half of what Carson was seeing.
     120ms is a smoothing tail, not the animation itself: iOS
     delivers scroll events in bursts during momentum scrolling, and
     without it those bursts show up as small jumps in what should
     be a continuous ramp.
     will-change keeps this layer promoted — and its backdrop buffer
     allocated — from load, so the first non-zero frame doesn't also
     have to pay for layer creation. That's a separate cost from the
     backdrop-root problem documented on the parent above, and it
     showed up the same way: as the effect arriving in pieces. */
  opacity: var(--frost-p, 0);
  will-change: opacity;
  transition: opacity 120ms linear, background-color 2400ms ease;
}
/* Carson's report: installed-as-PWA specifically (not a regular
   Safari tab) came back with a permanently blurred gradient sitting
   right where the nav buttons live, making them hard to read even
   though they stayed clickable. Standalone/PWA mode on iOS is a
   genuinely different rendering path from a tab — there's no
   Safari toolbar chrome for the Liquid Glass tinting-scan (the
   whole reason .top-frost-bar-glass exists as a separate child, see
   .top-frost-bar's own comment) to apply to at all in that mode.
   The split was solving a tab-mode-only problem; applying it
   unconditionally carried it into standalone mode too, where it
   wasn't needed and apparently caused its own new visual bug
   instead. This reverts to the ORIGINAL, pre-split approach —
   background/blur back directly on the fixed parent, exactly as it
   was before any of this — but ONLY inside this standalone-mode
   query, so tab-mode keeps the newer fix that Carson confirmed
   actually resolved his original report there. */
@media (display-mode: standalone) {
  .top-frost-bar {
    background: rgba(var(--bg-rgb), 0.4);
    backdrop-filter: blur(14px) saturate(1.4);
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    box-shadow: var(--frost-bar-shadow-x, 0px) var(--frost-bar-shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
    /* opacity stays 0.3s (matches the scroll-triggered fade-in
       timing this whole element already uses) — background-color
       corrected to 2400ms, same theme-transition audit fix as
       .top-frost-bar-glass's own copy of this comment just above:
       this property IS the theme-dependent frost fill here, not a
       text color, so it needs the standard fill timing, not the
       0.3s this was copied from opacity's own value without
       re-checking against that standard. */
    /* Same scroll-linked driver as tab mode's .top-frost-bar-glass;
       the only difference is which element carries it, since here
       the blur is back on the fixed parent and the glass child
       isn't rendered at all. opacity and backdrop-filter were
       already on the same element in this mode, so it never had the
       backdrop-root staging the split path had — it just needed the
       same continuous ramp instead of a 0.3s threshold fade. */
    opacity: var(--frost-p, 0);
    will-change: opacity;
    transition: opacity 120ms linear, background-color 2400ms ease;
  }
  /* The glint is a child of the parent here, so it already fades
     with it — it must NOT multiply --frost-p in a second time on
     its own, which would square the ramp (0.5 * 0.5 = 0.25) and
     leave the edge trailing the pane it's supposed to be the edge
     of. The bare `html` prefix is load-bearing, not decoration:
     .top-frost-bar::after's own base rule is written BELOW this
     media query, so at equal specificity source order would hand it
     the win and this override would silently do nothing. The extra
     element selector settles it by specificity instead, which
     doesn't depend on where either rule sits in the file. */
  html .top-frost-bar::after {
    opacity: 1;
  }
  .top-frost-bar-glass {
    display: none;
  }
}
.top-frost-bar::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: linear-gradient(to left,
    rgba(var(--light-rgb), 0.7) 0%,
    rgba(var(--light-rgb), 0.18) 40%,
    transparent 75%);
  pointer-events: none;
  /* Fades on the same driver as the glass it edges. This used to
     come for free from the parent's own opacity fade, which no
     longer exists in tab mode (see .top-frost-bar above) — same
     value, same 120ms tail, so the edge and the pane are always at
     identical strength rather than one arriving ahead of the
     other. */
  opacity: var(--frost-p, 0);
  transition: opacity 120ms linear;
}
@media (prefers-reduced-motion: reduce) {
  /* Only the smoothing tails go. The frost itself still tracks
     scroll position, which is direct manipulation rather than an
     animation playing on its own — exactly the kind of thing this
     setting is meant to leave alone. */
  .top-frost-bar, .top-frost-bar-glass, .top-frost-bar::after { transition: none; }
}

/* Flush with the glass — Carson's ask, and a correction from an
   inset shadow tried first: that read as the buttons sinking INTO
   the surface rather than sitting flush with it, so what's actually
   wanted is no shadow at all once the glass has arrived.
   This used to be a flat `box-shadow: none` under
   html.frost-scrolled, which meant every one of these buttons
   dropped its shadow in a single instant, at the 8px threshold,
   while the bar behind them was still running its own separate
   fade — two motions landing a beat apart, and a real part of what
   Carson saw as the nav appearing in phases. An earlier pass tried
   to paper over that by giving each button a 100ms box-shadow
   transition; that narrowed the gap without closing it, because the
   two were still independent animations of different durations
   started by the same instant.
   Now the shadow's ALPHA is interpolated off --frost-p directly, in
   each button's own box-shadow declaration below — the exact same
   number the glass itself fades on. There's nothing left here to
   toggle: at p=0 the shadow is at its full 0.20, at p=1 it's gone,
   and every value between is whatever the scroll position says. One
   driver, so the buttons and the bar cannot get out of step with
   each other.
   html.frost-scrolled itself still exists, but now only for the
   :active press treatments just below — a momentary inset shadow
   while a button is actually being pressed, which genuinely does
   need a yes/no answer about whether it's sitting on glass. */

/* Press-in — see index.html's own copy of this comment for the full
   reasoning: an inset shadow only makes physical sense once flush
   against the glass (a real surrounding surface to sink into),
   which is why this is scoped to html.frost-scrolled specifically,
   not the raised default state too. See the filter:brightness rule
   further down for what the raised state's own press feedback is
   instead. */
html.frost-scrolled .theme-toggle:active,
html.frost-scrolled .login-btn:active {
  box-shadow: inset var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) calc(var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) * 0.5) 6px rgba(0, 0, 0, 0.3);
}
html.frost-scrolled .credits-btn:active {
  box-shadow: inset var(--btn-tl-shadow-x, 0px) calc(var(--btn-tl-shadow-y, 7px) * 0.5) 6px rgba(0, 0, 0, 0.3);
}
/* Press feedback for the RAISED default state — see index.html's own
   copy of this comment for the full reasoning (filter:brightness
   over opacity, and why this applies in BOTH states, layering on
   top of the frosted state's own inset shadow above rather than
   being replaced by it). */
.theme-toggle:active,
.login-btn:active,
.credits-btn:active {
  filter: brightness(0.85);
}
/* Carson's own follow-up: while flush against the frosted glass and
   actively pressed, the glint should vanish entirely — see
   index.html's own copy of this comment for the full reasoning. */
html.frost-scrolled .theme-toggle:active::before,
html.frost-scrolled .credits-btn:active::before {
  opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
  /* .theme-toggle/.credits-btn both only have box-shadow/filter
     left post-split — background-color/border-color live on their
     own separate .theme-toggle-fill/.credits-btn-fill selectors
     now, which this rule doesn't touch at all. Both fill layers'
     own transitions (2400ms theme fade, plus .credits-btn-fill's
     own 0.25s opacity for the morph) stay active under reduced
     motion regardless, exactly as intended — see index.html's own
     copy of this comment for the general reasoning (.login-btn
     can't reuse .credits-btn's list). */
  .theme-toggle,
  .credits-btn {
    transition: none;
  }
  .login-btn {
    transition-property: transform, opacity;
  }
  .theme-toggle::before,
  .credits-btn::before {
    transition: none;
  }
}

/* ---------- The top-right corner row ----------
   Carson, 4 Sep 2026, standardising the nav across all four pages:
   "User chip/log in on the far right, theme toggle to the left of that,
   then notifications left of that, followed by the home button to the
   left of that on every page not index." This page is not index, so it
   gets the home button too - see the markup below, which is where it
   arrived from account.html.

   Before this, these controls were each independently position:fixed
   with a hand-computed right offset chained off the width of the one to
   their right - 18, 66, 114, restated as 76 and 134 at the 780px
   breakpoint, across this file and notifications.css. Putting the chip
   on the outside is not expressible that way, because the chip's width
   depends on the signed-in user's name. A flex row computes it.

   account.css's .header-actions rule with ONE deliberate difference:
   z-index 10, not 80. /account uses 80 to stay above its confirm
   overlay; this page needs the opposite, and it is Carson's own earlier
   call, recorded on .theme-toggle below - the credits panel and overlay
   should COVER these controls. index.html carries the identical rule
   for the identical reason. */
.header-actions {
  position: fixed;
  /* + --safe-top / 2, not the full amount — Carson's report: adding
     the FULL --safe-top here (an earlier round of this fix) made the
     gap above these icons visibly bigger than the gap below them
     inside .top-frost-bar. The bar's own height grew by the FULL
     --safe-top (so its background still reaches the true top of the
     screen), but that extra space was landing entirely ABOVE these
     icons and not being matched below them — worked through the
     actual numbers: bar spans [0, 74+safeTop], icon top was
     (18+safeTop), giving an 18px gap below but an (18+safeTop) gap
     above. Halving it here instead makes both gaps equal
     (18 + safeTop/2 each) — splits the extra room the notch needs
     evenly instead of piling all of it on one side. */
  top: calc(18px + var(--banner-h, 0px) + (var(--safe-top) / 2));
  right: 18px;
  z-index: 10;
  display: flex;
  align-items: center;
  gap: 10px;
}

.theme-toggle {
  /* position/top/right/z-index moved to .header-actions above on
     4 Sep 2026 - a flex child now. relative stays: .theme-toggle::before
     is absolute and anchors to it.

     Carson's z-index note, kept because it is why the row above is 10
     rather than account.css's 80 - he asked for the credits panel and
     overlay to COVER this button rather than it staying reachable above
     them, matching .login-btn/.user-chip-wrap. */
  position: relative;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  /* Glass, matching .credits-item — this is a secondary/utility
     control, not a primary action, so it should read as part of the
     same glass language as the credits cards rather than sitting on
     top of it as flat opaque UI. See .theme-toggle::before for the
     glint; --btn-tr-glint-angle is computed for this top-right corner
     specifically (see the script near the other glint-angle scripts)
     since it sits almost directly under the light source, unlike the
     credits panel over on the left. */
  background: none;
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  border: 1px solid transparent;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* Light-respecting shadow, same technique as the rest of the site
     — see .getting-started-flip-pane on index.html for the full
     reasoning. Same tuned scale as index.html's own .nav-btn (7px
     reach, 22px blur, fixed 0.20 alpha rather than var(--shadow) —
     that variable is only 0.06 alpha in light mode, too faint to
     actually read as a shadow, per Carson's own "I can barely make
     it out" on the carousel arrows). --own-shadow-x/-y is this
     button's OWN measured direction, written onto the element by
     setGlintAngles(); the shared --btn-tr-shadow-x/-y behind it is
     the old fixed-corner pair, now only a fallback for the window
     before layout. Same shape, same names and same reasoning as
     .notif-bell-btn's rule in notifications.css, which has read this
     chain since #131. */
  box-shadow: var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
  /* box-shadow/filter only now — background-color/border-color moved
     to .theme-toggle-fill below, given its own explicit 2400ms
     fade. This actually fixes a real, separate inconsistency the
     comment here used to describe: this element never had a theme
     fade of its own on this specific page at all — index.html's own
     copy of it did, this one didn't — snapping instantly here while
     fading everywhere else. The new fill layer needed its own
     explicit transition regardless of that history, so giving it
     the same 2400ms both pages already use elsewhere resolves the
     mismatch as a side effect of the split, not a separate fix. */
  transition: box-shadow 100ms ease, filter 100ms ease;
}
.theme-toggle-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the icon svg) regardless of DOM order — see
     .stat-widget-fill's own copy of this same comment (account.html)
     for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: rgba(var(--surface-rgb), 0.6);
  border: 1px solid var(--border);
  transition: background-color 2400ms ease, border-color 2400ms ease;
}
.theme-toggle::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this.

     --own-glint-angle is this button's OWN measured angle, written
     onto the element by setGlintAngles() once it is laid out; the
     --btn-tr-glint-angle fallback is the old shared corner value,
     still correct-ish for this button (it is the closest of the three
     to that corner) and used until the measurement lands. See that
     function's "THE TOP-RIGHT CLUSTER, MEASURED" comment for the
     numbers, and notifications.css for why the per-element name is
     --own-* rather than a generic one. */
  background:
    linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
    rgba(255, 255, 255, 0.9) 0%,
    rgba(var(--light-rgb), 0.45) 22%,
    transparent 48%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
  /* opacity transition added for the frosted-press rule further
     down — see index.html's own copy of this comment. */
  transition: opacity 100ms ease;
}
.theme-toggle svg { width: 17px; height: 17px; }
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Icon fade, brought back after being removed for the mid-transition-
   timed swap — Carson's own ask: even with the swap correctly timed
   to the transition's own low-contrast window, a hard instant swap
   leaves no forgiveness if that timing is ever slightly off. A brief
   fade around the swap (see paintIcon's own animate=true path)
   softens exactly that risk, at the cost of nothing when the timing
   IS right — the fade's simply imperceptible against an already-
   near-invisible icon in either case. */
#theme-icon { transition: opacity 150ms ease; }
@media (prefers-reduced-motion: reduce) {
  #theme-icon { transition: none; }
}

/* ---------- Login button + modal ---------- */

.login-btn {
  /* A flex child of .header-actions since 4 Sep 2026 - position, top,
     right and z-index all moved there. It and .user-chip-wrap are
     mutually exclusive (nav-user.js hides one), so whichever is showing
     takes the rightmost slot without either knowing the other's width. */
  position: relative;
  height: 38px;
  padding: 0 18px;
  border-radius: 999px;
  background: var(--text);
  color: var(--bg);
  border: none;
  font-size: 13.5px;
  font-weight: 600;
  cursor: pointer;
  /* --own-shadow-x/-y measured onto this element by setGlintAngles();
     shared pair behind it as the pre-layout fallback. See
     .theme-toggle's own copy of this note. This button is hidden
     whenever the chip is shown, so the two never disagree on screen -
     but it is the control that OCCUPIES the chip's slot for a
     signed-out visitor, which is why it is measured rather than left
     on the corner value. */
  box-shadow: var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
  transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 100ms ease, filter 100ms ease;
}
.login-btn:hover { transform: translateY(-1px); opacity: 0.92; }
.login-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.login-btn[hidden] { display: none; }

/* ---------- Signed-in user chip + dropdown ---------- */

.user-chip-wrap {
  /* relative, NOT static, and load-bearing: .user-dropdown is absolute
     and anchors to this element. Dropping the positioning when this
     became a flex child would have left the menu resolving against the
     viewport. */
  position: relative;
}
.user-chip-wrap[hidden] { display: none; }

.user-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 38px;
  padding: 0 8px 0 8px;
  border-radius: 999px;
  /* Glass, matching .theme-toggle (same top-right glint angle — this
     chip sits right next to it, close enough that the angle doesn't
     meaningfully differ).
     background: none / border: transparent — Carson's site-wide ask
     after confirming the blur/color split looks identical to the
     combined approach. Visible fill/border moved to the new
     .user-chip-fill child below; this element keeps only the static
     blur + layout. border stays 1px transparent (border-box) to
     preserve this element's exact dimensions. */
  background: none;
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  border: 1px solid transparent;
  color: var(--text);
  font-size: 13.5px;
  font-weight: 600;
  cursor: pointer;
  /* --own-shadow-x/-y measured onto this element by setGlintAngles();
     shared pair behind it as the pre-layout fallback. See
     .theme-toggle's own copy of this note. This is the control the
     shared value was worst for: measured (-5.9px, 3.7px) against the
     shared (0.5px, 7.0px), i.e. the shadow was falling on the wrong
     side of the button entirely. */
  box-shadow: var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
  /* Carson's report: the top-frost-bar's own appearance reads as
     "stepping" rather than one smooth motion — this element had no
     transition of any kind, so html.frost-scrolled .user-chip's
     box-shadow:none (further down) was snapping this shadow away
     instantly while the bar itself was still mid-fade. .theme-toggle/
     .login-btn/.credits-btn beside it already had this; matches
     their own 100ms. Same fix as index.html's own copy of this bug.

     Superseded but kept for the history: that box-shadow:none rule
     no longer exists — this shadow's own alpha rides --frost-p
     continuously instead (see the "Flush with the glass" comment
     further up), so there's no snap left for this transition to
     smooth over. It stays on as a short tail on that continuous
     value, and for the :active press. */
  transition: box-shadow 100ms ease;
}
.user-chip-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the avatar/name/chevron) regardless of DOM order —
     see .stat-widget-fill's own copy of this same comment
     (account.html) for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: rgba(var(--surface-rgb), 0.6);
  border: 1px solid var(--border);
  /* 0.15s, unchanged from before the split — this is a hover-
     feedback speed, not the 2400ms theme fade (see .user-chip:hover
     just below, which recolors this same property on a completely
     different trigger). Genuinely dual-purpose: hovering and theme-
     switching both drive this one background value today, and nothing
     about the blur/color split changes that existing tension — only
     WHICH element the color now lives on, matching Carson's own
     explicit scope for this pass (blur separated from color, not a
     revisit of the separate hover-vs-theme timing question from the
     original transition audit). */
  transition: background 0.15s ease;
}
.user-chip::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this.

     --own-glint-angle, same as .theme-toggle::before just above —
     read that rule's comment. This is the button the shared fallback
     was worst for: 62.1deg out, because the chip sits furthest from
     the top-right corner --btn-tr-glint-angle describes. */
  background:
    linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
    rgba(255, 255, 255, 0.9) 0%,
    rgba(var(--light-rgb), 0.45) 22%,
    transparent 48%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
.user-chip:hover .user-chip-fill { background: rgba(var(--accent-rgb), 0.35); }
.user-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.user-chip .avatar {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
  flex: none;
}
.user-chip .chevron {
  width: 14px;
  height: 14px;
  color: var(--text-dim);
  transition: transform 0.2s ease;
  flex: none;
}
.user-chip[aria-expanded="true"] .chevron { transform: rotate(180deg); }
.user-chip .name {
  max-width: 120px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.user-dropdown {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 180px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  /* Same light-respecting shadow as .theme-toggle — reuses
     --own-shadow-x/-y (same 7px reach the buttons themselves use),
     scaled up in blur/alpha only (28px/0.18 vs the
     buttons' 22px/0.20) to feel a bit bigger, matching a dropdown
     menu being larger than a button but smaller than a full panel —
     the same relationship its old flat shadow (12px/32px) already
     had to the button and panel scales.

     This menu now measures itself rather than borrowing the corner
     value. The old note here said it "opens directly below .user-chip,
     close enough to the same position that a separate live
     measurement/scale wasn't worth adding" - close to the CHIP, yes,
     but the chip itself was 62deg off the corner value, so inheriting
     it meant an open menu lit from a different direction than the
     button it hangs from. Measured (-4.8px, 5.1px) against the shared
     (0.5px, 7.0px). It is a child of #user-chip-wrap and a SIBLING of
     the chip button, so it does not inherit the chip's value - it
     needs its own, and gets one. */
  box-shadow: var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) 28px rgba(0, 0, 0, 0.18);
  padding: 6px;
  transform: translateY(-6px) scale(0.97);
  opacity: 0;
  pointer-events: none;
  transition: transform 0.18s cubic-bezier(.22,.61,.36,1), opacity 0.15s ease;
}
.user-dropdown.open {
  transform: translateY(0) scale(1);
  opacity: 1;
  pointer-events: auto;
}

.user-dropdown button, .user-dropdown a {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  padding: 9px 10px;
  border-radius: 9px;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 13.5px;
  font-weight: 550;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  font-family: inherit;
}
.user-dropdown button:hover, .user-dropdown a:hover { background: var(--accent-soft); }
.user-dropdown svg { width: 15px; height: 15px; flex: none; color: var(--text-dim); }
.user-dropdown .divider { height: 1px; background: var(--border); margin: 5px 4px; }
.user-dropdown .danger-item { color: var(--down); }
.user-dropdown .danger-item svg { color: var(--down); }

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(0, 0, 0, 0.32);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  opacity: 0;
  transition: opacity 0.22s ease;
}
.modal-overlay.open { opacity: 1; }
.modal-overlay[hidden] { display: none; }

.login-modal {
  position: relative;
  width: 100%;
  max-width: 360px;
  transform: scale(0.94) translateY(8px);
  opacity: 0;
  transition: transform 0.28s cubic-bezier(.22,.61,.36,1), opacity 0.22s ease;
  /* flip easter egg — .login-modal is now just a plain positioning
     shell for its own scale/translateY open animation (untouched,
     above) — the glass styling (background, blur, border, the
     glint) and the flip rotation both moved to
     .login-modal-flip-pane, a child, exact same reasoning as the
     credits panel's own split: this element's own transform is
     already spoken for by the open animation and can't also carry a
     rotateY flip. perspective here is what makes that child's
     rotation read as real depth. */
  perspective: 1400px;
}
/* flip easter egg — the actual glass pane now; see .login-modal's
   own comment above for why this split happened. Double-click the
   modal's own background (not a field, button, or link, which
   should all behave normally) to flip it around. */
.login-modal-flip-pane {
  width: 100%;
  position: relative;
  /* background: none / border: transparent — Carson's site-wide ask
     after confirming the blur/color split looks identical to the
     combined approach. Visible fill/border moved to the new
     .login-modal-flip-pane-fill child below; box-shadow stays here
     since it's not part of the 2400ms color transition either.
     border stays 1px transparent (border-box) to preserve this
     element's exact dimensions. */
  background: none;
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  border: 1px solid transparent;
  border-radius: var(--radius);
  padding: 32px 28px 28px;
  /* Shadow respects the light source's own position — same
     technique as index.html (see its .getting-started-flip-pane
     for the full reasoning). --shadow-x/-y set in
     setModalGlintAngle(), reusing the modal's own live
     getBoundingClientRect() it already computes its glint angle
     from. */
  box-shadow: var(--shadow-x, 0px) var(--shadow-y, 16px) 52px rgba(0, 0, 0, 0.15);
  transform-style: preserve-3d;
  transform-origin: 50% 50%;
  /* transform/filter only now — background-color/border-color moved
     to .login-modal-flip-pane-fill along with the properties they
     animate. */
  transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
}
.login-modal-flip-pane-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the form fields) regardless of DOM order — see
     .stat-widget-fill's own copy of this same comment (account.html)
     for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: rgba(var(--surface-rgb), 0.68);
  border: 1px solid var(--border);
  transition: background-color 2400ms ease, border-color 2400ms ease;
}
.login-modal-flip-pane.flipped {
  /* Plain rotateY here, not perspective() — unlike .glass-card,
     .login-modal (the parent) and this flip-pane are a tight 1:1
     wrapper with no size/position mismatch between them, so the
     parent's own separate perspective property (see .login-modal)
     already centers correctly on this element — no shared-ancestor
     misalignment risk to work around. Adding perspective() here too
     would apply it twice, compounding into visible distortion
     rather than a clean rotation. */
  transform: rotateY(180deg);
  pointer-events: none;
  filter: brightness(0.92) saturate(0.85);
}
@media (prefers-reduced-motion: reduce) {
  /* transform/filter (the flip itself) is what actually needs
     disabling here — background-color/border-color no longer live
     on this element at all post-split. The fill layer's own 2400ms
     theme fade is untouched by this selector entirely, exactly as
     intended. */
  .login-modal-flip-pane { transition: none; }
}
.login-modal-flip-pane::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1.5px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--modal-glint-angle, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--modal-glint-angle, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
/* flip easter egg — mirror-corrected glint + directional haze on the
   back, same technique as elsewhere on this site. */
.login-modal-flip-pane.flip-mid::before {
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--modal-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--modal-glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
}
.login-modal-flip-pane::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease 0.35s;
  background: linear-gradient(var(--modal-glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.16) 0%,
    rgba(255, 255, 255, 0.08) 35%,
    rgba(255, 255, 255, 0.03) 65%,
    transparent 100%);
}
.login-modal-flip-pane.flipped::after { opacity: 1; }
.modal-overlay.open .login-modal { transform: scale(1) translateY(0); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .modal-overlay, .login-modal { transition: none; }
}

.modal-close {
  position: absolute;
  top: 14px;
  right: 14px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: transparent;
  border: none;
  color: var(--text-dim);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.modal-close svg { width: 16px; height: 16px; }
.modal-close:hover { background: var(--accent-soft); color: var(--accent); }

.login-modal h2 { font-size: 19px; font-weight: 650; margin: 0 0 4px; letter-spacing: -0.01em; }
.login-sub { font-size: 13px; color: var(--text-dim); margin: 0 0 20px; }

/* ---------- Credits panel ---------- */
/* Opens from an "i" button mirroring .theme-toggle's position/size on
   the opposite corner. Unlike the centered login-modal, this slides in
   from the left and pins to the viewport's own edges (top/left/bottom,
   each with the same 16px gap) rather than centering — "a card that
   flies out and sits within the browser window," not a popup dialog.
   The list can outgrow the viewport on a small phone, so the header
   stays put and only .credits-panel-body scrolls internally. */
.credits-overlay {
  position: fixed;
  inset: 0;
  z-index: 55;
  background: rgba(0, 0, 0, 0.32);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease;
}
.credits-overlay.open { opacity: 1; pointer-events: auto; }
.credits-overlay[hidden] { display: none; }

.credits-btn-persp-wrap {
  position: fixed;
  top: calc(18px + var(--banner-h, 0px) + (var(--safe-top) / 2));
  left: 18px;
  /* Higher than .credits-overlay (55) and .credits-panel (60) so it
     stays visible/clickable above them while it's showing at all —
     still true and still needed, just for a different reason now:
     Carson's follow-up ask puts a bare icon INSIDE the open panel
     (see .credits-btn-persp-wrap.panel-open .credits-btn below), so
     this wrapper still needs to render above the panel's own glass
     surface for that icon to actually be visible on top of it,
     rather than hidden underneath. */
  z-index: 70;
  width: 38px;
  height: 38px;
}
/* Reworked from a full opacity:0 fade-out into the SAME morph
   account.html's hamburger uses — Carson's explicit ask: "like with
   the hamburger menu's three lines... put the 'i' on the left flyout
   panel as well, sans the button shape around it." This wrapper no
   longer fades at all (the old opacity transition removed above);
   .credits-btn itself morphs instead, exactly like
   .sidebar-trigger.open does on account.html — background, border,
   and shadow all cleared, leaving just the bare "i" glyph sitting
   directly on the panel's own glass surface once it's open, still
   fully visible and still clickable (this wrapper's own click
   handler is untouched) rather than disappearing. ::before (the
   glint overlay) is hidden alongside it — a glint traces a circular
   edge that no longer exists once the background/border are gone,
   so leaving it visible would look like a stray ring floating with
   nothing to justify its own shape. */
.credits-btn-persp-wrap.panel-open .credits-btn {
  box-shadow: none;
  /* Carson's report, with a screenshot: with the panel open, a faint
     circular disc was still visible around the "i", overlapping the
     panel's own border and bleeding out past its top-left corner —
     the last remnant of the button shape this morph is supposed to
     clear away. Cause: the morph already clears the fill layer, the
     glint and the shadow, but this element's OWN backdrop-filter
     (blur(16px) saturate(1.5), see .credits-btn below) was never
     touched, and backdrop-filter is clipped to this element's
     border-radius:50% — so a circle's worth of blurred, saturated
     backdrop kept rendering after every visible circular property
     was already gone. It read as a soft LIGHTER disc specifically
     because the backdrop it samples now includes the open panel: a
     16px blur smears the panel's own bright 1px border across the
     whole circle, brightening it against the near-black page
     behind. This is the one thing account.html's hamburger never
     had to deal with — .sidebar-trigger is a plain solid button
     with no backdrop-filter at all, which is exactly why its
     version of this same morph looked clean and this one didn't.
     blur(0px) saturate(1), not `none`: an identity filter list with
     the same functions in the same order interpolates, so this
     eases out over the same 0.25s as the fill layer's own fade
     instead of snapping the page content behind the button into
     focus the instant it's tapped (`none` isn't interpolable — it
     would jump at the halfway point). See .credits-btn's own
     transition below for the matching timing. */
  backdrop-filter: blur(0px) saturate(1);
  -webkit-backdrop-filter: blur(0px) saturate(1);
}
.credits-btn-persp-wrap.panel-open .credits-btn-fill {
  /* opacity: 0 replaces the old background:none/border-color:
     transparent approach — see index.html's own identical copy of
     this comment for the full reasoning: with background/border now
     living on this separate fill layer, fading the WHOLE layer out
     achieves the same bare-icon morph without background-color/
     border-color needing to serve both "morph fast" and "theme-fade
     slow" anymore. */
  opacity: 0;
}
.credits-btn-persp-wrap.panel-open .credits-btn::before {
  opacity: 0;
}
.credits-btn {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  /* Glass, matching .theme-toggle — its mirror on the opposite corner,
     so it should carry the same treatment. --btn-tl-glint-angle is
     computed for this specific top-left position (see the credits
     panel's glint-angle script, which sets this too).
     background: none / border: transparent — visible fill/border
     moved to the new .credits-btn-fill child below; border stays
     1px transparent (border-box) to preserve this element's exact
     dimensions. */
  background: none;
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  border: 1px solid transparent;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* Same technique as .theme-toggle, but this button sits at the
     top-LEFT instead, so it reuses --btn-tl-shadow-x/-y (its own
     existing --btn-tl-glint-angle's counterpart) rather than the
     shared top-right value the other three buttons use. */
  box-shadow: var(--btn-tl-shadow-x, 0px) var(--btn-tl-shadow-y, 7px) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
  /* box-shadow/filter only now — background-color/border-color moved
     to .credits-btn-fill below, ALONGSIDE the resolution of a real,
     previously-unfixable tension: see index.html's own identical
     copy of this comment for the full reasoning. The fill layer's
     own transition finally gives each property its own speed —
     2400ms for the theme fade, 0.25s for the morph (now animating
     opacity instead) — so this button's theme fade finally matches
     the rest of the site without slowing the morph down at all. */
  /* backdrop-filter added at 0.25s — deliberately NOT the 100ms
     the other two run at: this one isn't press feedback, it's the
     open/close morph (see .panel-open above), so it's timed to
     .credits-btn-fill's own 0.25s opacity fade instead, letting the
     blur disc and the fill disappear together as one motion rather
     than the blur finishing first and leaving the fill trailing
     behind it. */
  transition: box-shadow 100ms ease, filter 100ms ease,
              backdrop-filter 0.25s ease, -webkit-backdrop-filter 0.25s ease;
  /* Same preventive fix as index.html's identical copy of this
     comment: Mobile Safari's default tap-highlight overlay becomes
     newly visible once a button's background goes transparent while
     still tappable, with nothing left to blend it into — exactly
     what happens here once the morph above clears this button's own
     fill layer. */
  -webkit-tap-highlight-color: transparent;
}
.credits-btn-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the icon svg) regardless of DOM order — see
     .stat-widget-fill's own copy of this same comment (account.html)
     for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: rgba(var(--surface-rgb), 0.6);
  border: 1px solid var(--border);
  /* Two genuinely different speeds on purpose — see .credits-btn's
     own comment above. background-color/border-color at the
     standard 2400ms theme fade; opacity (what the morph now
     animates) kept at the original 0.25s. */
  transition: background-color 2400ms ease, border-color 2400ms ease, opacity 0.25s ease;
}
.credits-btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--btn-tl-glint-angle, 200deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--btn-tl-glint-angle, 200deg),
    rgba(255, 255, 255, 0.9) 0%,
    rgba(var(--light-rgb), 0.45) 22%,
    transparent 48%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
  /* opacity transition added for the frosted-press rule further
     down — see index.html's own copy of this comment. */
  transition: opacity 100ms ease;
}
.credits-btn svg { width: 17px; height: 17px; }
.credits-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.credits-panel {
  position: fixed;
  /* + --safe-top — this panel's own top edge (and the credits button
     that morphs into its header when open, see .credits-panel-header
     elsewhere) needs the same treatment as the header buttons above:
     without it, it'd sit exactly 16px from the true top of the
     screen once viewport-fit=cover is active, extending its own
     header content up under the notch/status bar. */
  top: calc(16px + var(--safe-top) + var(--os-glass-bleed));
  left: 16px;
  bottom: 16px;
  width: min(400px, calc(100vw - 32px));
  z-index: 60;
  transform: translateX(-120%);
  opacity: 0;
  pointer-events: none;
  /* Own open/close animation stays fast (transform/opacity); the
     theme-driven fill/border now fade at the same 2400ms as the rest
     of the page on .credits-panel-flip-pane below, not here. */
  transition: transform 0.32s cubic-bezier(.22,.61,.36,1), opacity 0.25s ease;
  /* flip easter egg — .credits-panel is now just a plain positioning
     shell for its own slide open/close (untouched, above) — the
     glass styling (background, blur, border, the glint) and the flip
     rotation both moved to .credits-panel-flip-pane, a child, exact
     same reasoning as account.html's #sidebar/#sidebar-flip-card
     split: this element's own transform is already spoken for by the
     slide animation and can't also carry a rotateY flip. perspective
     here is what makes that child's rotation read as real depth. */
  perspective: 1400px;
}
/* flip easter egg — the actual glass pane now; see .credits-panel's
   own comment above for why this split happened. Double-click the
   panel's own background (not a credit's link, which would just
   navigate as normal on the first click) to flip it around and see
   the back, mirrored text and all — same pattern as account.html's
   sidebar throughout. */
.credits-panel-flip-pane {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  position: relative;
  /* background: none / border: transparent — Carson's site-wide ask
     after confirming the blur/color split looks identical to the
     combined approach. Visible fill/border moved to the new
     .credits-panel-flip-pane-fill child below; box-shadow stays
     here since it's not part of the 2400ms color transition either.
     border stays 1px transparent (border-box) to preserve this
     element's exact dimensions. */
  background: none;
  /* Reverted back to the original blur(20px) — Carson's follow-up
     report: matching this panel's transparency to the cards made it
     too hard to read as a flyout sitting over arbitrary page
     content behind it, unlike the cards, which never have anything
     unpredictable underneath them. */
  backdrop-filter: blur(20px) saturate(1.5);
  -webkit-backdrop-filter: blur(20px) saturate(1.5);
  border: 1px solid transparent;
  border-radius: var(--radius);
  /* Same light-respecting shadow as .login-modal-flip-pane — see
     its own comment for the full reasoning. --credits-panel-
     shadow-x/-y set in setGlintAngles(), own dedicated name (not
     a shared generic one) since this is set on :root rather than
     the panel itself — see index.html's own copy of this comment
     for why that distinction matters. */
  box-shadow: var(--credits-panel-shadow-x, 0px) var(--credits-panel-shadow-y, 16px) 52px rgba(0, 0, 0, 0.15);
  transform-style: preserve-3d;
  transform-origin: 50% 50%;
  /* transform/filter only now — background-color/border-color moved
     to .credits-panel-flip-pane-fill along with the properties they
     animate. */
  transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
}
.credits-panel-flip-pane-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the panel's own header/body) regardless of DOM order
     — see .stat-widget-fill's own copy of this same comment
     (account.html) for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  /* Reverted back to the original 0.5 — see .credits-panel-flip-
     pane's own comment for why. */
  background: rgba(var(--surface-rgb), 0.5);
  border: 1px solid var(--border);
  transition: background-color 2400ms ease, border-color 2400ms ease;
}
.credits-panel-flip-pane.flipped {
  transform: rotateY(180deg);
  pointer-events: none;
  filter: brightness(0.92) saturate(0.85);
}
@media (prefers-reduced-motion: reduce) {
  /* transform/filter (the flip itself) is what actually needs
     disabling here — background-color/border-color no longer live
     on this element at all post-split. The fill layer's own 2400ms
     theme fade is untouched by this selector entirely, exactly as
     intended. */
  .credits-panel-flip-pane { transition: none; }
}
/* The sheen itself: a 1.5px ring, drawn as a gradient rather than a
   solid color, masked so only the ring (not the fill) is visible.
   Brightest right at the top-right corner, fading to nothing by the
   opposite corner — an actual specular highlight, not a border. */
.credits-panel-flip-pane::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1.5px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--glint-angle, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--glint-angle, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
/* flip easter egg — mirror-corrected glint + directional haze on the
   back, same technique as account.html's sidebar. */
.credits-panel-flip-pane.flip-mid::before {
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
}
.credits-panel-flip-pane::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease 0.35s;
  background: linear-gradient(var(--glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.16) 0%,
    rgba(255, 255, 255, 0.08) 35%,
    rgba(255, 255, 255, 0.03) 65%,
    transparent 100%);
}
.credits-panel-flip-pane.flipped::after { opacity: 1; }
/* flip easter egg — the pane's visible "thickness", same technique as
   account.html's #sidebar-edge-left/-right: two thin strips
   perpendicular to the main face, forming the left/right edges of a
   shallow box. Edge-on (barely visible) when the pane faces the
   viewer head-on; they widen into view as the whole thing turns.
   Only the OUTER-facing corner of each strip is rounded, matching
   the parent's own radius on that side — a plain rectangular strip's
   square corners would otherwise poke out past the parent's rounded
   corner (a real bug caught on account.html's own widgets). */
.flip-edge-left, .flip-edge-right {
  position: absolute;
  top: 0;
  height: 100%;
  width: 14px;
  background: linear-gradient(to right, rgba(var(--surface-rgb), 0.42), rgba(var(--surface-rgb), 0.14));
  backdrop-filter: blur(20px) saturate(1.5);
  -webkit-backdrop-filter: blur(20px) saturate(1.5);
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  pointer-events: none;
}
.flip-edge-left {
  left: 0;
  transform-origin: left center;
  transform: rotateY(90deg);
  border-left: 1px solid var(--border);
  border-top-left-radius: inherit;
  border-bottom-left-radius: inherit;
}
.flip-edge-right {
  right: 0;
  transform-origin: right center;
  transform: rotateY(-90deg);
  border-right: 1px solid var(--border);
  border-top-right-radius: inherit;
  border-bottom-right-radius: inherit;
}
.credits-panel.open {
  transform: translateX(0);
  opacity: 1;
  pointer-events: auto;
}
.credits-panel[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
  .credits-panel { transition: none; }
}

.credits-panel-header {
  /* History: started at 20px, pushed to 48px to clear the "i" button
     sitting at the panel's own top-left corner (top:18px; left:18px;
     38px), then reverted back to 20px once that button started
     fading to opacity:0 the instant the panel opened — at that
     point the two were never visible at the same time, so the
     reserved space was solving a collision that couldn't happen.
     Restored again now, to a larger 68px — Carson's follow-up ask
     reversed that fade entirely: the button now morphs into a bare
     icon INSIDE the open panel instead of disappearing (see
     .credits-btn-persp-wrap.panel-open .credits-btn), so it's
     visible the whole time the panel's open, and the collision this
     padding originally existed to prevent is real again. 68px, not
     just the icon's own bare 56px bottom edge (18px top + 38px
     height) — Carson's own explicit ask this time was for genuine
     breathability, not just the minimum clearance needed to avoid
     literal overlap. */
  padding: 68px 20px 14px 20px;
  border-bottom: 1px solid var(--border);
  /* Same missing-transition bug as .credits-item and friends just
     below — border-color snapping instantly instead of crossfading
     with everything else on theme toggle. Never had a transition
     here at all. */
  transition: border-color 2400ms ease;
  flex-shrink: 0;
}
.credits-panel-header h2 { font-size: 17px; font-weight: 650; margin: 0 0 4px; letter-spacing: -0.01em; }
.credits-panel-header p { margin: 0; font-size: 12.5px; color: var(--text-dim); line-height: 1.4; }

.credits-panel-body {
  overflow-y: auto;
  /* Top padding matches .credits-item's own margin-bottom (10px), so
     the gap above the first card is the same as the gap between every
     other pair of cards — previously this was only 4px, noticeably
     tighter than the 10px rhythm between cards, which is what made
     the first one read as crowded against the header's divider. */
  padding: 10px 20px 20px;
  flex: 1 1 auto;
  /* Fade mask — same mechanic as account.html/index.html's own
     scrollable cards/lists. --fade-top/--fade-bottom are set by JS,
     computed from actual scroll position — 0px at whichever edge
     genuinely has nothing more content past it. */
  --fade-top: 0px;
  --fade-bottom: 0px;
  mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
  -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
}
.credits-item {
  position: relative;
  padding: 14px 16px;
  margin-bottom: 10px;
  /* More opaque than the panel itself ("even more opaque frosted
     glass" per the brief) — higher fill opacity than .credits-panel,
     so each card reads as its own denser pane of glass sitting on
     top of the panel's glass. Same gradient-border sheen technique
     as .credits-panel::before, just thinner/tighter since these
     cards are much smaller.
     background: none / border: transparent — Carson's site-wide ask
     after confirming the blur/color split looks identical to the
     combined approach: the visible fill/border moved to the new
     .credits-item-fill child below, this element keeps only the
     static blur + layout. border stays 1px transparent (border-box)
     to preserve this element's exact dimensions. */
  background: none;
  backdrop-filter: blur(16px) saturate(1.5);
  -webkit-backdrop-filter: blur(16px) saturate(1.5);
  border: 1px solid transparent;
  border-radius: 12px;
}
.credits-item-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (.credits-item-head, the description <p>) regardless
     of DOM order — see .stat-widget-fill's own copy of this same
     comment (account.html) for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background: rgba(var(--surface-rgb), 0.68);
  border: 1px solid var(--border);
  /* Carson's report: these cards were snapping instantly on theme
     toggle instead of crossfading like everything else on this
     panel (.credits-panel-flip-pane itself already has this same
     transition) — simply never had one at all. Now lives here
     instead of directly on .credits-item. Same for
     .credits-license/.credits-license.proprietary and .credits-item
     a's own color, just below — none of the four had it. */
  transition: background-color 2400ms ease, border-color 2400ms ease;
}
.credits-item::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--glint-angle, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--glint-angle, 225deg),
    rgba(255, 255, 255, 0.9) 0%,
    rgba(var(--light-rgb), 0.45) 22%,
    transparent 48%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
.credits-item:last-child { margin-bottom: 0; }
.credits-item-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.credits-item a {
  font-size: 14px;
  font-weight: 650;
  color: var(--text);
  text-decoration: none;
  /* Same 2400ms crossfade as everything else here, but hover needs
     its own much faster override right below — hovering should feel
     immediate, not fade in over 2.4 seconds like a theme toggle. */
  transition: color 2400ms ease;
}
.credits-item a:hover,
.credits-item a:focus-visible { color: var(--accent); text-decoration: underline; transition: color 0.15s ease; }
.credits-item p { margin: 6px 0 0; font-size: 12.5px; color: var(--text-dim); line-height: 1.45; }

.credits-license {
  flex-shrink: 0;
  font-size: 10.5px;
  font-weight: 650;
  letter-spacing: 0.02em;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--accent-soft);
  color: var(--accent);
  white-space: nowrap;
  transition: background-color 2400ms ease, color 2400ms ease;
}
.credits-license.proprietary {
  background: var(--border);
  color: var(--text-dim);
}

.field-label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-dim); margin: 0 0 6px; }
.field-input {
  width: 100%;
  padding: 10px 12px;
  margin-bottom: 14px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  font-size: 14.5px;
  font-family: inherit;
  transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
}
.field-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.login-error {
  font-size: 13px;
  color: var(--down);
  margin: -4px 0 12px;
}
.login-error[hidden] { display: none; }

.login-attempts-note {
  font-size: 12px;
  color: var(--text-dim);
  margin: -4px 0 12px;
  line-height: 1.4;
}
.login-attempts-note.warn { color: var(--down); }
.login-attempts-note[hidden] { display: none; }

.login-submit {
  width: 100%;
  padding: 12px;
  border-radius: 999px;
  background: var(--text);
  color: var(--bg);
  border: none;
  font-size: 14.5px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.15s ease;
}
.login-submit:hover { opacity: 0.9; }
.login-submit:disabled { opacity: 0.6; cursor: default; }

.login-divider {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 20px 0;
  font-size: 12px;
  color: var(--text-dim);
}
.login-divider::before, .login-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

.passkey-btn {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 11px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease;
}
.passkey-btn svg { width: 17px; height: 17px; }
.passkey-btn:hover { background: var(--accent-soft); }
.passkey-btn:disabled { opacity: 0.6; cursor: default; }

.forgot-password-btn {
  display: block;
  width: 100%;
  text-align: center;
  margin: 12px 0 0;
  padding: 4px;
  border: none;
  background: none;
  color: var(--accent);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.forgot-password-btn:hover { text-decoration: underline; }

.recovery-success {
  font-size: 13px;
  color: var(--ok);
  margin: -4px 0 12px;
  line-height: 1.4;
}
.recovery-success[hidden] { display: none; }

main { position: relative; z-index: 1; width: 100%; max-width: var(--content-w); margin-inline: auto; padding: 0 20px; }

h1 {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  font-size: var(--h1-size);
  font-weight: 650;
  letter-spacing: -0.02em;
  text-align: center;
  margin: 0 0 10px;
}

.tagline {
  text-align: center;
  color: var(--text-dim);
  font-size: var(--tagline-size);
  line-height: 1.5;
  margin: 0 0 28px;
}

.request-line {
  text-align: center;
  font-size: 13px;
  color: var(--text-dim);
  margin: 0 0 52px;
}
.request-line a { color: var(--accent); font-weight: 600; text-decoration: none; }
.request-line a:hover { text-decoration: underline; }

.section-label {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin: 0 0 14px;
  text-align: center;
}

/* ---------- Ambient "cloud passing over the light source" ----------
   Same idea and mechanism as account.html's copy of this — an
   occasional, gradual dimming sweep across every glass surface's own
   glint, staggered by horizontal position (via --cloud-delay, set
   inline per-element in JS) so it reads as one lazy cloud actually
   drifting across everything, not a scatter of things flickering
   independently. Meant to be rare and easy to miss during active
   use, just a bit of life in an otherwise static interface when it's
   sitting idle. */
@keyframes glint-cloud-pass {
  0%, 100% { opacity: 1; }
  35%, 65% { opacity: 0.35; }
}
/* Dims the actual light source glow slightly during the same
   ambient event — Carson's ask: something passing in front of the
   light source is what would cause the reflected glints to dim in
   the first place, so the light itself dipping too (not just its
   reflections) sells that cause-and-effect more convincingly.
   Deliberately gentler than the glints' own dip (they go to 0.35,
   a ~65% reduction; this goes to roughly a ~40% reduction from its
   own 0.26 base) — Carson's own wording was "dim slightly", and
   this is a single, large, ambient glow rather than dozens of small
   reflective highlights, so a heavier dip here would likely read as
   the whole page darkening rather than a subtle atmospheric detail.
   No per-element stagger needed (unlike the glints) since there's
   only one light source, not many — fires immediately when
   cloud-pass-active is added to body, at the START of the whole
   cascade, roughly matching the idea that the cloud reaches the
   light source itself before its shadow ripples out to distant
   reflections. Same 4.5s duration as the glints, for one consistent
   "how long a pass takes" across the whole effect. */
@keyframes light-glow-cloud-pass {
  0%, 100% { --light-glow-alpha: 0.26; }
  35%, 65% { --light-glow-alpha: 0.16; }
}
body.cloud-pass-active {
  animation-name: light-glow-cloud-pass;
  animation-duration: var(--cloud-pass-duration, 4.5s);
  animation-timing-function: ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
  body.cloud-pass-active { animation: none; }
}
.cloud-pass-active .theme-toggle::before,
.cloud-pass-active .user-chip::before,
/* The Home button's ring is defined in buttons.css; this only drives
   it. Without the line, it would be the one glint in the corner row
   that ignores a passing cloud. */
.cloud-pass-active .header-actions .btn::before,
.cloud-pass-active .login-modal-flip-pane::before,
.cloud-pass-active .credits-btn::before,
.cloud-pass-active .credits-panel-flip-pane::before,
.cloud-pass-active .credits-item::before,
.cloud-pass-active .card::before,
.cloud-pass-active .glass-card::before {
  animation-name: glint-cloud-pass;
  animation-duration: var(--cloud-pass-duration, 4.5s);
  animation-timing-function: ease-in-out;
  animation-delay: var(--cloud-delay, 0s);
}
/* Same class of bug as the backdrop-filter remnant above, caught
   while fixing that one: a CSS animation beats a normal declaration
   in the cascade, so this ambient glint animation was overriding
   .credits-btn-persp-wrap.panel-open .credits-btn::before's own
   opacity:0 — meaning any cloud pass that happened to fire while
   the panel was open flashed the button's circular edge highlight
   back on over it, a stray ring around an icon with no circle left
   to justify its shape. Excluded rather than re-hidden with
   !important: there's genuinely nothing left to animate here once
   the glint is gone, so dropping the animation is the honest fix.
   Sits after the rule it overrides and carries higher specificity,
   so it wins on both counts. */
.cloud-pass-active .credits-btn-persp-wrap.panel-open .credits-btn::before {
  animation-name: none;
}
@media (prefers-reduced-motion: reduce) {
  .cloud-pass-active .theme-toggle::before,
  .cloud-pass-active .user-chip::before,
  .cloud-pass-active .header-actions .btn::before,
  .cloud-pass-active .login-modal-flip-pane::before,
  .cloud-pass-active .credits-btn::before,
  .cloud-pass-active .credits-panel-flip-pane::before,
  .cloud-pass-active .credits-item::before,
  .cloud-pass-active .card::before,
  .cloud-pass-active .glass-card::before {
    animation: none;
  }
}

.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;
}

/* ---------- Request-page specific (kept from the original file) ---------- */
main { max-width: 460px; } /* narrower than the homepage's content-w — long form rows read better tighter */

/* ---------- Service brand logos ----------
   Ported from account.html — see that page's own copy of this
   comment for the full reasoning. Shared here by the credits panel
   links, the "Included With Every Account" static list, and the
   "Choose Your Services" card — same sizing/coloring logic in all
   three, one rule rather than three copies. */
.service-logo {
  display: inline-block;
  height: 0.95em;
  width: auto;
  vertical-align: middle;
  margin-right: 6px;
  flex-shrink: 0;
  /* Dark mode: the same slightly-muted treatment as before — these
     are full-color brand marks (unlike the site's own monochrome
     currentColor icons), so a plain `color` change can't touch
     them; filter:saturate()/brightness() is what dials that back
     uniformly across 12 very different multi-color SVGs.

     Driven by --logo-dim rather than by a data-theme selector. The
     selector version snapped: the toggle suppresses every CSS
     transition site-wide for the animation's whole duration (see
     .theme-animating) and only flips data-theme in finish() at the
     very end, so the `transition: filter 2400ms ease` that used to
     sit here was overridden by !important at exactly the instant it
     would have mattered. Same failure, and same fix, as the status
     and banner colors already carry in COLOR_SPECS: interpolate in
     step with the rest of the page rather than exempt this from
     .theme-animating. Endpoints unchanged — this resolves to
     saturate(0.55) brightness(0.85) at --logo-dim: 1. */
  filter: saturate(calc(1 - 0.45 * var(--logo-dim, 0)))
          brightness(calc(1 - 0.15 * var(--logo-dim, 0)));
}
/* AudioBookRequest's own mark is single-color (unlike every other
   logo here, which are full multi-color brand marks) — its upstream
   SVG shipped its own light/dark handling, but only reacted to the
   system's OWN color-scheme preference, not this site's explicit
   data-theme toggle override, so a user who's toggled the SITE to
   dark while their system stays light would've seen an invisible
   black mark on a dark background. Stripped that out at the source
   and drive it here instead, matching this site's actual dark-mode
   detection pattern (explicit attribute, OR system preference unless
   explicitly overridden to light) rather than system-preference-only. */
.abr-logo-path { fill: var(--abr-logo-fill, #000000); }

/* ---------- Glass cards ----------
   Same frosted-glass values as .credits-item on the homepage (fill
   opacity 0.68, blur(16px) saturate(1.5)) — one card per section of
   this form, matching the homepage's glass language rather than the
   original flat opaque sections. Same gradient-border glint
   technique too, but computed per-card here rather than shared from
   one root variable: unlike the credits panel (fixed to the
   viewport) or even the carousel (one compact cluster), these four
   cards are spread across a tall scrolling page, far enough apart
   that a single shared angle would be visibly wrong for at least
   some of them. See the glint-angle script below for how each card
   gets its own live-computed value. */
.glass-card {
  position: relative;
  /* background: none / border: transparent — Carson's site-wide ask
     after confirming the blur/color split looks identical to the
     combined approach. The visible fill/border moved to the new
     .glass-card-fill child below; this element keeps only the
     static blur + layout. border stays 1px transparent (border-box)
     to preserve this element's exact dimensions — box-shadow stays
     here too, since it's not part of the 2400ms color transition
     either. */
  background: none;
  /* Carson's diagnostic confirmed it directly: with stars
     temporarily boosted to 5-9px and locked at full opacity, they
     were clearly visible through the card — so stars genuinely are
     present behind it, and blur radius (not fill alpha) was always
     the real bottleneck. A real star is only 0.8-2.4px; a Gaussian
     blur spreads a point of light over roughly its own radius in
     every direction, and peak brightness falls off fast as that
     area grows, so even 6px was still enough to spread a 1-2px dot
     thin enough to disappear. Dropped to 2px — near the floor of
     what still reads as "glass" rather than a bare cutout, but
     small enough that a star's light shouldn't get diffused away
     entirely. */
  backdrop-filter: blur(2px) saturate(1.5);
  -webkit-backdrop-filter: blur(2px) saturate(1.5);
  border: 1px solid transparent;
  border-radius: var(--radius);
  padding: clamp(22px, 4vw, 28px) clamp(18px, 4vw, 24px);
  margin-bottom: 28px;
  /* flip easter egg — a genuine rotateY flip, same technique as the
     credits panel/sidebar elsewhere on this site, but simpler here:
     no separate outer positioning shell + inner rotating pane split
     needed, since .glass-card doesn't already have a transform of
     its own to preserve (unlike the sidebar/credits-panel, which
     both needed that split because their OWN transform was already
     spoken for by a slide-open animation). transform/filter merged
     directly into the existing transition list below, not left to a
     separate rule — a real bug caught earlier this session:
     CSS transition doesn't merge across rules with equal
     specificity, only the later one wins, so appending a second
     rule here would have silently dropped this transition. */
  transform-style: preserve-3d;
  transform-origin: 50% 50%;
  /* Shadow respects the light source's own position — same
     technique as the credits panel/login modal above. Ordinary
     page-flow content, no overflow: hidden ancestor to clip it the
     way the carousel had on index.html, so no layout buffer needed
     here — plain reach/blur, same recipe as the other flip-panes.
     --shadow-x/-y set per-card in setGlintAngles() (the .glass-card
     one, further down), reusing the live getBoundingClientRect()
     each card already computes its own glint angle from. */
  box-shadow: var(--shadow-x, 0px) var(--shadow-y, 16px) 52px rgba(0, 0, 0, 0.15);
  /* transform/filter only now — background-color/border-color moved
     to .glass-card-fill along with the properties they animate. */
  transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
}
.glass-card-fill {
  position: absolute;
  inset: 0;
  /* Negative, not left at auto/0 — a position:absolute element with
     default z-index still paints ABOVE normal-flow, non-positioned
     siblings (the card's own labels/inputs) regardless of DOM order
     — see .stat-widget-fill's own copy of this same comment
     (account.html) for the full reasoning. */
  z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  /* Carson's ask, third pass: 0.68 -> 0.48 -> 0.32 still wasn't
     enough to make out stars behind the card, his own explicit
     benchmark for this. Down to 0.18 here, alongside also reducing
     .glass-card's own backdrop-filter blur radius (16px -> 6px) —
     that blur was likely the real ceiling on star visibility all
     along, independent of this fill's own alpha. Inner "choice"
     content (.option, .included .row) keeps its own separate, solid
     var(--surface) fill untouched regardless of this number, so it
     stays just as frosted as before no matter how far this moves.
     This card isn't part of any stacked group (unlike account.html's
     settings cards, which sit directly on top of each other) —
     nothing behind it to worry about seeing through into. */
  background: rgba(var(--surface-rgb), 0.18);
  border: 1px solid var(--border);
  /* Matches body{}'s own 2400ms light/dark transition — without this,
     --surface-rgb and --border change value instantly on toggle (CSS
     custom properties don't animate on their own), so the card
     colors snapped while the background eased, which read as two
     separate things happening at different speeds instead of one
     coordinated theme change. Now lives here instead of directly on
     .glass-card. */
  transition: background-color 2400ms ease, border-color 2400ms ease;
}
.glass-card.flipped {
  /* perspective(1400px) here, not a separate perspective property on
     an ancestor — see the removed #request-form rule's own comment
     for why: this keeps the vanishing point centered on THIS card
     specifically, regardless of where it sits on the page, so every
     card rotates on a clean vertical axis rather than however far
     off-center it happens to be from some shared ancestor's middle. */
  transform: perspective(1400px) rotateY(180deg);
  filter: brightness(0.92) saturate(0.85);
  /* Deliberately NOT pointer-events:none — a real bug from earlier
     this session: since the dblclick listener that flips a card
     lives on the card itself (there's no separate outer element to
     put it on here, unlike the sidebar/credits-panel), blocking
     pointer events on this same element would also block the very
     gesture needed to flip it back. Form fields staying nominally
     interactive while flipped matches the credits panel's own
     precedent (its links stay clickable through the haze too). */
}
@media (prefers-reduced-motion: reduce) {
  /* transform/filter (the flip itself) is what actually needs
     disabling here — background-color/border-color no longer live
     on this element at all post-split, so there's nothing left of
     the old value this rule used to also carry. The fill layer's
     own 2400ms theme fade is untouched by this selector entirely,
     exactly as intended: disabling it under reduced motion would
     reintroduce the instant-snap bug this whole site-wide pass just
     fixed. */
  .glass-card { transition: none; }
}
.glass-card:last-of-type { margin-bottom: 0; }
.glass-card::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1.5px;
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--card-glint-angle, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--card-glint-angle, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
/* flip easter egg — mirror-corrected glint + directional haze on the
   back, same technique as the credits panel/sidebar elsewhere. */
.glass-card.flip-mid::before {
  /* Sparkle: tight, bright hot-spot layer + background-blend-
     mode:screen on top of the original glint below — same
     technique as index.html's own copy of this. */
  background:
    linear-gradient(var(--card-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 1) 0%,
      rgba(255, 255, 255, 0.85) 1.5%,
      rgba(255, 255, 255, 0.3) 4%,
      transparent 9%),
    linear-gradient(var(--card-glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.95) 0%,
    rgba(var(--light-rgb), 0.55) 18%,
    rgba(var(--light-rgb), 0.12) 40%,
    transparent 62%);
  background-blend-mode: screen, normal;
}
.glass-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease 0.35s;
  background: linear-gradient(var(--card-glint-angle-flipped, 225deg),
    rgba(255, 255, 255, 0.16) 0%,
    rgba(255, 255, 255, 0.08) 35%,
    rgba(255, 255, 255, 0.03) 65%,
    transparent 100%);
}
.glass-card.flipped::after { opacity: 1; }
.glass-card .section-label:first-child { margin-top: 0; }

/* ---------- Desktop layout: two columns ----------
   Mobile-first default above is untouched — .cards-row and .card-col
   are plain block elements until this breakpoint, so cards 1–3 just
   stack in DOM order exactly as before (card-col-left holds cards 1
   and 2, card-col-right holds card 3, and since neither is styled
   yet they render as ordinary stacked divs). At 860px+, this turns
   that same DOM into two flex columns side by side — info + included
   stacked on the left, the taller services list on the right — with
   the verification/submit card as its own centered row underneath,
   rather than stretched full-width. Only min-width was needed here;
   nothing above had to change to make mobile "stay the way it was,"
   because it already was that shape by default. */
@media (min-width: 860px) {
  main { max-width: 880px; }
}

/* =========================================================================
   MOBILE OVERRIDES
   -------------------------------------------------------------------------
   Carson's own ask: carry the nav-bar touch-target sizing already built
   and confirmed on account.html's own mobile view over to this page too
   (matches index.html's own identical copy of this same block — both
   pages share the exact same .theme-toggle/.login-btn/.user-chip-wrap/
   .credits-btn-persp-wrap structure and values). Placed last in the
   stylesheet on purpose, matching account.html's own established
   convention for exactly this kind of override.
   ========================================================================= */
@media (max-width: 600px) {
  /* .top-frost-bar's own height is otherwise a flat 74px+safe-top at
     every width (see that rule's own comment) — sized for the 38px
     default icon size. At this breakpoint the icons below grow to
     48px, but that height never followed them: the bar's own bottom
     edge stayed put while the icons themselves grew and pushed
     further down into it, leaving noticeably less gap below them
     than above (18px above, only 8px below at 48px) — a real
     mismatch that exists independent of --safe-top entirely, not
     something the earlier safe-top halving fix (elsewhere in this
     file) could have touched. +10px here (74->84) is exactly the
     icon's own growth (38->48px) added straight onto the base
     formula, restoring the same symmetric 18px-ish gap on both
     sides that already holds at desktop icon size. */
  .top-frost-bar {
    height: calc(84px + var(--safe-top) + var(--os-glass-bleed));
  }
  /* 38px -> 48px, ~25% — same target size, same reasoning as account.html's
     own copy of this: fine for a mouse, too small a touch target on an
     actual phone. */
  .theme-toggle {
    width: 48px;
    height: 48px;
  }
  .theme-toggle svg {
    width: 21px;
    height: 21px;
  }
  /* .credits-btn itself is width:100%/height:100% of its own positioned
     wrapper (.credits-btn-persp-wrap, left:18px — opposite corner from
     theme-toggle/login-btn, so no collision to account for on this
     side) — the wrapper is what actually needs resizing, not this
     element directly. */
  .credits-btn-persp-wrap {
    width: 48px;
    height: 48px;
  }
  .credits-btn svg {
    width: 21px;
    height: 21px;
  }
  /* .login-btn isn't a circle (it's a pill: height:38px + horizontal
     padding, not width:height) — scaled proportionally to match the
     SAME rate of growth the two circular buttons got, not copied
     numbers from a differently-shaped element. height 38->48
     (identical target, ~26%), padding 18->23px (~28%, matching that
     ratio), font-size 13.5->15px (~11%, the same modest bump used for
     .sidebar-cta's own analogous pill scale-up on account.html).
     right:66px -> 76px is the real follow-on effect of growing
     .theme-toggle above: that button's own left edge just moved from
     18+38=56px to 18+48=66px, and .login-btn's old 66px right-offset
     was chosen to sit just past that old edge with a ~10px gap — left
     unchanged, the new, bigger theme-toggle would now sit flush
     against (or slightly overlapping) this button instead of beside
     it. +10px keeps that same gap intact against the toggle's new,
     larger footprint. */
  .login-btn {
    /* right:76px was here - the +10px follow-on for .theme-toggle
       growing to 48px, which the flex row now does by measuring. Only
       the size changes survive. */
    height: 48px;
    padding: 0 23px;
    font-size: 15px;
  }
  /* .user-chip-wrap's own rule here held nothing but right:76px - the
     same +10px follow-on .login-btn needed when .theme-toggle grew to
     48px - and went with the offsets on 4 Sep 2026. The flex row
     re-spaces itself, so only .user-chip's size changes remain. */
  /* Avatar only, matching /account at this same breakpoint. The name
     and the chevron go: at avatar-only width the chevron reads as
     decoration rather than an affordance, and the whole chip is the
     target anyway.

     This is a fix, not a preference. Left at full width the chip is
     179px ("Carson Aldred"; the name is capped at 120px, so ~186px is
     the worst case), which makes the row 353px with the Home button in
     it. At 390px that puts the row's left edge at x=19 against a
     credits button occupying 18-66 - a 47px overlap, with the Home
     button drawn on top of the "i". Collapsed: chip 48px, row 222px,
     left edge 150px, 84px clear. The threshold where the two would
     still collide drops from 437px to 306px, below any real phone.

     Values are account.css's, so both pages collapse identically. */
  .user-chip {
    height: 48px;
    width: 48px;
    padding: 0;
    justify-content: center;
    font-size: 15px;
  }
  .user-chip .name,
  .user-chip .chevron { display: none; }
  .user-chip .avatar { width: 30px; height: 30px; font-size: 12px; }
}

/* =========================================================================
   PWA / STANDALONE: clear the iOS status bar
   -------------------------------------------------------------------------
   Installed-as-a-PWA only. In a Safari tab env(safe-area-inset-top) is 0
   and the layout viewport already starts below the status bar, so the
   existing offsets are correct there and are left untouched. In standalone
   mode the inset is real (62px on a 440x956pt device, read off the device
   rather than inferred) and the status bar genuinely occupies that space,
   so anything positioned into it is covered by the OS.

   Two consequences, both fixed here:

   1. The floating buttons used (var(--safe-top) / 2). Half an inset put
      them at ~49pt, level with the clock, where iOS composites its own
      glass over them — which is why they read as permanently washed out
      and why the fade never changed with scroll: that treatment belongs to
      the OS, sits above every page layer, and ignores scroll entirely.
      The full inset clears it. Gaps stay symmetric, so the frost bar's own
      height needs no matching change: the bar spans 0..(84 + --safe-top),
      the buttons sit at (18 + --safe-top) and are 48px tall at phone
      widths, giving 80 - 62 = 18 above (measured from the safe-area
      boundary, the first pixel actually visible) and 146 - 128 = 18 below.

   2. Only the buttons moved, so the page's own header ended up underneath
      them. body's padding-top already encodes 18px + button height + the
      design's gap, so moving the buttons down by exactly var(--safe-top)
      means moving content down by exactly var(--safe-top) too. That
      preserves the current spacing rather than inventing new spacing. The
      banner's negative margin exists only to cancel body's top padding, so
      it grows by the same amount in the same direction.

   Placed last in the stylesheet on purpose, following this file's existing
   convention for overrides: each selector below sets its own value in a
   rule further up, and for equal specificity the later rule wins whatever
   media query surrounds it. An earlier copy would be silently dead.
   ========================================================================= */
@media (display-mode: standalone) {
  /* See --os-glass-bleed's own comment at the top of this stylesheet
     for what this is and how it was measured. Set here rather than
     at :root so it can only ever be non-zero in standalone mode —
     every rule that adds it is then a guaranteed no-op in a tab. */
  :root {
    --os-glass-bleed: 24px;
  }
  /* .header-actions, not the three controls that used to be named here.
     They were each independently position:fixed until 4 Sep 2026 and
     each needed this override; they are flex children now and carry no
     top offset, so naming them here would be a no-op and the ROW would
     have kept its tab-mode top in an installed app. That is a bug only
     visible on a home-screen PWA, which is exactly where it would not
     have been noticed.

     .credits-btn-persp-wrap is untouched by that change - still
     independently fixed over on the left - so it keeps its place in
     this selector list. */
  .header-actions,
  .credits-btn-persp-wrap {
    top: calc(18px + var(--banner-h, 0px) + var(--safe-top) + var(--os-glass-bleed));
  }

  body {
    --body-pad-top: calc(72px + var(--safe-top) + var(--os-glass-bleed));
  }
  /* Nothing else to restate here: .site-banner is fixed at top: 0 and
     body's padding derives from the variable this rule just changed,
     so both follow automatically. */
}

/* =====================================================================
   WHAT THE NEW PAGES ADD (Package F8, 8 Oct 2026)
   ===================================================================== */

/* The dot wave sits on the bottom edge of the window. Carson, on the
   mockup: "The dot wave isn't positioned at the bottom of the webpage ...
   It's empty as well, so there's no reason for there to be a need to
   scroll down." <main> fills the window below the top bar, the content
   keeps its own height, and the wave takes the room left over at the
   bottom. A page taller than the window scrolls as before and ends on the
   wave.

   svh, not dvh, for the reason dot-wave.css gives for the band's own
   height: WebKit doesn't reliably re-measure dvh when a phone's toolbar
   comes back. svh is the window with the toolbar showing, which is what a
   page that doesn't scroll always has, since only scrolling hides it. vh
   comes first for a browser that doesn't know svh. */
main.page-frame {
  display: flex;
  flex-direction: column;
  min-height: calc(100vh - var(--body-pad-top) - var(--banner-h, 0px));
  min-height: calc(100svh - var(--body-pad-top) - var(--banner-h, 0px));
}
/* width: 100% because a flex item with auto side margins otherwise
   shrinks to its content; a page sets its own max-width and centres with
   margin-inline: auto. */
main.page-frame > .page-body { flex: 0 0 auto; width: 100%; margin-top: 28px; margin-bottom: 20px; }
main.page-frame > .wave-band { margin-top: auto; flex: none; }

/* The stars layer is exactly the page's height, and sky.js places a star
   anywhere from 0 to 100% down it, so one at the very bottom stuck out by
   its last pixel or two. On a page that fits the window, that made the
   page scroll by that much. Stars that low are already masked out above
   the fog line, so clipping them changes nothing you can see. */
.stars { overflow: hidden; }

/* A page's opening card: its title, a line, and a small label. */
.page-head { text-align: center; }
.page-head .tagline { max-width: 620px; margin-left: auto; margin-right: auto; }
.page-head .section-label { margin-bottom: 0; }
