/* ============================================================
   TOKENS — the single source of truth for scale and rhythm.

   Loaded FIRST on every page, before any theme or page stylesheet.
   That ordering is deliberate: everything here is a default that a
   later stylesheet may override, so adding this file cannot change
   an existing page. It only fills gaps.

   Colour palettes stay where they are. Each surface (lab, orrery,
   vellum, vespera, shelterix, journal, news, genesis) owns its own
   hues, and this file never overwrites them. What it does own:

     - the type, space, radius, elevation and motion scales
     - the values derived FROM a theme's ink, via color-mix, so a
       rule or a muted tone is always a tint of that theme's own
       ink rather than a separately-tuned hex
     - two variables the site consumed but never defined

   Deriving instead of hard-coding is what will let every surface
   gain a light and a dark counterpart without hand-tuning a second
   palette for each one.
   ============================================================ */

/* ============================================================
   FONTS — declared once, for every page.

   These eight faces were previously declared three times: in
   polish.css, again in journal.css under aliased family names
   ('Journal Newsreader' and friends) pointing at the same files, and
   again in news.css with weight ranges the files do not contain, so
   the browser synthesised the in-between weights. login.html loaded
   two of those blocks and downloaded the same faces twice.

   font-display is swap throughout. It was optional on Hanken Grotesk,
   which meant that on a slow first visit the body face never arrived
   at all -- the page simply kept the fallback. url() resolves against
   this stylesheet, not the document, so the paths hold on every route.
   ============================================================ */
@font-face {
  font-family: 'Hanken Grotesk';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('fonts/hanken-grotesk-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Newsreader';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/newsreader-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Newsreader';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/newsreader-italic-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Newsreader';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('fonts/newsreader-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Newsreader';
  font-style: italic;
  font-weight: 500;
  font-display: swap;
  src: url('fonts/newsreader-italic-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/ibm-plex-mono-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('fonts/ibm-plex-mono-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('fonts/ibm-plex-mono-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


/* ------------------------------------------------------------
   CASCADE LAYERS — declared here because this file loads first,
   so the order below holds regardless of <link> order on a page.

   That decoupling is the point. polish.css loads BEFORE the page
   stylesheet on six pages (Ephemerent, research, Orrery, Shelterix,
   arbiter-preview, login) and after it on the rest, so it could not
   win on source order and won on !important instead — 81 of them.
   With the order fixed here, those become ordinary rules.

   reset     normalize
   tokens    this file: scales, derived slots, type roles
   product   per-surface palettes and their shared components
   base      base.css, which documents itself as loading after the
             theme CSS. Three pages linked it first anyway; the layer
             makes that contract hold everywhere.
   page      stylesheets loaded by a single page
   polish    polish.css — the shared polish layer. It sits last because
             that is what its 84 !important declarations were encoding:
             hard corners over each product's own radius, flat cards,
             one nav treatment. As a layer it wins by position instead.
   mode      light/dark counterpart palettes. Sets custom properties
             only, and sits after polish because polish.css still
             carries theme blocks for orrery and vellum.
   utilities single-purpose helpers; always last
   ------------------------------------------------------------ */
@layer reset, tokens, product, base, page, polish, mode, utilities;

@layer tokens {
:root {
  color-scheme: light dark;

  /* ---- Type scale -------------------------------------------------
     Ten steps plus three display ramps, replacing 81 ad-hoc sizes.
     Body copy and below is fixed; only display type is fluid, because
     fluid body text fights the reading measure. */
  --t-micro: 0.625rem;   /* 10px — mono micro-labels */
  --t-xs:    0.6875rem;  /* 11px */
  --t-sm:    0.75rem;    /* 12px */
  --t-base:  0.8125rem;  /* 13px */
  --t-md:    0.875rem;   /* 14px */
  --t-lg:    0.9375rem;  /* 15px */
  --t-xl:    1.0625rem;  /* 17px — body */
  --t-2xl:   1.1875rem;  /* 19px */
  --t-3xl:   1.375rem;   /* 22px */
  --t-4xl:   1.625rem;   /* 26px */
  --d-sm: clamp(1.5rem, 2.6vw, 2.125rem);
  --d-md: clamp(2.125rem, 4.3vw, 3.625rem);
  --d-lg: clamp(3.25rem, 6.4vw, 5.5rem);

  /* Line-height, tracking and weight: five of each, no more. */
  --lh-display: 1.05;
  --lh-tight:   1.2;
  --lh-snug:    1.35;
  --lh-body:    1.6;
  --lh-loose:   1.75;

  /* Tracking is a function of size, not of role.

     A face is drawn with sidebearings that suit one optical size. Set
     it much smaller and the letters crowd; set it much larger and they
     drift apart. The fix is the one every type foundry specimen
     applies and most sites skip: open the small sizes, tighten the
     large ones. SSTR, an awwwards site of the day, tracks +0.46px at
     11.5px and -1.88px at 53.8px -- the same curve, in the same
     direction.

     Paired with the step it belongs to, so reaching for a size means
     reaching for its tracking. */
  --ls-micro:   0.06em;    /* 10px mono labels: needs the most air */
  --ls-xs:      0.03em;
  --ls-sm:      0.015em;
  --ls-base:    0.005em;
  --ls-md:      0;         /* around 14px the face is drawn correctly */
  --ls-lg:      0;
  --ls-xl:     -0.003em;
  --ls-2xl:    -0.008em;
  --ls-3xl:    -0.014em;
  --ls-4xl:    -0.018em;
  --ls-d-sm:   -0.022em;
  --ls-d-md:   -0.028em;
  --ls-d-lg:   -0.034em;

  /* Kept because 500-odd declarations still name them. */
  --ls-display: var(--ls-d-md);
  --ls-sans:    -0.04em;   /* Hanken Grotesk display can take more */
  --ls-tight:   var(--ls-2xl);
  --ls-0:        0;
  --ls-label:    0.12em;
  --ls-wide:     0.16em;

  --w-regular: 400;
  --w-medium:  500;
  --w-semi:    600;
  --w-bold:    700;

  /* ---- Space — 4px base ---------------------------------------- */
  --space-3xs: 2px;
  --space-2xs: 4px;
  --space-xs:  8px;
  --space-s:   12px;
  --space-m:   20px;
  --space-l:   32px;
  --space-xl:  52px;
  --space-2xl: 84px;

  /* ---- Radius — hard corners are this site's signature ---------- */
  --radius-control: 3px;
  --radius-panel:   4px;

  /* ---- Motion -------------------------------------------------- */
  /* Six steps, collapsed from 59 distinct literals across 269 uses.
     Anything at or above 1.15s is an ambient loop -- a drifting plate,
     a slow pulse -- and stays a literal in the rule that owns it,
     because those set a scene rather than answer a user. */
  --motion-press:   140ms;   /* 70-150:   a button taking a press */
  --motion-control: 200ms;   /* 151-250:  hover, focus, small state */
  --motion-advance: 300ms;   /* 251-340:  a panel or step advancing */
  --motion-glide:   420ms;   /* 341-470:  larger movement across space */
  --motion-settle:  520ms;   /* 471-650:  entrances coming to rest */
  --motion-slow:    900ms;   /* 651-1150: deliberate, scene-scale */
  --motion-enter:   var(--motion-settle);

  /* Declared at :root so it resolves everywhere. It was previously
     defined only in orrery/shelterix/vespera, so base.css and
     ephemerent.css consumed it on the lab pages and silently fell
     back to the browser default `ease`. */
  --ease:      cubic-bezier(0.22, 1, 0.36, 1);   /* the house curve */
  --ease-out:  cubic-bezier(0.16, 1, 0.3, 1);   /* entrances, longer tail */
  /* news.css animates its risk bars, 3D planes and reveals on a gentler
     curve than the house one. It reads as physical rather than snappy,
     which suits a diagram, so it gets a name instead of being folded in. */
  --ease-soft: cubic-bezier(0.22, 0.61, 0.36, 1);
  /* Symmetrical, for something that has to track a finger rather
     than perform an arrival. */
  --ease-press: cubic-bezier(0.4, 0, 0.2, 1);

  /* ---- Mode ----------------------------------------------------
     Grain sits differently on paper than on a dark ground: multiply
     darkens the low-lights the way ink does on stock, soft-light lifts
     a dark surface without milking it. Each surface sets its own pair
     below, so a theme flipping mode carries its grain with it. */
  --grain-opacity: 0.032;
  --grain-blend: multiply;

  /* ---- Container ----------------------------------------------- */
  --site-max:    1280px;
  --site-gutter: clamp(20px, 3.2vw, 48px);
  --site-nav:    68px;
}

/* ============================================================
   THE HIGH COLOUR HAS TWO NAMES

   Half this site calls its accent --accent and half calls it
   --signal, and nothing ever reconciled them. Measured across the
   surfaces: home, research, arbiter and the legal pages define only
   --accent; orrery, vellum, cloud and download define only --signal;
   vespera and shelterix define both; news, journal and genesis-fall
   define neither and carry private --nw-/--jr-/--qe- palettes.

   The consequence is quiet and expensive. There are thirteen bare
   var(--accent) references in this file -- the prose-link hover, the
   focus ring, the editorial rule, the atmosphere -- and on any
   --signal surface every one of them computes to an invalid value and
   is dropped. The rule is there, it matches, and it does nothing. A
   hover audit is what finally surfaced it: four controls that had a
   matching :hover rule and still did not move.

   This aliases rather than rewriting thirteen call sites, and it
   names the surfaces explicitly. Two attempts before this one got the
   scope wrong in opposite directions, both worth recording:

   A blanket `body { --accent: var(--signal, currentColor) }` beats
   shelterix.css's own `:root` accent, because inheritance is not a
   cascade competition -- a declaration on `body` wins over an
   inherited `:root` value from ANY layer, no matter which came first.
   It silently repainted shelterix from #d98a4a to #f4c98a.

   Moving it to `:root` fixed shelterix and broke the rest: polish.css
   declares --signal on `body.orrery-page`, so a :root rule cannot
   read it and every alias fell through to currentColor.

   So: name the surfaces that have no --accent of their own. It is an
   enumeration, which is usually the wrong shape -- but the set of
   palettes is closed, a new surface simply keeps today's behaviour
   rather than inheriting a wrong colour, and check-surface.mjs fails
   the build if one ever ships without an accent.
   ============================================================ */
body.orrery-page, body.vellum-page,
body.utility-page, body.auth-page {
  --accent: var(--signal, currentColor);
}
/* These two have a real accent, it is just spelled privately. */
body.nw-page { --accent: var(--nw-cobalt, currentColor); }
body.jr-page { --accent: var(--jr-cobalt, currentColor); }

/* ============================================================
   DERIVED SLOTS

   These read the active theme's own --ink, so one definition serves
   every palette on the site and each new theme gets them for free.
   Set on `body` rather than `:root` so a theme's own :root palette
   is already resolved by the time these are computed.
   ============================================================ */
body {
  /* --rule-strong was consumed 10 times in home.css and defined
     nowhere, so `border: 1px solid var(--rule-strong)` fell back to
     currentColor on the two most-visited pages. */
  --rule-strong: color-mix(in srgb, var(--ink, currentColor) 28%, transparent);

  /* Elevation: three tiers, each tinted with the theme's ink rather
     than neutral black, so a shadow reads as the surface's own
     shade instead of a grey wash. */
  /* Tinted toward the ground rather than the ink. A shadow the colour
     of the ink is invisible on a dark surface, because on a dark
     surface the ink is the light thing. */
  --shadow-1: 0 1px 2px rgba(0, 0, 0, var(--shadow-a1, 0.05));
  --shadow-2: 0 12px 28px -12px rgba(0, 0, 0, var(--shadow-a2, 0.12));
  --shadow-3: 0 30px 64px -22px rgba(0, 0, 0, var(--shadow-a3, 0.18));
  --glow: 0 0 60px color-mix(in srgb, var(--signal, var(--accent, transparent)) 16%, transparent);

  /* Night grounds need a deeper shadow to separate at all, and can
     carry one without looking heavy. */
  --shadow-a1: 0.05; --shadow-a2: 0.12; --shadow-a3: 0.18;

  /* A wash of the theme's signal, for hairline fills and hovers. */
  --accent-soft: color-mix(in srgb, var(--signal, var(--accent, transparent)) 12%, transparent);
}

/* ============================================================
   TYPOGRAPHIC ROLES

   Three named rules replacing the ad-hoc display and label
   treatments scattered across the stylesheets.
   ============================================================ */

/* Newsreader is a text serif with wide sidebearings. The aggressive
   negative tracking used on geometric grotesks would collide its
   serifs, so it gets -0.02em and leans on its optical-size axis,
   which base.css already enables. */
.display-serif {
  font-family: var(--serif, Georgia, serif);
  font-weight: var(--w-regular);
  letter-spacing: var(--ls-display);
  line-height: var(--lh-display);
}

.display-sans {
  font-family: var(--sans, system-ui, sans-serif);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-sans);
  line-height: 1.04;
}

/* The house micro-label — already the site's voice, finally named. */
.label {
  font-family: var(--mono, ui-monospace, monospace);
  font-size: var(--t-micro);
  font-weight: var(--w-semi);
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: var(--ls-label);
  color: var(--muted, inherit);
}
}

/* ============================================================
   CRAFT — the shared surface language.

   Lives here rather than in polish.css because journal.css and
   news.css are islands: those seven pages load no shared stylesheet
   at all, and tokens.css is the only file every page has in common.

   Contributed to @layer polish, so polish.css (same layer, loaded
   later) can still override any of it.
   ============================================================ */
/* ---- Measure ----------------------------------------------------
   Past roughly 75 characters the eye loses its place on the way back
   to the next line, and secondary prose is where that goes wrong: a
   note or a piece of fine print inherits the full width of whatever
   grid cell it landed in and ends up running 140, 180, even 200
   characters.

   Two corrections to the first version of this, both measured rather
   than reasoned:

   ch is NOT a character. It is the advance width of "0", and in a
   proportional face the digits are the widest thing in the font.
   Measured against this site's own prose, one ch buys 1.23 characters
   in Hanken Grotesk and 1.35 in Newsreader -- so the original 74ch cap
   was quietly permitting 91 to 100 characters per line, which is the
   number it was written to prevent. The values below are divided
   through by that ratio and land at 71-78 real characters.

   And this belongs in `base`, not in `polish`. A default measure is
   the weakest possible opinion: it exists for the paragraphs nobody
   considered, and it must lose to every paragraph somebody did. In
   the polish layer it outranked the page stylesheets and overrode
   their deliberate values -- the security page asks for 800px on its
   lead and was getting 1035px from here, so the cap meant to tighten
   that column was the thing widening it. The container catch-all is
   wrapped in :where() for the same reason one layer down: it is a
   safety net for unclassed paragraphs and has no business beating
   .lead, which it did on specificity alone.
   ------------------------------------------------------------------ */
@layer base {
  :root { --measure: 55ch; --measure-wide: 62ch; }

  /* A paragraph that is a direct child of a page container has nothing
     between it and the full site width, which is where the worst of
     these were: 137 characters on the security page, 165 on Shelterix.
     Grid and flex children are left alone -- their width is the
     layout's decision, not an oversight. */
  :where(
    .wrap, .ewrap, .callout, .shelterix-register, .footer .wrap,
    article, aside, section, figure, blockquote,
    .nw-record, .nw-branch-insight, .nw-method-note,
    .jr-status-note, .qe-section-head
  ) > p,
  :where(.qe-section-head, .jr-policy section) > div > p {
    max-width: var(--measure-wide);
  }

  .lead,
  .ethesis,
  .artifact-note,
  .research-graph-note,
  .qe-warning,
  .plan-fineprint,
  .pricing-fineprint,
  .pricing-efficiency,
  .billing-fineprint,
  .ework-pricing,
  .jr-status-note,
  .nw-standfirst,
  .sub,
  .fineprint,
  .note {
    max-width: var(--measure);
  }
}


@layer polish {

  /* ---- Film grain --------------------------------------------------
     A single fractal-noise tile, inline as a data URI, so it costs one
     rule and no request. `grain` was already a class on three pages
     with no CSS behind it; this gives every surface the same tooth.

     multiply on paper darkens the low-lights the way ink on stock
     does; soft-light on the dark themes lifts without milking. */
  body::after {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 2000;
    pointer-events: none;
    opacity: var(--grain-opacity, 0.028);
    mix-blend-mode: var(--grain-blend, multiply);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
  }

  /* A narrow column and a long word are a bad pair for display type set
     at a fixed size. Hyphenation is the typographic answer: the browser
     breaks at real syllable boundaries for the document language rather
     than letting the word push the page wider than the viewport. */
  @media (max-width: 640px) {
    /* h1 is deliberately excluded. A brand statement broken as
       "tempo-rary" reads as a fault, not as typesetting, and the hero
       clamps are sized to fit a 375px measure without help. Section
       headings and decks are long enough that a real syllable break is
       preferable to a ragged line, and the limits keep the fragments
       from getting silly. */
    h2, h3, .display, .display-serif, .display-sans, .deck {
      hyphens: auto;
      -webkit-hyphens: auto;
      hyphenate-limit-chars: 8 4 4;
    }
  }

  /* ============================================================
     ARRIVAL

     The hero had no entrance at all: polish.css set transition: none
     on hero reveals and the markup ships them already revealed, so the
     single most-seen moment on the site -- opening it -- was a cut.
     Everything else on the page animates and the first screen did not.

     Staged, not simultaneous. The eye reads a hero in a fixed order --
     kicker, headline, sentence, actions, image -- and motion that
     respects that order reads as the page assembling itself, while
     motion that ignores it reads as one block sliding.

     The headline moves without fading. It is almost certainly the
     largest contentful paint on this page, and an element at opacity 0
     is not painted, so fading it in would delay the metric by the
     length of the animation. Transform is free of that: the text is
     on screen from the first frame, just not yet at rest.
     ============================================================ */
  @media (prefers-reduced-motion: no-preference) {
    html.arrive .ehero .lbl {
      animation: eph-rise 620ms var(--ease-out) both;
    }
    html.arrive .ehero h1 {
      animation: eph-settle 900ms 60ms var(--ease-out) both;
    }
    html.arrive .ehero .ethesis,
    html.arrive .ehero .esub,
    html.arrive .ehero > .ewrap > p {
      animation: eph-rise 760ms 200ms var(--ease-out) both;
    }
    html.arrive .ehero-cta,
    html.arrive .ehero .ebtn {
      animation: eph-rise 700ms 320ms var(--ease-out) both;
    }
    /* The plate settles rather than rises: a picture that slides in
       reads as a slide, one that eases out of a slight overscale reads
       as a camera coming to rest. */
    html.arrive .ehero-plate {
      animation: eph-plate 1100ms 180ms var(--ease-out) both;
    }
    html.arrive .ebar,
    html.arrive .nav {
      animation: eph-chrome 700ms var(--ease-out) both;
    }
  }

  @keyframes eph-rise {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: none; }
  }
  /* No opacity: see the note above about largest contentful paint. */
  @keyframes eph-settle {
    from { transform: translateY(20px); }
    to   { transform: none; }
  }
  @keyframes eph-plate {
    from { opacity: 0; transform: scale(1.035); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes eph-chrome {
    from { opacity: 0; transform: translateY(-8px); }
    to   { opacity: 1; transform: none; }
  }

  /* ---- Entrance ----------------------------------------------------
     Adding a short blur to the existing fade-and-rise is what reads as
     depth of field rather than "a div appeared". 301 elements already
     carry .rv or .reveal, so this needs no markup. */
  .reveal,
  .rv {
    opacity: 0;
    transform: translateY(12px);
    filter: blur(5px);
    /* Three properties, three durations, on purpose. Moving them
       together is the single thing that makes an entrance read as one
       mechanical slide: the element arrives and settles on the same
       frame, which nothing physical does.

       Opacity finishes first, so the content is readable while it is
       still coming to rest. Focus resolves next. Position takes
       longest, because weight is the slowest thing to settle. */
    transition:
      opacity var(--motion-advance) var(--ease-out),
      filter var(--motion-glide) var(--ease-out),
      transform var(--motion-slow) var(--ease-out);
  }
  .reveal.in,
  .rv.in {
    opacity: 1;
    transform: none;
    filter: blur(0);
  }

  /* The markup already carries 51 of these; polish.css was zeroing
     every delay, so a staggered group animated as one block. */
  /* Widening gaps, not even ones: an evenly spaced cascade reads as a
     machine counting rather than as a page arriving. */
  .reveal.d1, .rv.d1 { transition-delay: 70ms; }
  .reveal.d2, .rv.d2 { transition-delay: 135ms; }
  .reveal.d3, .rv.d3 { transition-delay: 195ms; }
  .reveal.d4, .rv.d4 { transition-delay: 250ms; }

  /* ---- Skip link ------------------------------------------------
     Defined here because it is the one control whose failure mode is
     silent in the wrong direction: styled wrong, it is a stray link
     sitting in the top-left corner of the design. It was defined in
     four product stylesheets and genesis-fall.html loads none of
     them, so that page shipped a visible "Skip to content".

     Hidden by clipping rather than by left: -9999px. The offset
     technique keeps the element in the layout at a huge coordinate,
     which some engines will scroll to on focus and which contributes
     to scrollable overflow; clip removes it from paint while leaving
     it exactly where it is. */
  .skip-link, .jr-skip, .nw-skip {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .skip-link:focus, .jr-skip:focus, .nw-skip:focus,
  .skip-link:focus-visible, .jr-skip:focus-visible, .nw-skip:focus-visible {
    position: fixed;
    z-index: 10000;
    top: 12px;
    left: 12px;
    width: auto;
    height: auto;
    margin: 0;
    padding: 10px 16px;
    overflow: visible;
    clip-path: none;
    /* The chain has to reach the namespaced palettes and then end in a
       literal. The journal, news and genesis surfaces define none of
       --signal, --accent or --ink, so a chain that stopped there gave
       the focused link a transparent background -- visible, focused,
       and unreadable, which is worse than hidden. */
    background: var(--signal, var(--accent,
      var(--jr-cobalt, var(--nw-cobalt, var(--qe-teal, var(--ink, #16181c))))));
    color: var(--bg, var(--paper,
      var(--jr-paper, var(--nw-paper, var(--qe-night, #ffffff)))));
    font-family: var(--mono);
    font-size: var(--t-base);
    letter-spacing: var(--ls-sm);
    border-radius: var(--radius-control);
    outline: 2px solid var(--ink, var(--jr-ink, var(--nw-ink, currentColor)));
    outline-offset: 2px;
  }

  /* ============================================================
     DISPLAY CONFIDENCE — lab surfaces

     Every awwwards winner in the reference set sets its display type
     larger and tighter than feels comfortable in a stylesheet, and
     that is most of why they read as designed rather than as typed. A
     serif at 112px with leading under 1 is a different object from the
     same serif at 88px with leading at 1.1: the first is an image, the
     second is a paragraph that happens to be big.

     Newsreader carries this well because it is a text serif with a
     real optical-size axis, which base.css already switches on. What
     it will not carry is grotesk-level tracking -- the sidebearings
     are drawn wide and the serifs collide before -0.04em.
     ============================================================ */
  /* The size here was already judged against the fold: at 132px the
     last line and both calls to action fall off a 900px viewport, and
     a hero that does not finish is not a bigger hero. Left at the
     original scale; what changes is the leading and the tracking,
     which is where the confidence actually lives. */
  body.home-page .ehero h1 {
    line-height: 0.92;
    letter-spacing: -0.03em;
  }
  body.lab-page .esec-head h2 {
    font-size: clamp(26px, 3.6vw, 46px);
    line-height: 1.08;
    letter-spacing: -0.022em;
  }
  /* The closing statement is the one line most likely to be
     photographed and posted, so it gets to be the largest thing on the
     page after the hero. */
  body.lab-page .efoot-grid .big.serif {
    font-size: clamp(30px, 4.2vw, 58px);
    line-height: 1.04;
    letter-spacing: -0.026em;
  }

  /* ============================================================
     SECTION MARKS — lab surfaces

     home.css already alternates the section grounds: #work, #funding
     and #pledge sit on the second paper tone. A first attempt here
     added an nth-of-type alternation on top, which collided with it
     and put two adjacent sections on the same value -- worse than
     either rule alone. The tonal rhythm was already there.

     What was missing is the mark. Every section head looked identical,
     and the lab's teal appeared nowhere except in links, so a spread
     that is otherwise entirely ink on paper had no register colour at
     all. A short rule and a coloured numeral is what a printed page
     uses to say "this is where a chapter starts".
     ============================================================ */
  body.lab-page .esec-head {
    position: relative;
    padding-top: var(--space-m);
  }
  body.lab-page .esec-head::before {
    content: "";
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    /* Short, not full-bleed: a rule running the whole measure reads as
       a divider between things, a short one reads as a mark on one. */
    width: 56px;
    height: 2px;
    background: var(--accent);
  }
  body.lab-page .esec-head .lbl {
    color: var(--accent);
    font-weight: var(--w-semi);
  }

  /* ---- Off-screen work ------------------------------------------
     motion.js writes --scene-progress on a scene root every frame, and
     an untyped custom property change invalidates style for the whole
     subtree beneath it. On an eleven-thousand-pixel page that is most
     of the document, most frames: measured at 3818 recalculations
     costing 2.08s over one scroll, against 0.05s of layout.

     content-visibility lets the engine skip style and layout entirely
     for a section that is not near the viewport. The intrinsic size
     keeps the scrollbar honest while a section is skipped, so the page
     does not jump as sections enter.

     Registering the property with @property was tried first and made
     it worse -- 2.88s, because a typed property is validated on every
     write. The win is in not doing the work, not in doing it faster. */
  body.lab-page .esec,
  body.lab-page .efoot,
  body.nw-page .nw-section,
  body.jr-page .jr-section {
    content-visibility: auto;
    contain-intrinsic-size: auto 900px;
  }

  /* ---- Press --------------------------------------------------------
     A control that does not move under the finger feels dead. Small
     enough to be felt rather than watched, and fast, because the whole
     point is that it tracks the press rather than animating after it. */
  .btn, .ebtn, .jr-button, .nw-button, .mode-toggle, button {
    transition:
      background var(--motion-control) var(--ease),
      border-color var(--motion-control) var(--ease),
      color var(--motion-control) var(--ease),
      transform var(--motion-press) var(--ease-press);
  }
  .btn:active, .ebtn:active, .jr-button:active,
  .mode-toggle:active, button:active { transform: scale(0.985); }

  /* Links that carry an arrow move it, not themselves. */
  .btn [aria-hidden="true"], .ebtn [aria-hidden="true"],
  .jr-button [aria-hidden="true"] {
    display: inline-block;
    transition: transform var(--motion-advance) var(--ease);
  }
  .btn:hover [aria-hidden="true"], .ebtn:hover [aria-hidden="true"],
  .jr-button:hover [aria-hidden="true"] { transform: translateX(3px); }

  /* ---- Navigation underline ---------------------------------------
     Wipes out to the right and back in from the left, so hovering
     along a row reads as one continuous movement. */
  .nav a:not(.btn):not(.ebtn),
  .enav a:not(.btn):not(.ebtn),
  .jr-nav a:not(.jr-nav-submit) {
    position: relative;
  }
  .nav a:not(.btn):not(.ebtn)::after,
  .enav a:not(.btn):not(.ebtn)::after,
  .jr-nav a:not(.jr-nav-submit)::after {
    content: "";
    position: absolute;
    left: 0;
    bottom: -5px;
    width: 100%;
    height: 1px;
    background: currentColor;
    transform: scaleX(0);
    transform-origin: right;
    transition: transform var(--motion-control) var(--ease);
  }
  .nav a:not(.btn):not(.ebtn):hover::after,
  .enav a:not(.btn):not(.ebtn):hover::after,
  .jr-nav a:not(.jr-nav-submit):hover::after {
    transform: scaleX(1);
    transform-origin: left;
  }

  /* Type that sits on a photograph rather than on the page.

     A first version made this a constant light tone, reasoning that a
     plate is a picture in both faces and so wants its own colour
     regardless of the page. That was half right and produced
     near-white captions on a near-white photograph: the two plates are
     tonal opposites. The light face shows lit paper, the dark face
     shows its inversion, so the caption has to follow the plate --
     dark ink over the bright one, light ink over the dark one, each
     with the opposite scrim. */
  body {
    --on-plate: #17160f;
    --on-plate-scrim: 0 1px 3px rgba(255, 255, 255, .6), 0 1px 10px rgba(255, 255, 255, .45);
  }
  :root[data-mode="dark"] body,
  :root:not([data-mode="light"]) body.lab-page,
  :root:not([data-mode="light"]) body.legal-page {
    --on-plate: #f6f2e8;
    --on-plate-scrim: 0 1px 3px rgba(0, 0, 0, .55), 0 1px 12px rgba(0, 0, 0, .4);
  }

  /* ---- Atmosphere ---------------------------------------------------
     One very soft wash behind each hero. Borrowed from kloak and the
     DateAutomater stage, dialled back hard: on paper it is a warm
     breath under the type rather than a gradient anyone would name,
     and on the night surfaces it does the work a lit backdrop does in
     a photograph.

     Painted as the hero's own background rather than a blurred
     pseudo-element. A wide, gentle falloff IS the blur, so there is no
     filter to composite, nothing to bleed past the element box -- the
     first attempt used inset: -8% -12% with filter: blur(46px) and
     pushed the home page 45px wider than a phone -- and nothing that
     needs switching off for reduced motion. */
  .ehero, .hero, .vhero, .sxhero, .jr-hero, .ap-hero, .nw-article-hero {
    position: relative;
    background-image:
      radial-gradient(72% 58% at 22% 18%,
        color-mix(in srgb, var(--atmos-tint, var(--accent, transparent)) var(--atmos-a, 40%), transparent),
        transparent 68%),
      radial-gradient(58% 50% at 86% 78%,
        color-mix(in srgb, var(--atmos-tint-2, var(--signal, transparent)) var(--atmos-b, 24%), transparent),
        transparent 64%);
    background-repeat: no-repeat;
  }
  /* On paper, light is not coloured. A lamp in a dark room has a hue
     and casts one; daylight on a sheet of stock just makes the sheet
     brighter. Tinting cream with the teal accent produced a grey-green
     cast in the corner that reads as dirt rather than as light, so the
     paper faces lift toward their own lightest tone instead. */
  body { --atmos-tint: var(--card, #fff); --atmos-tint-2: var(--card, #fff); }
  /* A light effect needs somewhere dark to happen. The same wash that
     muddies cream is the light in the room on a night ground. */
  /* Gated on the FACE, not on the surface. Listing body.orrery-page
     unconditionally meant a reader who forced light still got the
     coloured lamp, which put an amber wash across Shelterix's paper
     face and a blue one across Orrery's. */
  :root:not([data-mode="light"]) body.lab-page,
  :root:not([data-mode="light"]) body.orrery-page,
  :root:not([data-mode="light"]) body.vellum-page,
  :root:not([data-mode="light"]) body.shelterix-page,
  :root:not([data-mode="light"]) body.utility-page,
  :root[data-mode="dark"] body {
    --atmos-tint: var(--accent);
    --atmos-tint-2: var(--signal, var(--accent));
    --atmos-a: 26%;
    --atmos-b: 16%;
    --shadow-a1: 0.30; --shadow-a2: 0.46; --shadow-a3: 0.58;
  }

  /* ---- Vignette -----------------------------------------------------
     Dark surfaces only. Corners a shade deeper than the centre is what
     stops a large black field reading as flat paint.

     Deliberately weak, and the weakness is measured rather than
     judged. This is a fixed overlay, so it is not an ancestor of any
     text and scripts/check-contrast.mjs cannot see it: whatever it
     costs is invisible to the audit. At opacity .5 it took the corner
     text on the dark journal face from 15.0:1 to 4.45:1 -- below AA,
     silently. At .28 against a 70% mix the same text holds 8.2:1, and
     the transparent stop is pushed out to 55% so the darkening only
     reaches the outer margin where text rarely sits. */
  body.orrery-page::before, body.vellum-page::before,
  body.shelterix-page::before, body.utility-page::before,
  :root[data-mode="dark"] body.jr-page::before,
  :root[data-mode="dark"] body.nw-page::before,
  :root[data-mode="dark"] body.vespera-page::before,
  :root[data-mode="dark"] body.lab-page::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 1999;
    pointer-events: none;
    background: radial-gradient(ellipse 124% 94% at 50% 44%,
      transparent 55%,
      color-mix(in srgb, var(--bg, var(--paper, #000)) 70%, black) 100%);
    opacity: 0.28;
  }

  /* ---- Elevation, used sparingly -------------------------------------
     The flatness here is deliberate -- hard corners, hairline rules --
     so shadow is reserved for the two cases that earn it: a
     photographic plate, which is an object sitting on the page, and a
     card under the cursor, which needs to answer. Both tiers are
     tinted with the surface ink, so they read as that surface's own
     shade rather than a grey wash.

     DateAutomater uses six shadows in five thousand lines. That is the
     right order of magnitude. */
  .ehero-plate-image, .ap-hero-plate, .vespera-board {
    box-shadow: var(--shadow-3);
  }
  .card-hover, .price-card, .jr-card, .nw-card {
    transition: box-shadow var(--motion-control) var(--ease),
                transform var(--motion-control) var(--ease);
  }
  .card-hover:hover, .price-card:hover {
    box-shadow: var(--shadow-2);
  }

  /* ---- Index rows -------------------------------------------------
     A whole-row background tint on hover is the cheapest possible
     answer and it reads that way: the row lights up as a block and
     nothing about it says which part you are pointing at. An index
     row in print is marked at its edge and its number, so that is
     what moves -- a rule grows from the leading edge, the numeral
     takes the accent, and the whole row shifts by three pixels.

     transform and colour only. The tint stays, quieter, because it is
     what carries the hit area. */
  .erow, .eprodrow {
    position: relative;
    transition:
      background var(--motion-control) var(--ease),
      transform var(--motion-control) var(--ease);
  }
  .erow::before, .eprodrow::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 2px;
    background: var(--accent);
    transform: scaleY(0);
    transform-origin: 50% 0;
    transition: transform var(--motion-advance) var(--ease);
  }
  .erow:hover, .eprodrow:hover { transform: translateX(3px); }
  .erow:hover::before, .eprodrow:hover::before { transform: scaleY(1); }
  .erow .no, .eprodrow .no {
    transition: color var(--motion-control) var(--ease);
  }
  .erow:hover .no, .eprodrow:hover .no { color: var(--ink); }

  /* ---- Inline links -----------------------------------------------
     A first version wiped the underline away on hover, borrowing the
     navigation sweep. Wrong gesture: the nav sweep has two states to
     move between, so it can leave and return, while a prose link only
     has one, and erasing its underline reads as the link becoming
     less clickable at the moment you point at it.

     Hover should intensify. The rule takes the accent and drops away
     from the baseline, which is the same thing a printer does to mark
     a reference. */
  .ethesis a, .esec p a, .efoot a:not(.ebtn), .lead a, .erow a {
    text-decoration-line: underline;
    text-decoration-thickness: 1px;
    text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
    text-underline-offset: 0.16em;
    transition:
      text-decoration-color var(--motion-control) var(--ease),
      text-underline-offset var(--motion-control) var(--ease),
      color var(--motion-control) var(--ease);
  }
  .ethesis a:hover, .esec p a:hover, .efoot a:not(.ebtn):hover,
  .lead a:hover, .erow a:hover {
    text-decoration-color: var(--accent);
    text-underline-offset: 0.24em;
    color: var(--accent);
  }

  /* ---- Affordance -------------------------------------------------
     Thirty-four controls on this site did not answer the pointer at
     all. None of them look wrong in a screenshot -- a still image
     cannot show a hover -- which is how they survived every pass.

     The rule above is the reason for most of them: it enumerates the
     containers that get the prose-link treatment, and the site has
     three footer classes (.efoot, .footer, .utility-footer) of which
     it names one. An enumeration is the wrong shape for a default.
     Everything below either widens one to a :where() default or gives
     a specific control the house idiom it was missing.

  /* Prose links, by default rather than by list. Unclassed only: an
     anchor carrying a class has its own identity and often wraps a
     whole card, where an underline would be nonsense. */
  :where(main, footer, .footer, .utility-footer, .nw-article-body, .jr-main)
    :not(h1, h2, h3, h4, h5, h6) >
    a:not([class]):not(:has(img, svg, figure, strong)) {
    text-decoration-line: underline;
    text-decoration-thickness: 1px;
    text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
    text-underline-offset: 0.16em;
    transition:
      text-decoration-color var(--motion-control) var(--ease),
      text-underline-offset var(--motion-control) var(--ease),
      color var(--motion-control) var(--ease);
  }
  :where(main, footer, .footer, .utility-footer, .nw-article-body, .jr-main)
    :not(h1, h2, h3, h4, h5, h6) >
    a:not([class]):not(:has(img, svg, figure, strong)):hover {
    text-decoration-color: var(--accent);
    text-underline-offset: 0.24em;
    color: var(--accent);
  }

  /* A headline that is a link is still a headline: it takes the accent
     on hover and never an underline. The first version of the rule
     above put one under the news lead's h1. */
  :where(h1, h2, h3, h4) > a:not([class]) {
    transition: color var(--motion-control) var(--ease);
  }
  :where(h1, h2, h3, h4) > a:not([class]):hover { color: var(--accent); }

  /* Wordmarks. Every one of them is a lockup of two weights -- a quiet
     half and a strong half -- so the hover is that the quiet half
     comes up to meet the loud one. A logo does not want a loud
     answer, and a plain opacity fade is not an idea. */
     News and journal carry their own complete palettes (--nw-*,
     --jr-*) because tokens.css never defines --ink or --accent for
     nw-page or jr-page -- they are not in any of its surface lists.
     So their affordances live in their own stylesheets, next to the
     colours they can actually name. Writing them here bought a rule
     that referenced an undefined variable and therefore did nothing,
     which is worse than no rule at all: it looks handled. */
  .ebrand { transition: color var(--motion-control) var(--ease); }
  .ebrand .motto { transition: color var(--motion-control) var(--ease); }
  .ebrand:hover .motto { color: var(--ink); }

  /* Block links: an anchor wrapping a title and a deck. The house
     idiom for a row is the mark takes the accent and the whole thing
     steps right, so a card that is really a row uses it too. */
  .home-news-grid > a, a.home-news-read, .research-map-frontier {
    transition: color var(--motion-control) var(--ease),
                transform var(--motion-control) var(--ease);
  }
  .home-news-grid > a strong {
    transition: color var(--motion-control) var(--ease);
  }
  .home-news-grid > a:hover strong { color: var(--accent); }
  a.home-news-read:hover,
  .research-map-frontier:hover { color: var(--accent); }
  a.home-news-read:hover { transform: translateX(3px); }

  /* Arrow glyphs already shift on buttons; give the text links that
     carry one the same manners. The external mark goes up and out,
     because that is the direction it points. */
  a.mono [aria-hidden="true"],
  a.home-news-read [aria-hidden="true"] {
    display: inline-block;
    transition: transform var(--motion-control) var(--ease);
  }
  a.mono:hover [aria-hidden="true"] { transform: translateX(3px); }

  /* The disclosure is a control and was the only one on the page that
     did not say so. Its + mark is already there; light it. */
  details.faq > summary {
    transition: color var(--motion-control) var(--ease);
  }
  details.faq > summary::after {
    transition: transform var(--motion-advance) var(--ease),
                color var(--motion-control) var(--ease);
  }
  details.faq > summary:hover,
  details.faq > summary:hover::after { color: var(--accent); }

  /* ---- Editorial rule ---------------------------------------------
     The deck / pull-quote treatment, opt-in via class. */
  .deck,
  .pullquote {
    border-left: 1px solid var(--signal, var(--accent, currentColor));
    padding-left: var(--space-m);
  }

  /* ---- Emphasis ----------------------------------------------------
     A serif italic inside a display line, in the theme's own signal.
     Newsreader italic is already loaded on every page that has a
     display face, so this costs nothing to download. */
  h1 em, h2 em, .display em, .serif em {
    font-family: var(--serif, Georgia, serif);
    font-style: italic;
    font-weight: var(--w-regular);
    color: var(--signal, var(--accent, inherit));
    letter-spacing: var(--ls-tight);
  }
}

@layer utilities {
/* ---- Glass chrome --------------------------------------------------
   The nav was opaque, which makes it a bar the page slides under.
   A blurred, saturated pane makes it a surface the page passes
   behind -- the single change that most makes a fixed header feel
   considered rather than stuck on. Behind @supports, and dropped
   entirely on the low-power tier and for reduced motion. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  html:not(.no-heavy-motion) .nav,
  html:not(.no-heavy-motion) .ebar,
  html:not(.no-heavy-motion) .jr-header {
    background: color-mix(in srgb, var(--bg, var(--paper, var(--jr-paper))) 78%, transparent);
    backdrop-filter: blur(16px) saturate(125%);
    -webkit-backdrop-filter: blur(16px) saturate(125%);
  }
  html:not(.no-heavy-motion) .nav.scrolled,
  html:not(.no-heavy-motion) .ebar.scrolled {
    background: color-mix(in srgb, var(--bg, var(--paper)) 88%, transparent);
  }
}
}

/* Motion off means motion off: no grain animation, no blur to resolve,
   no stagger. The blur in particular is expensive to composite and is
   exactly the kind of effect that provokes symptoms. */
@media (prefers-reduced-motion: reduce) {
  @layer polish {
    .reveal, .rv {
      opacity: 1;
      transform: none;
      filter: none;
      transition: none;
    }
    .reveal.d1, .rv.d1, .reveal.d2, .rv.d2,
    .reveal.d3, .rv.d3, .reveal.d4, .rv.d4 { transition-delay: 0s; }
    .nav a::after, .enav a::after, .jr-nav a::after { transition: none; }
  }
}

/* ------------------------------------------------------------
   SURFACE MODE

   Each surface declares how the shared craft layer should sit on it.
   The dark-ground products invert the grain blend; everything else
   inherits the paper defaults from :root.
   ------------------------------------------------------------ */
@layer tokens {
  :root:not([data-mode="light"]) body.orrery-page,
  :root:not([data-mode="light"]) body.utility-page,
  :root:not([data-mode="light"]) body.auth-page,
  :root:not([data-mode="light"]) body.vellum-page,
  :root:not([data-mode="light"]) body.shelterix-page,
  body.genesis-fall-page {
    --grain-opacity: 0.055;
    --grain-blend: soft-light;
    color-scheme: dark;
  }

  body.lab-page,
  body.jr-page,
  body.nw-page,
  body.vespera-page {
    color-scheme: light;
  }
}

/* ============================================================
   MODE — a light and a dark face for every surface.

   Each surface has a NATIVE mode: the lab, journal, news and Vespera
   pages are paper; Orrery, Vellum and Shelterix are night. That
   identity is the default and stays in each surface's own stylesheet.
   This layer supplies the counterpart.

   Selection has three states:
     - no data-mode      the surface's native identity, except that a
                         reader whose OS asks for dark gets the dark
                         face of the paper surfaces. A dark-native
                         product stays dark for a light-OS reader,
                         because that is the brand, not a preference.
     - data-mode=light   force the light face everywhere
     - data-mode=dark    force the dark face everywhere

   Only custom properties are set here, so nothing about layout or
   spacing depends on the mode.
   ============================================================ */

/* ---- Paper surfaces, dark face ------------------------------- */
@layer mode {
  :root[data-mode="dark"] body.lab-page,
  :root[data-mode="dark"] body.legal-page,
  :root[data-mode="dark"] body.error-page {
    --paper: #14130f; --paper-2: #1a1813; --card: #1f1d17;
    --ink: #ece7db;
    --rule: rgba(236, 231, 219, 0.15); --rule-2: rgba(236, 231, 219, 0.08);
    --accent: #5cbcaa; --accent-2: #e0956b;
  }
  :root[data-mode="dark"] body.jr-page {
    --jr-paper: #12151c; --jr-paper-deep: #0e1116; --jr-paper-pale: #191d26;
    --jr-ink: #e6e9ef;
    --jr-rule: rgba(230, 233, 239, 0.17); --jr-rule-light: rgba(230, 233, 239, 0.09);
    --jr-cobalt: #86a4ff; --jr-cobalt-dark: #aec0ff;
    --jr-coral: #ec8f80; --jr-lime: #b8ce66; --jr-violet: #ab92dc;
    --jr-focus: #ec8f80;
  }
  :root[data-mode="dark"] body.nw-page {
    /* --nw-plate deliberately stays dark here: it is the ground of an
       illustration whose contents are hardcoded light. Nudged off the
       light-face value so the plate still separates from a dark page
       instead of merging with it. */
    --nw-plate: #0b0f1c;
    --nw-paper: #12141d; --nw-paper-pale: #191c26; --nw-paper-deep: #0e1017;
    --nw-ink: #e8eaf2;
    --nw-rule: #3d4049; --nw-rule-light: #2b2e36;
    --nw-cobalt: #7c9cf5; --nw-cobalt-dark: #aabeff;
    --nw-coral: #ec7264; --nw-coral-bright: #ff8f7e;
    --nw-violet: #ab92dc; --nw-lime: #c8d963; --nw-white: #12141d;
  }
  :root[data-mode="dark"] body.vespera-page {
    --void: #070a0f; --bg: #10141b; --bg-2: #161b24;
    --panel: #1a2029; --panel-2: #202733; --card: #161b24;
    --line: rgba(232, 236, 242, 0.16); --line-strong: rgba(232, 236, 242, 0.34);
    --rule: rgba(232, 236, 242, 0.15); --rule-2: rgba(232, 236, 242, 0.08);
    --ink: #e8ecf2;
    --signal: #7d94ff; --signal-2: #ff8a7d; --signal-3: #d3ec7e; --signal-4: #a68cf0;
    --accent: #7d94ff; --accent-2: #ff8a7d;
    --accent-soft: rgba(125, 148, 255, 0.26);
  }
  /* ---- Night surfaces, light face ---------------------------- */
  :root[data-mode="light"] body.orrery-page,
  :root[data-mode="light"] body.utility-page,
  :root[data-mode="light"] body.auth-page {
    --void: #e2e7ec; --bg: #f4f6f8; --bg-2: #eaeef2;
    --panel: #ffffff; --panel-2: #f0f3f6;
    --line: rgba(16, 20, 26, 0.13); --line-strong: rgba(16, 20, 26, 0.26);
    --ink: #10141a;
    --violet: #2c6fa8; --violet-lt: #1f5480; --violet-dk: #143a5c;
    --teal: #0e8e7e; --teal-lt: #0a7062;
    --brass: #96661a; --brass-lt: #7b5414;
    --signal: #2c6fa8;
    --green: #0f8a52; --red: #c22f3d; --amber: #a3670a;
  }
  :root[data-mode="light"] body.vellum-page {
    --bg: #f7f4ee; --bg-2: #efebe2; --panel: #fffdf8; --panel-2: #f3efe6;
    --line: rgba(23, 21, 15, 0.13); --line-strong: rgba(23, 21, 15, 0.26);
    --ink: #17150f;
    --signal: #8a5f0f; --brass: #8a5f0f; --brass-lt: #6f4c0c;
    --red: #dc0014; --green: #0f8a52; --amber: #a3670a;
    --wf-edge: 138, 95, 15; --wf-hot: 23, 21, 15;
    --wf-node: 138, 95, 15; --wf-grid: 120, 112, 96;
  }
  :root[data-mode="light"] body.shelterix-page {
    --void: #efe9df; --bg: #faf7f2; --bg-2: #f2ede4;
    --panel: #ffffff; --panel-2: #f6f1e8; --card: #fffdf9;
    --line: rgba(20, 17, 12, 0.13); --line-strong: rgba(20, 17, 12, 0.22);
    --rule: rgba(20, 17, 12, 0.13); --rule-2: rgba(20, 17, 12, 0.07);
    --ink: #14110c;
    --signal: #885a12; --signal-2: #71490f;
    --accent: #a8490d; --accent-2: #833807;
    --accent-soft: rgba(154, 102, 20, 0.26);
  }
  /* The lab surfaces are night-native now.

     They were paper-by-default because the hero photography is lit
     paper and glowed on a dark ground. That reason is gone: the plates
     have purpose-made dark twins, so the pictures belong on this
     ground rather than fighting it.

     The deeper reason for the flip is that every surface device on
     this site -- the grain, the atmosphere, the elevation, the glow --
     is a light effect, and a light effect needs somewhere dark to
     happen. On cream at 3% opacity none of them read at all. The same
     values on a night ground are the whole texture of the page.

     Paper remains one click away and the plates swap back with it.

     The journal, news and Vespera surfaces still follow the reader's
     system preference: they are typographic, and the choice there is
     about reading comfort rather than about identity. */
  :root:not([data-mode="light"]) body.lab-page,
  :root:not([data-mode="light"]) body.legal-page,
  :root:not([data-mode="light"]) body.error-page {
    --paper: #14130f; --paper-2: #1a1813; --card: #1f1d17;
    --ink: #ece7db;
    --rule: rgba(236, 231, 219, 0.15); --rule-2: rgba(236, 231, 219, 0.08);
    --accent: #5cbcaa; --accent-2: #e0956b;
    --graph-blue: #6381d9;
    --grain-opacity: 0.055; --grain-blend: soft-light;
    color-scheme: dark;
  }
  :root:not([data-mode="light"]) body.lab-page img:not([src$=".svg"]):not([data-src-dark]),
  :root:not([data-mode="light"]) body.legal-page img:not([src$=".svg"]) {
    filter: brightness(0.82) contrast(1.04);
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-mode="light"]) body.jr-page {
      --jr-paper: #12151c; --jr-paper-deep: #0e1116; --jr-paper-pale: #191d26;
      --jr-ink: #e6e9ef;
      --jr-rule: rgba(230, 233, 239, 0.17); --jr-rule-light: rgba(230, 233, 239, 0.09);
      --jr-cobalt: #86a4ff; --jr-cobalt-dark: #aec0ff;
      --jr-coral: #ec8f80; --jr-lime: #b8ce66; --jr-violet: #ab92dc;
      --jr-focus: #ec8f80;
    }
    :root:not([data-mode="light"]) body.nw-page {
      --nw-paper: #12141d; --nw-paper-pale: #191c26; --nw-paper-deep: #0e1017;
      --nw-ink: #e8eaf2;
      --nw-rule: #3d4049; --nw-rule-light: #2b2e36;
      --nw-cobalt: #7c9cf5; --nw-cobalt-dark: #aabeff;
      --nw-coral: #ec7264; --nw-coral-bright: #ff8f7e;
      --nw-violet: #ab92dc; --nw-lime: #c8d963; --nw-white: #12141d;
    }
    :root:not([data-mode="light"]) body.vespera-page {
      --void: #070a0f; --bg: #10141b; --bg-2: #161b24;
      --panel: #1a2029; --panel-2: #202733; --card: #161b24;
      --line: rgba(232, 236, 242, 0.16); --line-strong: rgba(232, 236, 242, 0.34);
      --rule: rgba(232, 236, 242, 0.15); --rule-2: rgba(232, 236, 242, 0.08);
      --ink: #e8ecf2;
      --signal: #7d94ff; --signal-2: #ff8a7d; --signal-3: #d3ec7e; --signal-4: #a68cf0;
      --accent: #7d94ff; --accent-2: #ff8a7d;
      --accent-soft: rgba(125, 148, 255, 0.26);
    }
  }
  /* ---- Aliases, re-resolved -------------------------------------
     vespera.css defines --vespera-signal: var(--signal) on :root, so
     it captured the :root value. A counterpart palette declared on
     body then changes --signal without the alias noticing, and every
     element reading the alias keeps the old hue.

     Re-declaring the aliases on body makes them resolve against the
     value body actually has. Same reason polish.css's --teal family
     is repeated here. */
  body {
    --vespera-signal: var(--signal);
    --focus-ring: var(--accent, var(--signal, currentColor));
  }
  :root[data-mode="light"] body.orrery-page,
  :root[data-mode="light"] body.utility-page,
  :root[data-mode="light"] body.auth-page,
  :root[data-mode="light"] body.vellum-page {
    --teal: var(--signal); --teal-lt: var(--signal);
    --violet: var(--signal); --violet-lt: var(--signal);
    --brass: var(--signal); --brass-lt: var(--signal);
  }

  /* home.css draws its research map in a fixed blue tuned for paper. */
  :root[data-mode="dark"] body.lab-page { --graph-blue: #6381d9; }

  /* Grain and colour-scheme follow the face, not the surface. */
  :root[data-mode="dark"] body.lab-page,
  :root[data-mode="dark"] body.legal-page,
  :root[data-mode="dark"] body.error-page,
  :root[data-mode="dark"] body.jr-page,
  :root[data-mode="dark"] body.nw-page,
  :root[data-mode="dark"] body.vespera-page {
    --grain-opacity: 0.038; --grain-blend: soft-light; color-scheme: dark;
  }
  :root[data-mode="light"] body.orrery-page,
  :root[data-mode="light"] body.utility-page,
  :root[data-mode="light"] body.auth-page,
  :root[data-mode="light"] body.vellum-page,
  :root[data-mode="light"] body.shelterix-page {
    --grain-opacity: 0.028; --grain-blend: multiply; color-scheme: light;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-mode="light"]) body.jr-page,
    :root:not([data-mode="light"]) body.nw-page,
    :root:not([data-mode="light"]) body.vespera-page {
      --grain-opacity: 0.038; --grain-blend: soft-light; color-scheme: dark;
    }
  }

  /* Photography on this site is lit paper. On a dark ground an
     untouched paper photograph glows like a lightbox, so pull it back
     to sit in the page rather than on top of it.

     Plates that carry a purpose-made dark twin are excluded: those are
     already toned for this ground, and grading them again would take
     them down twice. */
  :root[data-mode="dark"] body.lab-page img:not([src$=".svg"]):not([data-src-dark]),
  :root[data-mode="dark"] body.nw-page img:not([src$=".svg"]),
  :root[data-mode="dark"] body.jr-page img:not([src$=".svg"]) {
    filter: brightness(0.82) contrast(1.04);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-mode="light"]) body.nw-page img:not([src$=".svg"]),
    :root:not([data-mode="light"]) body.jr-page img:not([src$=".svg"]) {
      filter: brightness(0.82) contrast(1.04);
    }
  }
}

/* ---- Mode control -------------------------------------------------
   Injected by mode.js into whichever nav wrapper the page happens to
   use. The old control was only a diamond: accessible to a screen
   reader, but visually indistinguishable from an ornamental icon.
   Name the current look on full navigation bars and collapse back to
   the mark where the mobile menu needs the room. */
@layer polish {
  .mode-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: auto;
    min-width: 82px;
    height: 38px;
    gap: 8px;
    margin-inline-start: clamp(8px, 1.2vw, 16px);
    padding: 0 10px 0 9px;
    flex: none;
    background: transparent;
    border: 1px solid var(--line, var(--rule, currentColor));
    border-radius: var(--radius-control);
    color: var(--ink-2, inherit);
    font-size: 13px;
    line-height: 1;
    cursor: pointer;
    transition:
      color var(--motion-control) var(--ease),
      border-color var(--motion-control) var(--ease),
      background var(--motion-control) var(--ease);
  }
  .mode-toggle:hover {
    color: var(--signal, var(--accent, inherit));
    border-color: var(--signal, var(--accent, currentColor));
    background: var(--accent-soft, transparent);
  }
  .mode-toggle-mark {
    display: grid;
    place-items: center;
    width: 14px;
    height: 14px;
    transition: transform var(--motion-advance) var(--ease);
  }
  .mode-toggle:hover .mode-toggle-mark { transform: rotate(-18deg); }
  .mode-toggle-copy {
    display: grid;
    gap: 2px;
    min-width: 32px;
    color: inherit;
    font-family: var(--mono, ui-monospace, monospace);
    line-height: 1;
    text-align: left;
  }
  .mode-toggle-copy small,
  .mode-toggle-copy b {
    display: block;
    font: inherit;
    text-transform: uppercase;
  }
  .mode-toggle-copy small {
    color: var(--faint, currentColor);
    font-size: 6.5px;
    letter-spacing: .14em;
  }
  .mode-toggle-copy b {
    font-size: 8.5px;
    font-weight: 500;
    letter-spacing: .08em;
  }

  /* The journal and news headers are grids with a fixed column count,
     so the button has to be told where to sit rather than appended. */
  /* Several navs order their flex children explicitly. An appended
     child with no order sorts to the front of every ordered sibling,
     which put the control at the far left of the Shelterix bar --
     last in the DOM, first on screen. */
  .mode-toggle { order: 99; }
  .jr-header-inner .mode-toggle { align-self: center; }
  /* The news masthead is a three-column grid with the menu button in
     the last cell, so the control takes its own place rather than
     stacking under the tagline. */
  .nw-masthead-row .mode-toggle { align-self: start; justify-self: end; }
}

@media (max-width: 900px) {
  @layer polish {
    .mode-toggle {
      width: 34px;
      min-width: 34px;
      height: 34px;
      margin-inline-start: 8px;
      padding: 0;
    }
    .mode-toggle-copy { display: none; }
  }
}

@media (max-width: 640px) {
  @layer polish {
    /* Supporting pages repeat their contact action in the body. Keeping
       that full-width action beside Menu and Look pushed the header to
       513px on a 375px screen. */
    body.supporting-page .nav-inner > .btn-ghost { display: none; }
  }
}

@media (prefers-reduced-motion: reduce) {
  @layer polish {
    .mode-toggle, .mode-toggle-mark { transition: none; }
    .mode-toggle:hover .mode-toggle-mark { transform: none; }
  }
}

/* ============================================================
   QUIET TONES, DERIVED

   --muted and --faint were hand-picked per surface, which meant
   fourteen separate judgement calls about how quiet is quiet enough.
   Several landed just under WCAG AA (4.24, 4.33, 4.47 against 4.5),
   and adding a counterpart face to each surface would have doubled
   the number of those judgements.

   Instead, mix the surface's own ink into the surface's own ground.
   Hue is preserved because both operands belong to the theme, and the
   contrast ratio is a property of the mix rather than of a guess.
   Measured across all fourteen surface/mode pairs against the page
   ground, 70% ink clears 5.4:1 at worst. The extra margin over the
   4.5 threshold pays for panels, which sit a step off the ground and
   cost roughly half a point.

   This sits in @layer mode so it applies in both faces, and so each
   surface's stylesheet can still state its own ink and ground.
   ============================================================ */
@layer mode {
  /* Any element that restates its own ink and ground re-derives these,
     so a fixed-tone surface (a render viewport, a photographic plate)
     keeps legible labels whichever face the page is wearing. */
  body,
  [data-tone],
  .proof-stage, .review-stage, .engine-view {
    --ink-2: color-mix(in srgb, var(--ink) 88%, var(--bg, var(--paper, #fff)));
    --muted: color-mix(in srgb, var(--ink) 78%, var(--bg, var(--paper, #fff)));
    --faint: color-mix(in srgb, var(--ink) 70%, var(--bg, var(--paper, #fff)));
  }

  /* The journal and news pages namespace their palettes, so they need
     the same treatment spelled with their own token names. */
  body.jr-page {
    --jr-ink-soft: color-mix(in srgb, var(--jr-ink) 88%, var(--jr-paper));
    --jr-muted:    color-mix(in srgb, var(--jr-ink) 78%, var(--jr-paper));
    --jr-faint:    color-mix(in srgb, var(--jr-ink) 70%, var(--jr-paper));
  }
  body.nw-page {
    --nw-ink-soft: color-mix(in srgb, var(--nw-ink) 88%, var(--nw-paper));
    --nw-muted:    color-mix(in srgb, var(--nw-ink) 78%, var(--nw-paper));
    --nw-faint:    color-mix(in srgb, var(--nw-ink) 70%, var(--nw-paper));
  }
}

/* genesis-fall.html is a single art-directed teaser with its own
   --qe-* palette and no counterpart; it stays night in both modes. */

/* ============================================================
   REDUCED MOTION — the one thing here that is deliberately unlayered.

   Every cascade layer loses to unlayered CSS, which is exactly what a
   kill switch wants: nothing in a product stylesheet, a page
   stylesheet or an inline block can put motion back. polish.css
   carries the same block, but seven pages -- the journal and news
   surfaces -- load no shared stylesheet at all, and tokens.css is the
   only file all 25 have in common.

   The durations are 0.001ms rather than 0, so transitionend and
   animationend still fire and any script waiting on them still runs.
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-delay: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    transition-delay: 0.001ms !important;
    scroll-behavior: auto !important;
  }
  html { scroll-behavior: auto !important; }
  /* Content that only appears through an entrance must not stay hidden
     when the entrance is switched off. */
  .reveal, .rv, [data-motion="reveal"] {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
  }
  /* The grain is static, so it stays. The blur behind the chrome and
     the atmosphere plates are both expensive to composite and are the
     kind of effect that provokes symptoms, so both go. */
  .nav, .ebar, .jr-header {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
}
