/* ══════════════════════════════════════════════════════════════════════════
   site.css — every page loads this.

   Tokens, the reset, the shared layout scaffolding, the nav and footer, the
   glass surfaces, the backdrop, and the editor. A page's own stylesheet loads
   AFTER this one and carries only what is that page's alone.

   Split out of style.css when the site grew a second page. Nothing here was
   edited in the move — rules and their rationale travelled together, and a
   one-off test proved the two halves partitioned the original exactly before
   it was retired with the commit it existed to make reviewable.

   One ordering question is worth knowing about. `.pane` and `.sheet` sat late
   in style.css — lines 978 and 1006 of 1084, with the whole backdrop layer
   still after them — and the comments around them discuss ordering, because
   an earlier sweep of that kind was placed near the top and silently lost to
   a component rule two hundred lines below it. They are now in the file that
   loads FIRST, which inverts that.

   It inverts nothing load-bearing, though: the dependency had already been
   designed out rather than relied upon. `.pane` was made a class the markup
   applies (see NAMING below) precisely so the component rules beside it carry
   only layout and there is nothing left to out-order. What the split could
   have done is reintroduce such a dependency, so it was measured rather than
   assumed: no rule in home.css sets a property `.pane` or `.sheet` sets, on
   any element that wears them, and rendering the page under the old sheet and
   the new pair gave identical computed styles at every viewport tried. The two
   files do not interact at all. DESIGN.md, under "The build", has the numbers.
   ══════════════════════════════════════════════════════════════════════════ */

/* ════════════════════════════════════════════════════════════════════════════
   NAMING

   Custom properties are `--kind-name`, and the kind is always first, so the
   prefix tells you what a value IS before you have read what it is for:

     --color-*     every colour, including the ink ladder and the syntax set
     --radius-*    corner radii
     --space-*     gaps and section rhythm
     --font-*      families and sizes;  --letter-spacing-*  tracking
     --z-*         the five stacking tiers, listed once so they stay ordered
     --ease-* --duration-*   motion
     --field-* --glow-* --pointer-*   the backdrop's own geometry
     --card-*      per-instance values the markup sets inline

   Classes are `role-block-element`, with three prefixes carrying the role:

     .l-*      layout scaffolding that owns no look   .l-wrap .l-shell .l-grid
     .s-*      a page section                         .s-hero .s-range .s-core
     .bg-*     a fixed backdrop layer                 .bg-field .bg-glows
     (none)    a component                            .pane .label .pager
     *--mod    a variant of the component before it   .cta-button--solid
     .is-*     a state some script toggles            .is-on .is-tracking

   `.pane` is the shared glass surface and nothing else; the component classes
   beside it (`.example-card`, `.install-line`, `.showcase-box`)
   carry only that instance's layout. That split is why the sweep at the bottom
   of this file no longer has to out-order five earlier rules to win.

   ───────────────────────────────────────────────────────────────────────
   COLOUR

   Three colours and a greyscale.

     brand   hsl(350 …)   IDENTITY — the mark's letters, the drifting glows,
                          the primary button, the active pill. Nothing else.
     accent  hsl(250 …)   SECONDARY — the mark's two dots, the pointer
                          bloom, the field's three heavy lines, every hover,
                          run. Two shades: the base separates from the page,
                          -soft from the brand.
     ink     0% S         EVERYTHING ELSE — pure black or pure white, and
                          three steps down from it.
     syntax  5 hues       the grammar, and only the grammar.

   The greyscale is four steps and a wash, named for the job rather than for
   the alpha:

     --color-ink          headings, nav, code. The full-strength one.
     --color-text         body copy and every lede.
     --color-text-soft    captions, footnotes, glyphs, small labels.
     --color-text-faint   marks that are barely there — a dot at rest, a
                          placeholder's label.
     --color-wash         not text at all: a surface tint, for code chips,
                          hover backgrounds and the one divider.

   It was ten rungs named `--color-ink-78` down to `--color-ink-05`, with
   `--color-muted`, `--color-sec` and `--color-sec-soft` sitting between them.
   Six of the ten were used exactly once. Naming a colour for its alpha says
   what it IS and never what it is FOR, so each new element picked a number
   that looked about right and the ladder grew another rung.

   **The cool slate is gone.** `--color-sec` was hsl(210 9% 42%), and it
   existed so the page would not read as one red chord back when the neutrals
   were biased toward the brand. They are achromatic now, small labels were the
   only thing ever set in it, and at 9% saturation it sat within a couple of
   percent of `--color-text-soft`. Two names for one grey is the whole reason
   for this pass.
   ════════════════════════════════════════════════════════════════════════ */
:root{
  /* Both schemes, and the browser picks. `color-scheme` is what makes
     `light-dark()` resolve at all, and it also hands form controls, scrollbars
     and the canvas's own backdrop to the right palette without a rule each.

     Every colour below is one `light-dark()` pair, so a value and its dark
     counterpart are never in different parts of the file. Nothing outside this
     block names a colour. */
  color-scheme:light dark;

  /* Neutrals. --color-ground is flat and achromatic in both directions: 93.5%
     down, 7% up, with the hue carried for form only. Dark stops short of black
     because a pane has to sit lighter than the page and a glow needs something
     to show up on — but only just short. It was 9%, and at 9% the ground and
     the inner sheets were a single point of lightness apart, which is no
     separation at all; the glass was doing all of it on its own.

     The order, dark: ground 7, sheets 8, glass ~17 once its white alpha is
     composited over the ground. A sheet inside a pane reads recessed, which is
     the same relationship the light scheme has. */
  --color-ground:light-dark(hsl(350 0% 93.5%), hsl(350 0% 7%));
  --color-paper:light-dark(hsl(350 0% 100%), hsl(240 5% 8%));

  /* The greyscale. Dark runs a shade stronger at every step: white on black
     carries less apparent weight than black on white, so matching the alphas
     exactly would leave the dark page looking washed out.

     --color-text-soft is .56 rather than the .48 it started at: at .48 it
     measured 3.63:1 against the ground, under the 4.5 bar for the 14.5px
     captions and footnotes that are its main job. .56 measures 4.6. */
  --color-ink:light-dark(hsl(350 0% 0%), hsl(350 0% 100%));
  --color-text:light-dark(hsl(350 0% 0% / .70), hsl(350 0% 100% / .72));
  --color-text-soft:light-dark(hsl(350 0% 0% / .56), hsl(350 0% 100% / .60));
  --color-text-faint:light-dark(hsl(350 0% 0% / .24), hsl(350 0% 100% / .28));
  --color-wash:light-dark(hsl(350 0% 0% / .06), hsl(350 0% 100% / .08));

  --color-brand:light-dark(hsl(350 77% 62%), hsl(350 85% 66%));
  --color-brand-hover:light-dark(hsl(348 71% 57%), hsl(348 88% 72%));
  --color-brand-wash:light-dark(hsl(346 100% 97%), hsl(346 32% 22%));
  --color-brand-deep:light-dark(hsl(346 68% 45%), hsl(350 92% 76%));
  --color-brand-tint:light-dark(hsl(350 30% 50% / .15), hsl(350 45% 45% / .30));
  /* What sits ON the brand: the solid button, the active pill, a pressed
     toggle. White over light-mode watermelon is 3.55:1 and already an open
     call; over the lighter dark-mode brand it would be worse, so dark ink
     takes over there. */
  --color-on-brand:light-dark(hsl(350 0% 100%), hsl(350 0% 10%));

  /* ── The accent ────────────────────────────────────────────────────────
     Violet, and the reason it works is not the reason the green did. The
     green was watermelon's rind — a near-complement, 175 degrees away, which
     buys maximum separation and spends it on a hue that fights the brand for
     the same warm ground. Violet is 100 degrees away and SHARES the brand's
     red channel, so the two sit in one family: pink and purple read as a
     palette where pink and green read as two decisions.

     Seeded oklch(.695 .141 290) against the brand's oklch(.650 .185 14.1):
     .045 lighter and three quarters of the chroma. Closer in weight than the
     green ever was, which is affordable precisely because the hues no longer
     compete — separation is coming from the hue distance, so it does not also
     have to come from the chroma.

     TWO SHADES, AND WHICH ONE YOU WANT DEPENDS ON THE GROUND UNDER IT. The
     base separates from the page, -soft from the brand: on --color-ground the
     base reads 2.45 and -soft 1.69, and on the brand red it inverts, 1.25
     against 1.81. So the wordmark's dots take the base and the app icon's
     dots take -soft. Pick by asking what the accent is sitting ON. The
     inversion is wider here than it ever was on the green, which is the same
     fact as violet sharing the brand's red channel read from the other side:
     a colour that blends with the page's ground stands out on the brand's.

     Unlike the green pair these two do not share a saturation — 74 and 95 —
     so the relationship is hue-locked only. Violet has room for that: at
     these lightnesses the extra chroma reads as the same colour with more of
     itself in it, where on the green it read as a second green.

     The rest of the ramp: the base and -soft are SURFACES and neither is
     legible as text; -deep is the tier that may carry TEXT or a link, at 4.60
     on --color-ground; -tint is the chip's plate.

     -deep IS THE THIRD PLACE THE CHROMA TRAP CAUGHT US, after the button and
     the heavy lines, and it is the same shape every time: it sat at 62% S and
     measured chroma .195 against the mark's .141 — half again as much colour —
     because it is darker than the mark, and chroma rises as lightness falls.
     41% at 55% matches it at .142 and still clears 4.5 on the ground, which is
     the constraint that sets the lightness once the chroma is fixed. The dark
     arm is left alone: at 86% lightness it is chroma .090, already under the
     mark's .129, and there is nowhere above 90% saturation to go.

     There was a -wash too, a pale ground for the quiet CTA's hover. It is
     gone: at hsl(250 80% 96%) it measured 1.15:1 against the paper it sat on,
     which is a hover you have to be told about. That button inverts instead —
     see .cta-button--quiet below — and a token with no callers is a colour
     waiting to be picked by whoever next needs one about that light.

     -solid is the ground under a button and the only accent a white word may
     sit on. It lifts in dark and its text flips to ink, which is
     --color-brand's own arrangement move for move.

     ITS SATURATION IS SET BY THE LOGO'S CHROMA, NOT BY EYE. At 66% S it read
     as a louder purple than the mark it belongs to — while sitting at a LOWER
     S than the base's 74%, which is the tell that the S digit is not a
     perceptual quantity. In OKLCH the base is chroma .141 and that button was
     .177, a quarter more colour, because chroma rises as lightness falls at a
     fixed saturation and the button is the darker of the two. Matching .142
     puts it at 52%; the hover at 44%, one step further down. The dark arm is
     66%, chroma .126 against the base's dark .129.

     WHEN TWO COLOURS SHOULD LOOK EQUALLY SATURATED, COMPARE THEIR CHROMA. The
     cost is a little contrast — white is 3.92:1 where it was 4.32 — and that
     still clears the brand pill's own 3.55, the bar this site has already
     accepted for a solid pill of an identity colour. */
  --color-accent:light-dark(hsl(250 74% 74%), hsl(250 80% 78%));
  --color-accent-soft:light-dark(hsl(250 95% 84%), hsl(250 95% 88%));
  --color-accent-deep:light-dark(hsl(250 41% 55%), hsl(250 90% 86%));
  --color-accent-tint:light-dark(hsl(250 45% 70% / .2), hsl(250 55% 55% / .2));
  --color-accent-solid:light-dark(hsl(250 52% 64%), hsl(250 66% 74%));
  --color-accent-solid-hover:light-dark(hsl(250 44% 58%), hsl(250 66% 80%));
  --color-on-accent:light-dark(hsl(350 0% 100%), hsl(350 0% 10%));

  /* The grammar, and the only saturated thing on the page besides the brand
     and the accent.
     Five hues at least 45 degrees apart so no two forms can be confused, and
     saturated enough to read AS colour — they were 26-51% S and came out as
     five shades of mud, which is not much use when the argument the section is
     making is that one form wears one colour everywhere.

     Lightness crosses over between schemes; hue and saturation hold.

     THE NAMES ARE NOW HISTORICAL, and that is worth knowing before reading
     them. They were coined for a scanner this repo owned, which knew four
     things: a capsule, a string, a brace and a bracket. The sibling's scanner
     replaced it and knows nine roles, so what these five tokens are today is
     A PALETTE OF FIVE HUES rather than a list of five forms. `-brace` no
     longer colours a brace. Renaming them is a tidy-up worth doing the next
     time this block is opened; leaving them mis-named silently is not. */
  --color-syntax-cap:light-dark(hsl(346 82% 47%), hsl(350 90% 72%));
  --color-syntax-str:light-dark(hsl(36 95% 32%), hsl(38 92% 62%));
  --color-syntax-brace:light-dark(hsl(205 90% 37%), hsl(205 90% 66%));
  --color-syntax-brack:light-dark(hsl(98 65% 30%), hsl(96 55% 58%));
  --color-syntax-class:light-dark(hsl(280 70% 52%), hsl(280 78% 76%));

  /* ── binding the five hues to the sibling's nine roles ─────────────────
     `assets/lini-tokens.css` is printed by `lini highlight --css` and ships
     the thirteen `.lini-tok-*` rules; its own colours are defaults inside
     `@layer lini.defaults`, so these unlayered redeclarations win with no
     `!important` and no ordering to maintain. The site keeps its palette and
     gains a tokenizer that actually knows the grammar.

     Nine roles onto five hues plus two neutrals, so one pair has to share.
     `var` joins `class` because they are the same kind of thing — a reference
     to something declared elsewhere, `--var` and `.class` — which is the only
     collision here that reads as deliberate rather than as running out.

     WHAT THIS COSTS: braces and brackets are one `punct` role to the sibling,
     so they can no longer wear the two different hues the grammar key
     promises. They are both the punctuation grey below. Getting them back
     means splitting `Punct` in ../lini, where it would also reach the VS Code
     and Zed grammars — a real change, not a local one. */
  --lini-tok-comment:var(--color-text-soft);
  --lini-tok-string:var(--color-syntax-str);
  --lini-tok-value:var(--color-syntax-brack);
  --lini-tok-type:var(--color-syntax-cap);
  --lini-tok-prop:var(--color-syntax-brace);
  --lini-tok-var:var(--color-syntax-class);
  --lini-tok-class:var(--color-syntax-class);
  --lini-tok-op:var(--color-ink);
  --lini-tok-punct:var(--color-text-soft);

  /* Glass. Light glass is white pulled back to two thirds; dark glass is the
     same white at about an eighth. The last stop keeps its whisper of brand in
     both, and the rim keeps being the lit edge of the sheet rather than a line
     around it.

     Dark started at .075 / .045 / .06, on the theory that a little white goes
     a long way on a dark ground. It does — but the panes then sat two or three
     points off the page and read as slightly-different dark rather than as
     glass over it. Overshot to .125 / .08 / .105 correcting that, and these
     are a quarter of the way back down: enough surface to read as one, not
     enough to look lit. A pane has to look like a surface, which is the
     opposite requirement from the glows, which were lighting the page and were
     dimmed in the same pass. */
  --color-glass-a:light-dark(hsl(350 0% 100% / .82), hsl(350 0% 100% / .11));
  --color-glass-b:light-dark(hsl(350 0% 100% / .64), hsl(350 0% 100% / .07));
  --color-glass-c:light-dark(hsl(350 60% 99% / .66), hsl(350 45% 72% / .09));
  --color-glass-rim:light-dark(hsl(350 0% 100% / .7), hsl(350 0% 100% / .15));
  /* Half what it was in the dark. The cast is meant to read as the glow
     pooling under a pane; at .34 against a 6% ground it read as the pane
     emitting one. Light is untouched — there it is 2% over a 93.5% ground and
     barely there already. */
  --color-glass-cast:light-dark(hsl(350 75% 60% / .20), hsl(350 80% 50% / .17));
  --color-sheet-cast:light-dark(hsl(350 30% 50% / .10), hsl(350 45% 45% / .30));
  /* The control cast at pill scale — the CTA's `0 10px 34px -12px` times the
     26/41 a pill is of a button, so the pool is the same fraction of each. It
     was retyped at four call sites, which is four places to forget. */
  --shadow-control-sm:0 6px 22px -8px var(--color-control-cast-cool);

  /* ── the code sheet ───────────────────────────────────────────────────────
     The book's code blocks, brought back to the site. `/docs` dresses a block
     as a near-white sheet held by a two-colour hairline that runs violet into
     watermelon — the accent into the brand, which is the site's own pair in
     the site's own order — and a reader crossing from lini.rs into the book
     should not meet two ideas of what a code block is.

     Translucent, for the book's own reason: a pane here is glass, and a fill
     that lets the ground through reads as a sheet laid on it rather than as a
     panel cut into it. It carries no hairline — the book's two-colour edge was
     tried and dropped, because round a listing that already sits on a surface
     it was one line too many.

     So the fill carries the two colours instead: violet to watermelon across
     the sheet, the site's own pair in the site's own order, and the same run
     the book spends on its border. Subtle enough that it reads as one surface
     until you look for it, which is the point — a listing is not decoration.

     THE TWO ARMS ARE NOT THE SAME MOVE. Light is nearly opaque and a shade
     under the glass, so the sheet reads as paper laid on it. Dark is a THIRD
     as opaque: the pane there is white at .11 over a 7% ground, so a fill at
     .95 was not a sheet on it but a hole cut through it — near-black against
     something two and a half times lighter. At .42 the pane shows through and
     what lands is a gentle step down, which is all a code block on glass
     should be.

     The fill is lighter than the pane it sits on, where `--color-wash` was
     darker. A wash is a dent in a surface; this is a sheet laid on one, and a
     sheet catches the light. */
  --color-code-sheet:linear-gradient(150deg in oklab,
    light-dark(hsl(250 42% 94.2% / .94), hsl(250 46% 10% / .44)) 0%,
    light-dark(hsl(338 36% 95.5% / .90), hsl(338 38% 12% / .34)) 100%);
  /* The third cast, and the one a CONTROL sits on. Same job as the pane's —
     the glow pooling under a thing that is off the ground — and it comes in two
     colours, because the controls do.

     LONGER AND FAINTER, WHICH IS THE ONLY WAY A CAST GETS SOFTER. The first
     version was `0 8px 24px -8px` at .40 light and .34 dark, and it read as a
     tinted pool stuck to the button rather than as light under it: a short blur
     at a high alpha has nowhere to fade, so it lands as a hard little skirt.
     The blur is half again as long now and the alpha is down, which is the
     trade in the direction that buys a falloff — `0 10px 34px -12px`, and the
     spread goes to -12 with it so the near edge is still cut away and the pool
     starts a few pixels out rather than under the pill's own rim. A long blur
     spends its ink over its own length, so the numbers do not scale together:
     dropping the alpha alone gets you nothing to look at, and the pair had to
     be found by photographing them, not by arithmetic.

     LESS COLOUR THAN IT HAD, MUCH LESS. It sat at 75% saturation, which is the
     glass cast's, and under a control that reads as a colour rather than as a
     hint of one — the button looked like it was standing in tinted water. 38%
     light / 42% dark is the sheet cast's neighbourhood (30%), and at that
     chroma the hue is only findable next to a neutral shadow, which is where it
     should be. Not grey, though: a neutral cast under a watermelon pill reads
     as dirt under it. The rule is a hint of the light the page is lit by, not a
     colour of its own.

     TWO HUES, BECAUSE A ROSE POOL UNDER A VIOLET PILL IS THE WRONG LIGHT. That
     is the same argument the grey one loses, one step further in: 350 belongs
     under `Quick start`, which is the brand red, and the cool controls — the
     ghost CTA, which inverts to the violet accent under a pointer, and the
     pager's active pill, which is violet at rest — take 250 instead. Same
     saturation, same lightness, same alpha, so the two casts are one decision
     with the hue as its only variable.

     Dark is NOT the light arm at half weight — the rule the glows recorded does
     not apply, because this is not a wash over a ground, it is the only thing
     separating a control from a near-black page. On white the cast darkens; on
     black the same paint LIFTS, and a pill has too little of the page under it
     to lift unless the alpha carries it. So the two arms sit close together
     (.34 and .30) rather than a factor apart, and the dark ceiling still holds:
     past about .45 a pool stops being a pool and becomes the button emitting
     one, which is the same wall the glass cast hit at .34. */
  /* Warm is darker and heavier than cool, and has to be. A cast reads by how
     far it sits below what is around it, and the brand button is a mid-dark
     red on a ground this page tints pink — so a rose pool at the cool one's
     52%/.34 lands within a few points of both and disappears, while the same
     values under the WHITE ghost button read clearly. Matched by eye at the
     buttons rather than by matching the numbers, which is the whole reason
     the two are separate tokens. */
  --color-control-cast:light-dark(hsl(350 44% 40% / .44), hsl(350 46% 44% / .38));
  --color-control-cast-cool:light-dark(hsl(250 38% 52% / .34), hsl(250 42% 50% / .30));

  /* The pointer bloom and the four drifting glows. Each carries a pool as well
     as a colour: white in the light, so the colour mixes with white instead of
     with grey; a much fainter white in the dark, where the pool's job is to
     lift the ground rather than bleach it.

     THE BLOOM IS THE ACCENT'S, AND THE HUE CHANGE IS WHAT MADE IT WORK. It
     was the brand's, then briefly green, then the brand's again: a green pool
     travelling through four pink ones read as a second effect rather than as
     the page responding to you. Violet does not, and the arithmetic says why
     rather than the taste.

     THE BLOOM NEVER LANDS ON A BARE GROUND — the four glows have already
     tinted it pink, and what a wash has to do is register against THAT. A
     green must cancel the pink before any green appears: at .16 the composite
     came out (215, 211, 211), the green channel BELOW the red, reading as the
     pink going grey, and it took .30 to look like a colour. Violet shares the
     brand's red channel, so it has nothing to cancel — at .16 the blue is
     already 19 above the green, and at .20 it clears red too. It sits at .25,
     a step past merely legible and into visible, and still under the .30 the
     green needed just to register.

     THE DARK ARM IS NOT THE LIGHT ONE AT HALF WEIGHT, and it was, and it read
     as a white light rather than a purple one. The half-weight rule above is
     real, but it was written for a wash whose colour was a mid-lightness pink.
     A violet at 78% lightness is already most of the way to white, so at .12
     over a 7% ground it adds brightness and almost no hue: measured, the blue
     channel came out 9 above the red, which is a grey lift with a rumour of
     colour in it. The dark arm carries its own hue now — 62% lightness at 90%
     saturation. DARKER AND MORE SATURATED, not lighter and fainter, because
     over black there is nothing to lighten toward except white.

     The alpha then comes down rather than up, and the half-weight rule is
     right again once the colour is: .15 against light's .25. Blue lands 22
     above red, where the washed-out version was 9 and .22 was 32 and too
     strong. THE SATURATION BUYS THE HUE AND THE ALPHA BUYS THE PRESENCE — two
     knobs, and the first attempt at fixing this turned the second one because
     the first was wrong.

     MEASURE A WASH AGAINST WHAT IS ACTUALLY UNDER IT, not against the page
     colour. That is the rule the whole episode bought.

     Everything here runs at roughly half its light-scheme weight in the dark,
     over two passes. A wash that reads as a
     hint on a 93.5% ground reads as a light source on a 5% one — the same
     alpha is a much larger step when there is almost nothing underneath it,
     and the glows were lighting the page rather than moving behind it. The
     glass is untouched: the panes still need their own weight. */
  --color-bloom-core:light-dark(hsl(250 74% 74% / .25), hsl(250 90% 62% / .15));
  --color-bloom-mid:light-dark(hsl(250 74% 74% / .11), hsl(250 90% 62% / .07));
  --color-bloom-pool:light-dark(hsl(350 0% 100% / .55), hsl(350 0% 100% / .022));
  --color-glow-pool:light-dark(hsl(350 0% 100% / .92), hsl(350 0% 100% / .020));
  --color-glow-fade:light-dark(hsl(350 77% 62% / 0), hsl(350 85% 66% / 0));
  --color-pool-fade:hsl(350 0% 100% / 0);

  /* The line field, read once by home.js. Each scheme runs the line past its
     own ground rather than back toward the middle: white over 93.5%, near-
     black under 7%. Light is about four values in 255 and invisible until a
     glow drifts under it. Dark is ten — it was seventeen at pure black, which
     drew the lines rather than let them be an absence. The point in both is
     that a line is only visible where something passes behind it. */
  --color-field-line:light-dark(hsl(350 0% 100%), hsl(350 0% 3%));
  /* The three heavy lines are the accent's. Neutralising them was once tried
     and was the wrong read of "the background has a red hue in it" — they are
     the one thing in the backdrop meant to catch your eye, there are at most
     three alive at a time (BOLD_MAX), and taking the colour out left them as
     lines that were merely fatter. Colour, not weight.

     Which is why these are LUMINANCE-MATCHED to the watermelon they were,
     not eyeballed: Y .3647 against .3659 in light, .0687 against .0689 in
     dark. A hue swap that also changes how heavy a line reads is two changes
     wearing one commit, and only one of them was ever asked for. The lightness
     has now moved four times — watermelon, green at 56.25%, violet at 70.25%,
     violet again at 76.5% — every time BECAUSE the target did not. Chroma and
     hue both carry luminance, so holding the weight makes the lightness an
     output, never an input.

     THE SATURATION IS THE LOGO'S, and it is the one number here that is not
     derived. 28% was a desaturation asked for when these were green and read
     as loud; carried across to violet it read as washed instead, because the
     same S digit is a different amount of colour at a different hue and
     lightness. 74% in light is the base's own saturation. In dark it is 42%
     rather than the base's 80%: at that luminance violet's gamut is wide, and
     80% would be chroma .263 against the mark's .129 — twice the colour. What
     transfers between the two arms is the CHROMA, .126 and .139, not the S.

     The decimals are the price of holding Y to four places, in a value no eye
     reads directly: three short lines in a backdrop. */
  --color-field-bold:light-dark(hsl(250 74% 76.5%), hsl(250 42% 40.75%));

  --font-sans:'Google Sans',system-ui,-apple-system,sans-serif;
  --font-code:'Google Sans Code',ui-monospace,SFMono-Regular,monospace;
  /* 50, not 54. Every section heading here is two or three words on one or
     two lines, and at 54 the two-line ones (`A figure your agent / can read
     back`) crowded their own lede. The cap is what moved; the 4.4vw ramp and
     the 30px floor are untouched, so nothing below ~1140px changes at all. */
  --font-size-h2:clamp(30px,4.4vw,50px);
  --font-size-lede:clamp(15px,1.5vw,18px);
  --font-size-label:10.5px;
  --letter-spacing-h2:-.038em;
  --letter-spacing-label:.14em;

  /* One radius for every top-level pane. A pane *inside* one is tighter:
     matching the parent at 24px inside ~15px of padding made the inner corner
     look wider than the outer. Pills and inline code chips are not panes and
     keep their own. */
  --radius-pane:24px;
  --radius-pane-in:18px;
  --radius-pill:100px;
  --radius-chip:5px;
  --radius-focus:6px;

  /* Every card-to-card gap on the page is one number. It was 14px in six
     places and 16px in one, and the odd one out was the wall. */
  --space-gap:16px;
  /* The air BETWEEN two sections. One number, not a bottom padding plus the
     next section's top padding — see the note on `body`. It is a little under
     the 270 that pair used to add up to, because the panes inside the sections
     have tightened since it was set. */
  --space-section:clamp(110px,16vw,230px);
  /* The air between a section's lede and the thing it introduces — a pane, or
     the wall of cards. It was three different values on three panes that sit
     one under another, which is three chances to notice and no reason to. */
  --space-section-content:32px;
  --space-wrap:clamp(16px,4vw,44px);

  /* The four tiers, in order, so that adding a fifth is a decision rather than
     an accident. --z-field is above BOTH backdrops: the field used to clear
     the glows by DOM order alone, so the bloom painted a pink wash *over* the
     white lines and dimmed them exactly where the pointer was.

     --z-backdrop is 0 rather than a negative, so a backdrop layer does not sit
     under the shell's own painting — which means content has to opt in to clear
     it. A positioned element at z-index 0 paints after every in-flow,
     non-positioned box in the same stacking context, whatever the source order,
     so a section that takes no tier is a section the glows paint over. Every
     one of them takes `--z-field` or `--z-content` for that reason, in the
     sweep below and in gallery.css, home.css and play.css. A new page's
     top-level section wants one too, and /play is the page that proved it: it
     had none, and the glows washed over the editor and the figure. */
  --z-backdrop:0;   /* .bg-glows, .bg-bloom  */
  --z-field:1;      /* #bg-field, and every section's content */
  --z-content:2;    /* the hero and the showcase */
  --z-nav:3;

  --ease-out:cubic-bezier(.5,0,.2,1);
  --duration-fast:.18s;
  --duration-hover:.16s;
}

*{box-sizing:border-box}
html{scroll-behavior:smooth}
/* A column with ONE gap between sections, rather than a bottom padding on each
   and a top padding on the next that a reader has to add up. Measured before
   the change, the landing page's six seams ran 330, 270, 198, 208, 270 and
   220 — six numbers, none of them chosen, all of them the sum of two others.

   Flex rather than block margins because a gap cannot collapse and cannot be
   half-supplied: a section that forgets its padding is now impossible rather
   than merely unlikely. `/play` has a single body child so this is inert
   there; `/gallery` gets the same rhythm for free. */
body{margin:0;display:flex;flex-direction:column;gap:var(--space-section);
  background:var(--color-ground);color:var(--color-ink);
  font-family:var(--font-sans);font-weight:400;line-height:1.55;
  -webkit-font-smoothing:antialiased}
h1,h2,h3{margin:0;text-wrap:balance}
ul{margin:0;padding:0;list-style:none}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;border:0;background:none;cursor:pointer}
:focus-visible{outline:2px solid var(--color-brand);outline-offset:3px;
  border-radius:var(--radius-focus)}

/* ── scrollbars ──────────────────────────────────────────────────────────────
   Every scrollbar inside the shell is thin and quiet; the page's own is not.
   That split is the whole policy, and scoping to `.l-shell` is what encodes
   it — the root scrollbar belongs to <html>, outside the shell, so the
   browser's full-size bar survives untouched while every pane, editor, code
   block and dialog inside gets the compact one. The universal selector is
   deliberate: a scroll container this site adds next year should not have to
   know a scrollbar rule exists.

   Standard properties only, no ::-webkit-scrollbar: every engine this site's
   `light-dark()` already requires understands scrollbar-width and
   scrollbar-color, and the moment scrollbar-color is set Chrome ignores the
   -webkit rules anyway. The colours are the ink ladder doing its usual jobs —
   a mark that is barely there at rest (faint, no track), lifting to soft over
   a wash track while the pointer is in the container.

   THE TWO PROPERTIES DO NOT BEHAVE ALIKE, and that is what shapes the three
   rules below. `scrollbar-color` inherits; `scrollbar-width` does not. So the
   rest colour is declared once on the shell and reaches every descendant —
   including a scroll container added next year, which was the whole point of
   the `.l-shell *` this replaces — while the width has to name its containers,
   because there is no way to inherit it to them. Verified rather than assumed:
   set on `.l-shell` alone, a `pre` inside it reported `scrollbar-width: auto`
   and the inherited colour.

   The HOVER also has to name them, for the opposite reason — it inherits, and
   set high it would light every bar on the page at once. The list is short
   because these are the only things inside the shell that scroll. */
.l-shell{scrollbar-color:var(--color-text-faint) transparent}
.l-shell :is(pre,textarea,output,dialog,.showcase-slide,.theme-menu){
  scrollbar-width:thin}
.l-shell :is(pre,textarea,output,dialog,.showcase-slide,.theme-menu):hover{
  scrollbar-color:var(--color-text-soft) var(--color-wash)}

/* ── layout ──────────────────────────────────────────────────────────────── */
.l-wrap{max-width:1240px;margin:0 auto;padding:0 var(--space-wrap)}

/* The shell holds the top of the page — nav, hero, showcase — and gives the
   three fixed backdrop layers one stacking context to share. */
.l-shell{position:relative;overflow:hidden;isolation:isolate;
  /* How tall the field is. Everything else derives from it, so this is the
     only number to touch. */
  --field-height:200vh}

/* The twelve-column grid, used by the range wall and by the grammar key. They
   were two rules with the same declarations and different names.

   Track alignment is NOT shared: the wall wants the default `stretch`, so two
   cards in a row match height whatever their figures do, and the grammar key
   wants `start`, so a short key list does not stretch to the height of three
   snippets beside it. */
.l-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--space-gap)}

/* The tiny uppercase label, in one place. Seven selectors carried these five
   declarations at three sizes; the size is the only thing that ever varied, so
   it is the only thing a modifier changes. Margins stay with the context that
   needs them — a label does not know what it is labelling. */
.label{font-family:var(--font-code);font-size:var(--font-size-label);
  letter-spacing:var(--letter-spacing-label);text-transform:uppercase;
  color:var(--color-text-soft)}
.label--small{font-size:9.5px;color:var(--color-text-faint)}

/* ── the backdrop ────────────────────────────────────────────────────────────
   Fixed, so it sits under the content for the whole scroll rather than
   scrolling away with the hero. That is also what makes the drift direction
   unambiguous: an in-flow field already moves up at scroll speed, so the
   keyframe's own -translateY *added* to that and the field raced the page
   instead of lagging it. Fixed starts it from a standstill, so the keyframe is
   the only motion there is. */
#bg-field{position:fixed;top:0;left:0;z-index:var(--z-field);pointer-events:none;
  width:100%;height:var(--field-height)}

/* The whole page's scroll maps to the whole field. At the top you are looking
   at the top of it; at the bottom of the page its bottom edge has just reached
   the bottom of the viewport, so it never uncovers and never sticks.

   That mapping is the *default* `animation-range`, which is 0%-100% of the
   scroll range — so there is deliberately no `animation-range` here. An
   earlier version pinned it to `0 1200px`, which finished the drift a screen
   in and then froze the field for the remaining six.

   Travel is therefore `100vh - --field-height` (negative: up), which puts the
   field's bottom edge exactly on the viewport's at 100% progress. At 200vh
   that is one viewport of travel over the page's ~7000px, so the field crawls
   at roughly a tenth of scroll speed.

   `scroll(root block)` is the document's own scroll progress: no scroll
   listener, no rAF, no getComputedStyle, and it runs off the main thread.

   Guarded by @supports because without scroll-timeline support the shorthand
   falls back to a 0s time-based animation and `both` would snap the field to
   its end state permanently. */
@supports (animation-timeline: scroll()){
  @media (prefers-reduced-motion: no-preference){
    @keyframes field-drift{
      to{transform:translate3d(0,calc(100vh - var(--field-height)),0)}}
    #bg-field{animation:field-drift linear both;
      animation-timeline:scroll(root block);
      /* Promoted explicitly. The transform is compositor-driven either way,
         but declaring it keeps the layer from being re-created when the canvas
         backing store is resized. */
      will-change:transform}
  }
}

/* The sections are a positioned layer so their *content* sits above the fixed
   field, but they paint no ground of their own — otherwise the field is only
   ever visible through the hero, and the page goes white the moment the hero
   scrolls off. The body's --color-ground is the page colour; the field draws
   over it; the panes inside each section are opaque and stay readable.
   Every section does this now, including the footer. */
.s-range,.s-core,.s-agents,.s-themes,.s-footer{position:relative;z-index:var(--z-field)}

/* Fixed like the field, so the glow follows the pointer down the whole page
   instead of scrolling off with the hero. */
.bg-bloom{position:fixed;inset:0;z-index:var(--z-backdrop);pointer-events:none;
  /* THE POINTER LIVES HERE, ON THE ONE ELEMENT THAT READS IT. It used to be
     declared on `.l-shell` and inherited down, which is the tidy-looking
     arrangement and was the expensive one: `.l-shell` wraps the nav, the hero
     and the showcase, so every write invalidated that whole subtree, and every
     pane in it was re-rendered sixty times a second for a glow that had not
     moved. Moving the pointer made the source and figure panes flicker, and it
     also filled the inspector's `.l-shell` with a style attribute that changed
     under you while you were trying to read it. Here the write touches one leaf
     with no children. (The panes carry `backdrop-filter` again — see the pane
     block — so an invalidation costs a backdrop read-back on top of the repaint
     once more, which is the sharper version of this. It was never worth it on
     the repaint alone either.) */
  --pointer-x:38%;--pointer-y:42%;
  /* Bigger and stronger than it was (37vmax at .10), and given the same white
     pool as the drifting balls, so the thing under the pointer reads as one of
     them rather than as a fainter effect of its own. */
  background:
    radial-gradient(46vmax 46vmax at var(--pointer-x) var(--pointer-y),
      var(--color-bloom-core) 0%, var(--color-bloom-mid) 40%, transparent 70%),
    radial-gradient(46vmax 46vmax at var(--pointer-x) var(--pointer-y),
      var(--color-bloom-pool) 0%, var(--color-pool-fade) 70%);
  /* Opacity, not position. The old `transition: background-position` never did
     anything — the centre lives inside the gradient (a background *image*), not
     in `background-position` — so the bloom always teleported to wherever the
     pointer first appeared. Arriving from another screen that reads as a flash.
     It now starts invisible and fades up once the pointer is actually here, so
     the jump happens while there is nothing to see. */
  opacity:0;transition:opacity .45s var(--ease-out)}
.l-shell.is-tracking .bg-bloom{opacity:1}

/* ── nav ─────────────────────────────────────────────────────────────────── */
.nav{position:relative;z-index:var(--z-nav);display:flex;align-items:center;
  gap:clamp(22px,2.8vw,42px);padding:clamp(18px,2.2vw,30px) 0}
.brand-mark{height:27px;width:auto;color:var(--color-brand);flex:none;display:block}
/* The mark's two dots are the accent, and they are the only part of it that
   does not take currentColor. They were the brand at .66 — a pale pink that
   read as the same colour going quiet; the point of a secondary is that it
   is a different colour, so they take the full accent rather than a wash. */
.brand-mark .mark-dots{fill:var(--color-accent)}
/* The anchor is the flex item now, not the mark inside it — so the `flex:none`
   on .brand-mark stopped applying the moment the nav's brand became a link.

   The pixel up is optical, not arithmetic: the mark's own descender (the tail
   of the L) puts more of its ink below the row's centre than above it, so a
   mark centred by its box reads as sitting low beside the caps next to it. */
.nav-mark{flex:none;display:flex;margin-top:-1px}
.nav ul{display:flex;gap:clamp(15px,2vw,31px);font-size:16px;font-weight:600;
  color:var(--color-ink)}
/* Nav hover is the ACCENT, and that ended the last of three recorded bends in
   "watermelon is identity and nothing else". Hover is an interface state, which
   is ink's job; the bend was that the mark's own colour read as "this is live"
   and that was worth more than the rule. A second colour is a better answer
   than an exception — the accent can say "live" without spending the identity
   on it. Both halves of the bar behave the same so the row stays one thing, and
   the same colour answers a pointer in the footer, the pager and the gallery's
   pen. */
.nav ul li{cursor:pointer;transition:color .15s ease}
.nav ul li:hover{color:var(--color-accent-deep)}
/* Two marks rather than two words, and the gap goes with them: --space-gap is
   the card-to-card measure, and 16px between two 32px circles that already
   carry their own air reads as a hole in the row. */
.nav-right{margin-left:auto;display:flex;align-items:center;gap:4px}
/* An icon-sized nav control: the link to the source, and the theme button. The
   same 32px circle the playground's toolbar wears, minus the paper and the
   cast — nothing in the bar has a ground, and giving these one would make two
   buttons of what is still a row of links. Grid-centred, so the mark sits on
   the row's midline whatever box the SVG claims.
   Ink at rest and brand on hover: the same colours as the links to their left,
   which is the recorded bend, not a new one. */
.nav-icon{display:grid;place-items:center;flex:none;width:38px;height:38px;
  border-radius:var(--radius-pill);color:var(--color-ink);
  transition:color .15s ease}
.nav-icon:hover{color:var(--color-accent-deep)}
/* A phone has no switcher: it follows the system whatever is stored
   (site.js, THEME_BOOT), the way the book's theme does, and the bar has no
   room for a control that would set what the phone then ignores. */
@media (max-width:767px){
  .nav [popovertarget="theme-menu"]{display:none}
}

/* The theme sheet. A [popover] arrives wearing the UA's own dialog frame — a
   solid border, .25em of padding, and `inset:0` with `margin:auto` centring it
   in the viewport — so all four are undone before this is a sheet at all. It
   is `position:fixed` because the top layer has no ancestor to be positioned
   against; site.js writes the top and right on the way open.

   Opaque, not glass: it is a menu over content rather than a surface with
   content on it, and a translucent list of three words over a drifting glow is
   a list you cannot read. The cast is `.sheet`'s. */
.theme-menu{position:fixed;inset:auto;margin:0;border:0;padding:6px;
  min-width:132px;background:var(--color-paper);color:var(--color-ink);
  border-radius:var(--radius-pane-in);
  box-shadow:10px 0 36px -18px var(--color-sheet-cast)}
/* The row's corner is the sheet's less the 6px between them, which is what
   keeps a hovered row concentric with the sheet it sits in. */
.theme-menu button{display:block;width:100%;text-align:left;font-size:13px;
  font-weight:500;padding:8px 14px;color:var(--color-text);
  border-radius:calc(var(--radius-pane-in) - 6px);
  transition:background-color var(--duration-hover) var(--ease-out),
             color var(--duration-hover) var(--ease-out)}
.theme-menu button:hover{background:var(--color-wash);color:var(--color-ink)}
/* The chosen row is brand text, the way the active pager pill is brand — this
   is the one row of the three that is not an offer. */
.theme-menu button[aria-pressed="true"]{color:var(--color-brand)}
/* Nothing moves on hover — only colour. A 1px lift on a pill is a very small
   distance over a very short time, which reads as a twitch rather than as a
   response, and it fought the drifting backdrop behind it.

   THE CAST IS ONE VALUE AT REST, HOVER AND ACTIVE, and that rule is the reason.
   A shadow that grows under a pointer is the 1px lift by another means — it
   says the pill rose — so it is the same call, not a new one. It was tried
   anyway and photographed: on the quiet CTA the hover inverts the whole button
   to a filled accent, and a deeper pool arriving under a pill that has just
   changed colour entirely is not a response anyone can read.

   No borders anywhere — the two buttons separate by weight and colour instead,
   which keeps the hero's edges soft against the moving field.

   AND THE CAST GOES ON BOTH, not on the filled one alone. The instinct is that
   a solid button may carry more weight than a ghost one, and it is the wrong
   way round here: the quiet CTA is white paper on a 93.5% ground and is the one
   of the pair with no edge of its own at all, so it is the one the cast is
   doing real work for. `Quick start` already separates by colour.

   THE PAIR SHARE THE SHADOW AND DIFFER IN ITS HUE, which is one shadow, not
   two. The geometry is stated once here and each variant passes its own colour
   in through `--color-cast`: rose under the brand-red button, violet under the
   quiet one, whose hover fills it with the violet accent and whose neighbour in
   the pager is violet at rest. A rose pool under a violet control is the same
   mistake a grey pool is under a rose one, one step further in. What would
   make two objects out of a row that is one is two *shadows*; a hue that
   follows the control it is under does the opposite. */
.cta-button{font-size:16px;border-radius:var(--radius-pill);padding:11px 26px;
  white-space:nowrap;display:inline-flex;align-items:center;
  --color-cast:var(--color-control-cast);
  box-shadow:0 10px 34px -12px var(--color-cast);
  transition:background-color var(--duration-hover) var(--ease-out),
             color var(--duration-hover) var(--ease-out)}
.cta-button--solid{background:var(--color-brand);color:var(--color-on-brand);
  font-weight:600}
.cta-button--solid:hover{background:var(--color-brand-hover)}
.cta-button--quiet{background:var(--color-paper);font-weight:500;
  --color-cast:var(--color-control-cast-cool)}
/* The quiet CTA INVERTS on hover rather than tinting — the same move the
   gallery's pen makes, arrived at the same way. A pale wash under it measured
   1.15:1 against its own paper ground: technically a colour change, in
   practice invisible, which is what was reported. Filling it is the only
   version of "more purple" that survives being 16px of type on a white pane.

   White on the accent solid is 3.92:1, under the 4.5 bar this size carries.
   Deliberate, and not a regression: `Quick start` sits directly beside it at
   3.55 on the brand, which this file already records as an open call. The two
   now miss the same bar for the same stated reason rather than one of them
   missing it quietly and alone. */
.cta-button--quiet:hover{background:var(--color-accent-solid);
  color:var(--color-on-accent)}

/* `run` is the flip button's twin, and deliberately so: both reveal what a
   source really is. It is brand while it has something to do — the same call
   the flip's pressed state makes, and for the same reason, that ink does not
   read at this size against glass. */
/* `.run--icon` is the landing carousel's; /play's `run` keeps its word, and
   play.css records why — the two controls that change what you are looking at
   say so, and the four that act on what is already there are marks. Here there
   is no toolbar to be one of: it is a single control on a pane of source, and
   a play triangle says `compile this` faster than three uppercase letters at
   9.5px do. The geometry is scoped to the modifier because play.css overrides
   this rule's box but not a width or a height. */
.run{font-family:var(--font-code);font-size:9.5px;
  letter-spacing:var(--letter-spacing-label);text-transform:uppercase;
  background:var(--color-accent-solid);color:var(--color-on-accent);
  padding:3px 10px 4px;border-radius:var(--radius-pill);
  transition:background-color var(--duration-hover) var(--ease-out),
             opacity var(--duration-hover) var(--ease-out)}
.run:not(:disabled):hover{background:var(--color-accent-solid-hover)}
.run--icon{width:26px;height:26px;padding:0;display:grid;place-items:center}
/* The bolt's polygon is symmetric about x=128 in its own 256 box, so unlike
   the play triangle it needed no optical nudge off centre. */
.run--icon svg{width:13px;height:13px;display:block}
.run:disabled{opacity:.45;cursor:default}
/* The carousel's run is PRESENT AND DISABLED rather than absent. Hiding it
   until the first keystroke meant the one control on a pane of source only
   existed for someone who had already guessed the pane was editable — the same
   secret-handshake failure the gallery's pen was moved out of. Dimmed, it is an
   affordance that says "there is something to press here, once you have given
   it something to do", and it wears the pen's resting look for exactly that
   reason: the two disabled controls on the site should be one idea.

   Not `opacity` — a translucent green pill on a drifting glow picks the glow up
   and reads as a faded button rather than an off one. */
.run--icon:disabled{opacity:1;background:var(--color-wash);
  color:var(--color-text-soft)}
.run[hidden]{display:none}

/* The editor, wherever it appears — the landing carousel's seven panes and
   /play's one. It is a transparent textarea over a highlighted <pre>, so the
   colour survives the switch into edit mode. Every metric that positions a
   glyph is declared once, here, for both: a single pixel of disagreement in
   font, size, line height, padding or wrapping and the caret drifts away from
   the letter it is sitting on. `white-space:pre` on both, so neither wraps and
   the shared scroll offset stays true. */
.editor{position:relative;width:100%;height:100%;min-height:0;
  --gutter:2.6em;cursor:text}
/* The .55em is breathing room between the gutter's tint and the first glyph —
   the code sat flush against the numbers' column. It offsets the text layers
   only: the gutter keeps its width, so the tint does not widen with it. */
/* 13/1.6 is the site's one code size, and all three layers of the editor have
   to carry it to the decimal or the ink drifts off the input it sits over. */
.editor-ink,
.editor-input{position:absolute;inset:0 0 0 calc(var(--gutter) + .55em);margin:0;border:0;
  padding:0;overflow:auto;white-space:pre;
  font-family:var(--font-code);font-size:13px;line-height:1.6;
  tab-size:2;background:none}

/* Line numbers, because the pane did not read as editable — it looked like a
   printout of a source rather than a place to type in one. A gutter is the
   cheapest thing that says editor, and it doubles as the ruler the compiler's
   diagnostics point at: they arrive as `line:col`.

   It shares the line height with the two layers beside it and scrolls with
   them, so the numbers stay level with the code they count. Not selectable —
   dragging across the source should not pick up the numbering. */
.editor-gutter{position:absolute;inset:0 auto 0 0;width:var(--gutter);
  overflow:hidden;text-align:right;padding-right:.9em;
  font-family:var(--font-code);font-size:13px;line-height:1.6;
  color:var(--color-text-faint);user-select:none;pointer-events:none}
.editor-gutter span{display:block}
/* The ink is the picture; the textarea is the input, and its own glyphs are
   invisible. The caret is not — it is the one thing the textarea still draws,
   so it keeps ink's colour. */
.editor-ink{color:var(--color-ink);pointer-events:none}
.editor-input{color:transparent;caret-color:var(--color-ink);resize:none;
  -webkit-text-fill-color:transparent}
.editor-input::selection{background:var(--color-brand-tint);color:transparent}
.editor-input:focus{outline:none}

/* Diagnostics sit under the source rather than over the figure. The figure
   keeps its last good state when a compile fails — blanking it would throw
   away the thing the edit is being compared against — so the error has to
   appear where the cause is. */
.editor-errors{display:block;position:absolute;left:16px;right:16px;bottom:14px;
  z-index:2;max-height:38%;overflow:auto;
  background:var(--color-brand-tint);border-radius:var(--radius-pane-in);
  padding:9px 12px;font-family:var(--font-code);font-size:11.5px;
  line-height:1.5;color:var(--color-brand-deep)}
.editor-errors[hidden]{display:none}
.editor-error + .editor-error{margin-top:3px}
.editor-error--warning{color:var(--color-text-soft)}

/* The six `.tok-*` classes that used to live here are gone with the scanner
   that emitted them; the colours are bound to `--lini-tok-*` up in :root now.
   All that is left is the one thing the generated sheet has no opinion about:
   an operator is the only token whose FORM is the look, so it carries weight
   as well as colour. The key and the snippets share this because they share
   the class, which is the property the old pair had to be tested to keep. */
.lini-tok-op{font-weight:500}

/* ── a figure's own paper gets a corner ────────────────────────────────────
   A Lini scene paints no background it was not given, so most figures here
   are transparent and this rule finds nothing. The six that DO bring paper —
   a schematic's cream sheet, a drawing's white — emit it as a full-bleed
   `<rect class="lini-canvas">`, and a square plate laid on a rounded pane is
   the one corner on the site that does not agree with the corner beside it.

   `rx` / `ry` are SVG geometry properties, so CSS can set them, and the value
   is in the figure's own user units: it scales with the figure rather than
   staying a fixed screen radius, which is what keeps a small specimen and a
   large one looking like the same treatment. Only inlined SVG is reachable —
   a wall card is an `<img>` and keeps whatever its source gave it. */
.lini-canvas{rx:10px;ry:10px}

.copy{flex:none;font-family:var(--font-code);font-size:10px;
  letter-spacing:var(--letter-spacing-label);text-transform:uppercase;
  color:var(--color-text-soft);background:none;
  padding:6px 12px;border-radius:var(--radius-pill);
  transition:background-color var(--duration-hover) var(--ease-out),
             color var(--duration-hover) var(--ease-out)}
.copy:hover{background:var(--color-wash)}
.copy.is-done{background:var(--color-brand);color:var(--color-on-brand)}

/* `.flip` is /play's `desugar` toggle, and only that. It was worn in two
   places — the landing page's beats floated one in a figure's corner to swap
   the drawing for its source — and that composition is gone; the appearance
   stays here beside its toolbar siblings `.run` and `.copy` because that is
   where a control's look belongs, not because two callers still share it. */
.flip{font-family:var(--font-code);font-size:9.5px;
  letter-spacing:var(--letter-spacing-label);text-transform:uppercase;
  color:var(--color-text-faint);padding:2px 9px 3px;border-radius:var(--radius-pill);
  transition:background-color var(--duration-hover) var(--ease-out),
             color var(--duration-hover) var(--ease-out)}
.flip:hover{background:var(--color-wash);color:var(--color-text-soft)}
/* Pressed is a state, and states are ink's job — except that this one has to
   read at 9.5px against glass, where an ink tint does not. It is the one
   control on the site that is brand while active. The pager's pill used to be
   the other; it went accent with the rest of the interface states, so this is
   now a bend on its own and worth revisiting if `.flip` ever has a second
   caller again. */
.flip[aria-pressed="true"]{background:var(--color-brand);color:var(--color-on-brand)}

/* ══ footer ════════════════════════════════════════════════════════════════
   No rule across the top. The page's separator has been space and surface
   throughout, and a footer is the one place a border feels so conventional
   that it almost slips past — it would still be a line drawn between two
   things. The gap above is doing it. */
.s-footer{position:relative;z-index:var(--z-field);
  padding:0 0 clamp(36px,5vw,60px)}
.footer-grid{display:grid;gap:clamp(28px,4vw,64px);
  grid-template-columns:minmax(0,1.6fr) repeat(2,minmax(0,1fr));
  align-items:start}
/* The mark is a link now — every wordmark on the site goes home (shell.rs,
   SITE) — and an inline-block keeps the margin the mark had under it. */
.footer-mark{display:inline-block;color:inherit}
.footer-brand .brand-mark{height:24px;margin-bottom:14px}
.footer-brand p{margin:0;font-size:14px;color:var(--color-text-soft);max-width:34ch}
.footer-col .label{display:block;margin-bottom:12px}
.footer-col li{margin-top:9px;font-size:14.5px;color:var(--color-text)}
.footer-col a{cursor:pointer;transition:color .15s ease}
.footer-col a:hover{color:var(--color-accent-deep)}
/* One column for the brand, two for the links. Stacking all three read as
   three quarters of empty page for two lists of three — the columns are short
   enough to sit side by side at any width the footer is ever shown at, and
   putting them there is what ends the scroll. */
@media (max-width:620px){
  .footer-grid{grid-template-columns:repeat(2,minmax(0,1fr));
    gap:clamp(24px,6vw,40px) var(--space-gap)}
  .footer-brand{grid-column:1 / -1}
  /* A pill sized for a desktop row is a slab on a phone, where two of them
     take most of the column. A point of type and four of padding either side. */
  .cta-button{font-size:15px;padding:10px 22px}
}

@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  *{animation:none !important;transition:none !important}
}

/* ══ panes ═════════════════════════════════════════════════════════════════
   Deliberately at the end of the file. The component classes above set layout
   for these same elements, and at equal specificity the later rule wins — an
   earlier attempt at exactly this kind of sweep was placed near the top and
   silently lost to `.range{background:...}` two hundred lines below it.
   Nothing above sets a background any more, so the win is no longer
   load-bearing, but the order still is.

   THE GLASS READS ITS BACKDROP AGAIN, AT 6px, AND THAT IS A FLASH RISK TAKEN
   ON PURPOSE. The declaration was deleted outright one revision ago because it
   was the page's flash — the nav and the figure pane blanking for a single
   frame, irregularly, on the landing page and on /play, and on none of the
   book's, which carry neither a pane nor a glow. That diagnosis stands and is
   written out below; what has changed is the verdict on the price. A pane with
   nothing behind it softened is not glass, it is a white card with the
   backdrop's drawing running through it at full contrast — in dark, where the
   glass rests at .11, a 4px field line crosses the source pane as a lit white
   stroke through the code. That is worse to look at, every second the page is
   open, than a one-frame blank that arrives now and then. So the blur is back
   on the pages that are being *read* — the landing page and /gallery — and
   /play, which is a tool someone works in for an hour, stays flat. play.css
   turns it off there and says so.

   `backdrop-filter` is not a paint, it is a read-back. Chromium has to render
   everything beneath the element into its own render surface, blur that, and
   only then draw the element and whatever paints above it — which splits one
   flat list of composited quads into a *chain* of render passes that have to be
   produced in order, every frame. Behind these panes sits the glow field, four
   large transform animations that never stop, plus (on the landing page) a
   canvas redrawn every frame; so the read-back can never reuse a previous
   result. When a link in that chain misses its frame, everything in that pass
   is simply absent — which is why a *nav* with no filter of its own went blank
   alongside the pane: it paints above the panes, so it rode the same pass. The
   editor, which owns its own scrolling layer, never did.

   That is a race, not a schedule, and the measured symptoms fit only the race:
   no long frames at all over minutes (165 Hz, worst frame 6.9 ms, zero over
   13 ms), no periodic main-thread work of any kind, and the flash irregular and
   bursty rather than on a beat. See the glow block below for the theory this
   replaces, which was wrong. Nothing here shortens that chain; a page with this
   declaration on it can flash, and the two that carry it are expected to.

   6px, NOT THE 7 IT WAS. Blur only touches detail finer than its radius and the
   backdrop has almost none — a flat ground, and glows that fade over hundreds
   of pixels, which a blur of any of these radii leaves bit-identical. The whole
   contribution is the field lines where they cross a pane: measured frame
   against frame, under 1/255 mean, 3/255 at the 99th percentile and 6/255 at
   worst in light; 6/255 and 14/255 in dark. Small in aggregate and the entire
   point locally. 5, 6 and 7 were compared on the same frozen dark page over a
   line that crosses the source pane: at 5 the elbow of the line still resolves
   as a drawn corner, at 6 it is a smudge behind glass, and 7 is barely
   distinguishable from 6. 6 is the smallest radius that does the job, which is
   also the least a read-back can be asked to do.

   A FILTER ALSO MAKES A PANE A STACKING CONTEXT, which is a side effect worth
   knowing about rather than relying on. While it was here, every pane was
   painted in the positioned phase at z-index 0 in tree order — after
   `.bg-glows` — and so cleared the fixed backdrop layers without any rule
   saying it should. /play's working area was living on that, and the glows
   painted over its editor and figure the moment the filter went. `.play`
   carries `--z-field` explicitly now (play.css). Nothing on this site should
   depend on a filter for its paint order: the tier scale above is where that is
   decided, and content that sits over a backdrop opts into it.

   Not a flat white at 70%. Three things besides the blur make it read as glass
   rather than as a pane someone forgot to finish — and they are what /play is
   left with, which is why a flat pane there is still a pane:

     · a gradient down the pane, most opaque at the top-left and thinnest at the
       bottom, so the backdrop shows through unevenly the way it would under
       real glass rather than uniformly the way it does under a tint;
     · the last stop carries a whisper of brand (350 at 99% L) instead of going
       grey, so the thinnest part of the pane warms toward the glow behind it;
     · a soft brand cast underneath instead of a neutral drop shadow — 2-3% at
       a long blur and a heavy negative spread, so it reads as the glow pooling
       under the pane, not as an edge.

   That last one is a knowing bend of "the ground moves instead". It is doing a
   different job here: the ground still moves, and this only grounds the glass
   against it.

   One class, applied in the markup, rather than a list of five component
   selectors swept at the bottom of the file. */
.pane{
  background:linear-gradient(155deg,
    var(--color-glass-a) 0%,
    var(--color-glass-b) 55%,
    var(--color-glass-c) 100%);
  -webkit-backdrop-filter:blur(6px);
  backdrop-filter:blur(6px);
  box-shadow:0 18px 54px -24px var(--color-glass-cast);
  /* The glass edge. This is the one place the site now carries a literal 1px
     border, and it is not the thing the no-borders rule was written against:
     that rule exists so surfaces do the separating instead of lines drawn
     between them, and a translucent white edge is not separating this pane from
     anything — it is the lit rim of the sheet itself, which is what stops glass
     from reading as a flat tint. It only works because it is white-on-white:
     against the ground it disappears, and it only shows where the pane crosses
     a field line or a glow. */
  border:1px solid var(--color-glass-rim);
}

/* pane in pane: lit rather than filled or outlined.
   Third answer to the same question. It was a step darker than the sheet it sat
   on (--color-sunk), then white with a 1px hairline — and the hairline worked
   but cost the site's no-borders rule, which is one of the load-bearing ones.
   A soft brand cast does the same separating without a line to defend: offset
   sideways rather than down, blurred wide and pulled back hard by the negative
   spread, so what lands is a hint of the glow pooling beside the pane instead
   of a drop shadow under it. `no borders` is intact again; `no shadow` is bent
   the same way the glass panes above bend it, and for the same reason. */
.sheet{background:var(--color-paper);border-radius:var(--radius-pane-in);
  padding:15px 16px;box-shadow:10px 0 36px -18px var(--color-sheet-cast)}


/* ══ the glow field ════════════════════════════════════════════════════════
   Four soft watermelon balls behind everything, drifting. They are what makes
   a white pane legible as a pane: against a flat ground a white card on a
   near-white page has no edge, and the fix was a shadow that looked like a
   shadow. A ground that *moves* separates the layers by parallax instead —
   watch for a second and the panes are obviously in front of something.

   Two motions compose, which is why the layer is nested. .bg-glows-layer
   carries the scroll-driven parallax; each ball carries its own slow, endless
   drift on a different period (no shared factors), so the pattern never
   repeats visibly. Putting both on one element would not work — the second
   `transform` animation would simply replace the first.

   Cheap on purpose: a radial-gradient fading to transparent is soft without
   `filter: blur()`, and only `transform` animates, so the whole layer stays on
   the compositor. */
.bg-glows{position:fixed;inset:0;z-index:var(--z-backdrop);pointer-events:none;
  overflow:hidden}
.bg-glows-layer{position:absolute;inset:0}
.bg-glows i{position:absolute;display:block;
  width:var(--glow-size);height:var(--glow-size);
  left:var(--glow-x);top:var(--glow-y);
  margin:calc(var(--glow-size) / -2) 0 0 calc(var(--glow-size) / -2);
  /* Two stacked gradients, brand over white — first listed paints on top.
     One translucent brand gradient alone composites against whatever is behind
     it, which is the page ground, so at a 95% grey the balls came out dusty:
     the grey was mixing into every one of them and reading as "the glow is
     darker". The white layer gives each ball its own near-white pool to sit on,
     so the brand mixes with white instead, and the grey is left doing its job
     between the balls rather than inside them. Both fade out over the same
     radius, so there is no halo where one outlasts the other. */
  background:
    radial-gradient(circle closest-side,
      var(--glow-color) 0%, var(--color-glow-fade) 100%),
    radial-gradient(circle closest-side,
      var(--color-glow-pool) 0%, var(--color-pool-fade) 100%);
  animation:var(--glow-keyframes) var(--glow-duration) ease-in-out infinite alternate;
  will-change:transform}

/* Travel is what makes the motion visible, not size or duration alone. At
   62vmax moving 28vw these read as completely static — a very large, very soft
   gradient shifted by a fraction of its own width looks identical to one that
   never moved. The travel is roughly doubled now, which is what lets the balls
   stay full size and still obviously move. Periods are coprime so the
   composite never visibly repeats.

   THE TURN OF AN `alternate` ANIMATION WAS NOT THE PAGE'S FLASH, THOUGH THIS
   BLOCK ONCE SAID IT WAS. The reasoning was that every period is an iteration
   boundary where the compositor reverses the ball, that four short periods put
   a boundary roughly once a second, and that this matched the cadence of the
   flash. The periods were lengthened from 3/4/5/7s on that account and the
   flash carried on, which should have settled it and did not.

   It is settled now. Driven at 250ms — sixteen turns a second, full travel,
   so better than a quarter of all frames would have had to be bad — not one
   captured frame was blank. The turn costs nothing: to the compositor an
   `alternate` animation is one curve sampled by time, and a reversal is not an
   event in it. The flash was the panes' `backdrop-filter` — which is back, at
   6px and on two page kinds, as a known cost; the pane block above says why.
   These periods are not the lever on it either way.

   The long periods stay, on their own merit rather than that one: half a minute
   of travel is the "slow, endless" drift this block always described, and 3
   seconds was never it. */
.bg-glows i:nth-child(1){--glow-x:20%;--glow-y:24%;--glow-size:62vmax;
  --glow-color:light-dark(hsl(350 77% 62% / .22), hsl(350 85% 66% / .14));--glow-keyframes:glow-a;--glow-duration:31s}
.bg-glows i:nth-child(2){--glow-x:78%;--glow-y:34%;--glow-size:52vmax;
  --glow-color:light-dark(hsl(350 77% 62% / .18), hsl(350 85% 66% / .12));--glow-keyframes:glow-b;--glow-duration:37s}
.bg-glows i:nth-child(3){--glow-x:40%;--glow-y:74%;--glow-size:58vmax;
  --glow-color:light-dark(hsl(350 77% 62% / .17), hsl(350 85% 66% / .11));--glow-keyframes:glow-c;--glow-duration:41s}
.bg-glows i:nth-child(4){--glow-x:66%;--glow-y:86%;--glow-size:46vmax;
  --glow-color:light-dark(hsl(350 77% 62% / .15), hsl(350 85% 66% / .10));--glow-keyframes:glow-d;--glow-duration:47s}

@keyframes glow-a{from{transform:translate3d(-26vw,-16vh,0)}
                  to{transform:translate3d(30vw,20vh,0)}}
@keyframes glow-b{from{transform:translate3d(24vw,22vh,0)}
                  to{transform:translate3d(-28vw,-18vh,0)}}
@keyframes glow-c{from{transform:translate3d(-22vw,24vh,0)}
                  to{transform:translate3d(28vw,-22vh,0)}}
@keyframes glow-d{from{transform:translate3d(20vw,-20vh,0)}
                  to{transform:translate3d(-26vw,18vh,0)}}

/* The glow layer parallaxes too, but faster than the line field — two
   backdrops moving at different rates is what reads as depth rather than as
   one printed sheet sliding past. */
@supports (animation-timeline: scroll()){
  @media (prefers-reduced-motion: no-preference){
    @keyframes glows-drift{to{transform:translate3d(0,-26vh,0)}}
    .bg-glows-layer{animation:glows-drift linear both;
      animation-timeline:scroll(root block);
      will-change:transform}
  }
}
@media (prefers-reduced-motion: reduce){.bg-glows i{animation:none}}
